diff --git a/DEPENDENCIES b/DEPENDENCIES index 95dc77e4..877bcbd7 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -1,4 +1,4 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 614dd74eb014f50b4b83d81db3a39de43ffe83d0 -blaze https://github.com/sourcemeta/blaze 87b62693c610f7a07efb63d1a8baf22e99571aa9 +core https://github.com/sourcemeta/core af2b932ef6284c880f47012749b78bb75526f7a4 +blaze https://github.com/sourcemeta/blaze a5a019aa49139e7cd21accd52ad2c65f66afff16 bootstrap https://github.com/twbs/bootstrap 1a6fdfae6be09b09eaced8f0e442ca6f7680a61e diff --git a/src/compiler/mapper/number_arbitrary.h b/src/compiler/mapper/number_arbitrary.h index a8137925..eb76f5a6 100644 --- a/src/compiler/mapper/number_arbitrary.h +++ b/src/compiler/mapper/number_arbitrary.h @@ -25,7 +25,7 @@ class NumberArbitrary final : public sourcemeta::blaze::SchemaTransformRule { sourcemeta::core::JSON &schema, [[maybe_unused]] const sourcemeta::blaze::SchemaTransformRule::Result &result) const -> void override { - make_encoding(schema, "DOUBLE_VARINT_TUPLE", + make_encoding(schema, "NUMBER_VARINT_TUPLE", sourcemeta::core::JSON::make_object()); } }; diff --git a/src/runtime/decoder_any.cc b/src/runtime/decoder_any.cc index 806d67b9..4c0fa9c0 100644 --- a/src/runtime/decoder_any.cc +++ b/src/runtime/decoder_any.cc @@ -65,6 +65,8 @@ auto Decoder::ANY_PACKED_TYPE_TAG_BYTE_PREFIX( return sourcemeta::core::JSON{true}; case SUBTYPE_NUMBER: return this->DOUBLE_VARINT_TUPLE({}); + case SUBTYPE_DECIMAL: + return this->NUMBER_VARINT_TUPLE({}); case SUBTYPE_POSITIVE_REAL_INTEGER_BYTE: return sourcemeta::core::JSON{static_cast(this->get_byte())}; case SUBTYPE_POSITIVE_INTEGER: diff --git a/src/runtime/decoder_common.cc b/src/runtime/decoder_common.cc index 282375ff..effd60c6 100644 --- a/src/runtime/decoder_common.cc +++ b/src/runtime/decoder_common.cc @@ -36,6 +36,7 @@ auto Decoder::read(const Encoding &encoding) -> sourcemeta::core::JSON { HANDLE_DECODING(19, ROOF_TYPED_ARRAY) HANDLE_DECODING(20, FIXED_TYPED_ARBITRARY_OBJECT) HANDLE_DECODING(21, VARINT_TYPED_ARBITRARY_OBJECT) + HANDLE_DECODING(22, NUMBER_VARINT_TUPLE) #undef HANDLE_DECODING default: // We should never get here. If so, it is definitely a bug diff --git a/src/runtime/decoder_number.cc b/src/runtime/decoder_number.cc index 2e55b87b..a9b90999 100644 --- a/src/runtime/decoder_number.cc +++ b/src/runtime/decoder_number.cc @@ -1,5 +1,8 @@ #include +#include + +#include // std::isfinite #include // std::int64_t, std::uint64_t #if defined(__GNUC__) && !defined(__clang__) @@ -23,4 +26,38 @@ auto Decoder::DOUBLE_VARINT_TUPLE( return sourcemeta::core::JSON{static_cast(digits) / divisor}; } +auto Decoder::NUMBER_VARINT_TUPLE( + [[maybe_unused]] const struct NUMBER_VARINT_TUPLE &options) + -> sourcemeta::core::JSON { +#ifdef __clang__ +#pragma clang fp reciprocal(off) +#endif + const std::int64_t coefficient{this->get_varint_zigzag()}; + const std::uint64_t tag{this->get_varint()}; + const std::int64_t exponent{ + sourcemeta::core::zigzag_decode(tag >> 1)}; + + if ((tag & 1) != 0) { + return sourcemeta::core::JSON{ + sourcemeta::core::Decimal{coefficient}.scale_by( + sourcemeta::core::Decimal{exponent})}; + } + + // A scale that has grown past the range of the type can only keep growing, + // so the loop stops there rather than counting out an exponent that a + // corrupt stream may have made arbitrarily large + const std::uint64_t shifts{exponent < 0 + ? static_cast(-exponent) + : static_cast(exponent)}; + double scale{1.0}; + for (std::uint64_t index = 0; index < shifts && std::isfinite(scale); + ++index) { + scale *= 10.0; + } + + return sourcemeta::core::JSON{exponent < 0 + ? static_cast(coefficient) / scale + : static_cast(coefficient) * scale}; +} + } // namespace sourcemeta::jsonbinpack diff --git a/src/runtime/encoder_any.cc b/src/runtime/encoder_any.cc index 6f97c019..17242e9b 100644 --- a/src/runtime/encoder_any.cc +++ b/src/runtime/encoder_any.cc @@ -114,6 +114,10 @@ auto Encoder::ANY_PACKED_TYPE_TAG_BYTE_PREFIX( static_cast(subtype << TYPE_SIZE)); this->put_varint(absolute); } + } else if (document.is_decimal()) { + this->put_byte(TYPE_OTHER | + static_cast(SUBTYPE_DECIMAL << TYPE_SIZE)); + this->NUMBER_VARINT_TUPLE(document, {}); } else if (document.is_string()) { const sourcemeta::core::JSON::String &value{document.to_string()}; const auto size{document.byte_size()}; diff --git a/src/runtime/encoder_common.cc b/src/runtime/encoder_common.cc index 2a73947f..1b7d3d60 100644 --- a/src/runtime/encoder_common.cc +++ b/src/runtime/encoder_common.cc @@ -38,6 +38,7 @@ auto Encoder::write(const sourcemeta::core::JSON &document, HANDLE_ENCODING(19, ROOF_TYPED_ARRAY) HANDLE_ENCODING(20, FIXED_TYPED_ARBITRARY_OBJECT) HANDLE_ENCODING(21, VARINT_TYPED_ARBITRARY_OBJECT) + HANDLE_ENCODING(22, NUMBER_VARINT_TUPLE) #undef HANDLE_ENCODING default: // We should never get here. If so, it is definitely a bug diff --git a/src/runtime/encoder_number.cc b/src/runtime/encoder_number.cc index a1b499e7..7118af90 100644 --- a/src/runtime/encoder_number.cc +++ b/src/runtime/encoder_number.cc @@ -2,8 +2,79 @@ #include -#include // assert -#include // std::uint64_t +#include // assert +#include // std::from_chars +#include // std::size_t +#include // std::int64_t, std::uint64_t +#include // std::numeric_limits +#include // std::string_view +#include // std::errc + +namespace { + +#ifndef NDEBUG +// The widest coefficient that still fits in a signed 64-bit integer +constexpr std::size_t MAXIMUM_COEFFICIENT_DIGITS{19}; +#endif + +// The coefficient and the base-10 exponent of an arbitrary precision decimal. +// Core keeps both of them private and only spells them out through the +// scientific form, as `[-]D[.DDDD]e(+|-)ADJUSTED`, whose exponent is adjusted +// to count from the leading digit rather than from the last one +auto decimal_coefficient(const sourcemeta::core::Decimal &value, + std::int64_t &exponent) -> std::int64_t { + const auto scientific{value.reduce().to_scientific_string()}; + const std::string_view view{scientific}; + const auto marker{view.find('e')}; + assert(marker != std::string_view::npos); + + auto mantissa{view.substr(0, marker)}; + const auto negative{mantissa.front() == '-'}; + if (negative) { + mantissa.remove_prefix(1); + } + + // Every digit past the leading one sits after a decimal mark that the + // adjusted exponent already accounts for + const auto point{mantissa.find('.')}; + const auto leading{mantissa.substr( + 0, point == std::string_view::npos ? mantissa.size() : point)}; + const auto trailing{point == std::string_view::npos + ? std::string_view{} + : mantissa.substr(point + 1)}; + + const auto digits{leading.size() + trailing.size()}; + assert(digits >= 1); + assert(digits <= MAXIMUM_COEFFICIENT_DIGITS); + + std::uint64_t magnitude{0}; + for (const auto digit : leading) { + magnitude = (magnitude * 10) + static_cast(digit - '0'); + } + for (const auto digit : trailing) { + magnitude = (magnitude * 10) + static_cast(digit - '0'); + } + + assert(magnitude <= + static_cast(std::numeric_limits::max())); + + auto adjusted_exponent{view.substr(marker + 1)}; + if (adjusted_exponent.front() == '+') { + adjusted_exponent.remove_prefix(1); + } + + std::int64_t adjusted{0}; + [[maybe_unused]] const auto conversion{std::from_chars( + adjusted_exponent.data(), + adjusted_exponent.data() + adjusted_exponent.size(), adjusted)}; + assert(conversion.ec == std::errc{}); + + exponent = adjusted - static_cast(digits - 1); + const auto coefficient{static_cast(magnitude)}; + return negative ? -coefficient : coefficient; +} + +} // namespace namespace sourcemeta::jsonbinpack { @@ -19,4 +90,26 @@ auto Encoder::DOUBLE_VARINT_TUPLE( this->put_varint(point_position); } +auto Encoder::NUMBER_VARINT_TUPLE( + const sourcemeta::core::JSON &document, + [[maybe_unused]] const struct NUMBER_VARINT_TUPLE &options) -> void { + assert(document.is_real() || document.is_decimal()); + const auto decimal{document.is_decimal()}; + std::int64_t coefficient{0}; + std::int64_t exponent{0}; + + if (decimal) { + coefficient = decimal_coefficient(document.to_decimal(), exponent); + } else { + std::uint64_t point_position; + coefficient = sourcemeta::core::real_digits( + document.to_real(), point_position); + exponent = -static_cast(point_position); + } + + this->put_varint_zigzag(coefficient); + this->put_varint((sourcemeta::core::zigzag_encode(exponent) << 1) | + static_cast(decimal)); +} + } // namespace sourcemeta::jsonbinpack diff --git a/src/runtime/include/sourcemeta/jsonbinpack/runtime_decoder.h b/src/runtime/include/sourcemeta/jsonbinpack/runtime_decoder.h index e30e3c7d..9327cb5b 100644 --- a/src/runtime/include/sourcemeta/jsonbinpack/runtime_decoder.h +++ b/src/runtime/include/sourcemeta/jsonbinpack/runtime_decoder.h @@ -34,6 +34,7 @@ class SOURCEMETA_JSONBINPACK_RUNTIME_EXPORT Decoder : private InputStream { // Number DECLARE_ENCODING(DOUBLE_VARINT_TUPLE) + DECLARE_ENCODING(NUMBER_VARINT_TUPLE) // Any DECLARE_ENCODING(BYTE_CHOICE_INDEX) diff --git a/src/runtime/include/sourcemeta/jsonbinpack/runtime_encoder.h b/src/runtime/include/sourcemeta/jsonbinpack/runtime_encoder.h index ccb2965c..0ec4eaf3 100644 --- a/src/runtime/include/sourcemeta/jsonbinpack/runtime_encoder.h +++ b/src/runtime/include/sourcemeta/jsonbinpack/runtime_encoder.h @@ -36,6 +36,7 @@ class SOURCEMETA_JSONBINPACK_RUNTIME_EXPORT Encoder : private OutputStream { // Number DECLARE_ENCODING(DOUBLE_VARINT_TUPLE) + DECLARE_ENCODING(NUMBER_VARINT_TUPLE) // Any DECLARE_ENCODING(BYTE_CHOICE_INDEX) diff --git a/src/runtime/include/sourcemeta/jsonbinpack/runtime_encoding.h b/src/runtime/include/sourcemeta/jsonbinpack/runtime_encoding.h index 9c543e3a..110277c5 100644 --- a/src/runtime/include/sourcemeta/jsonbinpack/runtime_encoding.h +++ b/src/runtime/include/sourcemeta/jsonbinpack/runtime_encoding.h @@ -23,6 +23,7 @@ struct FLOOR_MULTIPLE_ENUM_VARINT; struct ROOF_MULTIPLE_MIRROR_ENUM_VARINT; struct ARBITRARY_MULTIPLE_ZIGZAG_VARINT; struct DOUBLE_VARINT_TUPLE; +struct NUMBER_VARINT_TUPLE; struct BYTE_CHOICE_INDEX; struct LARGE_CHOICE_INDEX; struct TOP_LEVEL_BYTE_CHOICE_INDEX; @@ -55,7 +56,8 @@ using Encoding = std::variant< BOUNDED_8BIT_PREFIX_UTF8_STRING_SHARED, RFC3339_DATE_INTEGER_TRIPLET, PREFIX_VARINT_LENGTH_STRING_SHARED, FIXED_TYPED_ARRAY, BOUNDED_8BITS_TYPED_ARRAY, FLOOR_TYPED_ARRAY, ROOF_TYPED_ARRAY, - FIXED_TYPED_ARBITRARY_OBJECT, VARINT_TYPED_ARBITRARY_OBJECT>; + FIXED_TYPED_ARBITRARY_OBJECT, VARINT_TYPED_ARBITRARY_OBJECT, + NUMBER_VARINT_TUPLE>; // The encoding names are the ones the JSON BinPack specification // defines, so they keep their casing rather than following the C++ @@ -265,6 +267,57 @@ struct ARBITRARY_MULTIPLE_ZIGZAG_VARINT { // clang-format on struct DOUBLE_VARINT_TUPLE {}; +// clang-format off +/// @brief The encoding consists of a sequence of two integers. The first is +/// the signed coefficient of the number as a ZigZag-encoded Base-128 64-bit +/// Little Endian variable-length unsigned integer. The second is the base-10 +/// exponent that the coefficient is scaled by, ZigZag-encoded and shifted left +/// by one bit, whose least significant bit is set when the number is an +/// arbitrary-precision decimal rather than a double, as a Base-128 64-bit +/// Little Endian variable-length unsigned integer. +/// +/// Unlike DOUBLE_VARINT_TUPLE, this encoding covers both of the numeric +/// representations that a JSON document may hold, and the schema-driven mode +/// relies on that: a schema that permits a number cannot tell which of the two +/// an instance will carry, so the distinction travels with the data. The +/// exponent is signed so that magnitudes a decimal mark cannot reach, such as +/// 1e300, remain within the encoding. +/// +/// ### Options +/// +/// None +/// +/// ### Conditions +/// +/// The coefficient must fit in a signed 64-bit integer, which holds every +/// number of up to 19 significant digits +/// +/// ### Examples +/// +/// Given the decimal input value 2.1, the encoding results in the +/// variable-length integer 42 (the ZigZag encoding of 21) followed by the +/// variable-length unsigned integer 3 (the ZigZag encoding of the exponent -1, +/// shifted left by one bit, with the decimal bit set). +/// +/// ``` +/// +------+------+ +/// | 0x2a | 0x03 | +/// +------+------+ +/// ``` +/// +/// Given the double input value 3.14, the encoding results in the +/// variable-length integer 628 (the ZigZag encoding of 314) followed by the +/// variable-length unsigned integer 6 (the ZigZag encoding of the exponent -2, +/// shifted left by one bit, with the decimal bit clear). +/// +/// ``` +/// +------+------+------+ +/// | 0xf4 | 0x04 | 0x06 | +/// +------+------+------+ +/// ``` +// clang-format on +struct NUMBER_VARINT_TUPLE {}; + /// @} /// @ingroup runtime @@ -445,6 +498,7 @@ constexpr std::uint8_t SUBTYPE_LONG_STRING_BASE_EXPONENT_7 = 0b00000111; constexpr std::uint8_t SUBTYPE_LONG_STRING_BASE_EXPONENT_8 = 0b00001000; constexpr std::uint8_t SUBTYPE_LONG_STRING_BASE_EXPONENT_9 = 0b00001001; constexpr std::uint8_t SUBTYPE_LONG_STRING_BASE_EXPONENT_10 = 0b00001010; +constexpr std::uint8_t SUBTYPE_DECIMAL = 0b00001011; static_assert(SUBTYPE_FALSE <= sourcemeta::core::uint_max); static_assert(SUBTYPE_TRUE <= sourcemeta::core::uint_max); @@ -464,6 +518,7 @@ static_assert(SUBTYPE_LONG_STRING_BASE_EXPONENT_9 <= sourcemeta::core::uint_max); static_assert(SUBTYPE_LONG_STRING_BASE_EXPONENT_10 <= sourcemeta::core::uint_max); +static_assert(SUBTYPE_DECIMAL <= sourcemeta::core::uint_max); // Note that the binary values actually match the declared exponents static_assert(SUBTYPE_LONG_STRING_BASE_EXPONENT_7 == 7); diff --git a/src/runtime/loader.cc b/src/runtime/loader.cc index 2f5b61d5..d340da36 100644 --- a/src/runtime/loader.cc +++ b/src/runtime/loader.cc @@ -29,6 +29,7 @@ auto load(const sourcemeta::core::JSON &input) -> Encoding { PARSE_ENCODING(v1, ARBITRARY_MULTIPLE_ZIGZAG_VARINT) // Numbers PARSE_ENCODING(v1, DOUBLE_VARINT_TUPLE) + PARSE_ENCODING(v1, NUMBER_VARINT_TUPLE) // Any PARSE_ENCODING(v1, BYTE_CHOICE_INDEX) PARSE_ENCODING(v1, LARGE_CHOICE_INDEX) diff --git a/src/runtime/loader_v1_number.h b/src/runtime/loader_v1_number.h index 8b8ebcc1..b7582c08 100644 --- a/src/runtime/loader_v1_number.h +++ b/src/runtime/loader_v1_number.h @@ -16,6 +16,11 @@ auto DOUBLE_VARINT_TUPLE([[maybe_unused]] const sourcemeta::core::JSON &options) return sourcemeta::jsonbinpack::DOUBLE_VARINT_TUPLE{}; } +auto NUMBER_VARINT_TUPLE([[maybe_unused]] const sourcemeta::core::JSON &options) + -> Encoding { + return sourcemeta::jsonbinpack::NUMBER_VARINT_TUPLE{}; +} + // NOLINTEND(readability-identifier-naming) } // namespace sourcemeta::jsonbinpack::v1 diff --git a/test/compiler/2020_12_compiler_number_test.cc b/test/compiler/2020_12_compiler_number_test.cc index f5cdad9a..9e6d21e8 100644 --- a/test/compiler/2020_12_compiler_number_test.cc +++ b/test/compiler/2020_12_compiler_number_test.cc @@ -13,7 +13,7 @@ TEST(arbitrary) { const auto expected = sourcemeta::core::parse_json(R"JSON({ "$schema": "tag:sourcemeta.com,2024:jsonbinpack/encoding/v1", - "binpackEncoding": "DOUBLE_VARINT_TUPLE", + "binpackEncoding": "NUMBER_VARINT_TUPLE", "binpackOptions": {} })JSON"); diff --git a/test/e2e/circlecimatrix/schema-less/output.bin b/test/e2e/circlecimatrix/schema-less/output.bin index 5925b611..c6299a77 100644 --- a/test/e2e/circlecimatrix/schema-less/output.bin +++ b/test/e2e/circlecimatrix/schema-less/output.bin @@ -1,2 +1,2 @@ -version/* +version_* workflowstestjobsm1matrix parametersa$% \ No newline at end of file diff --git a/test/e2e/geojson/schema-less/output.bin b/test/e2e/geojson/schema-less/output.bin index d367f4b5..bbae37b8 100644 Binary files a/test/e2e/geojson/schema-less/output.bin and b/test/e2e/geojson/schema-less/output.bin differ diff --git a/test/e2e/openweathermap/schema-less/output.bin b/test/e2e/openweathermap/schema-less/output.bin index f78185e3..4e03fbed 100644 Binary files a/test/e2e/openweathermap/schema-less/output.bin and b/test/e2e/openweathermap/schema-less/output.bin differ diff --git a/test/e2e/openweatherroadrisk/schema-less/output.bin b/test/e2e/openweatherroadrisk/schema-less/output.bin index 6082099d..4e48b8a6 100644 Binary files a/test/e2e/openweatherroadrisk/schema-less/output.bin and b/test/e2e/openweatherroadrisk/schema-less/output.bin differ diff --git a/test/e2e/ox-test/schema-less/output.bin b/test/e2e/ox-test/schema-less/output.bin index a1ce8e81..64a9dcf9 100644 Binary files a/test/e2e/ox-test/schema-less/output.bin and b/test/e2e/ox-test/schema-less/output.bin differ diff --git a/test/runtime/decode_any_test.cc b/test/runtime/decode_any_test.cc index b35e67c2..5730e1e9 100644 --- a/test/runtime/decode_any_test.cc +++ b/test/runtime/decode_any_test.cc @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -1182,3 +1183,23 @@ TEST(ANY_PACKED_TYPE_TAG_BYTE_PREFIX__object_62_xs_shared) { EXPECT_TRUE(baz.is_string()); EXPECT_EQ(baz.to_string(), std::string(62, 'x')); } + +TEST(ANY_PACKED_TYPE_TAG_BYTE_PREFIX__decimal_2_point_1) { + using namespace sourcemeta::jsonbinpack; + sourcemeta::core::InputByteStream stream{0x5f, 0x2a, 0x03}; + Decoder decoder{stream}; + const auto result = decoder.ANY_PACKED_TYPE_TAG_BYTE_PREFIX({}); + EXPECT_TRUE(result.is_decimal()); + const sourcemeta::core::JSON expected{sourcemeta::core::Decimal{"2.1"}}; + EXPECT_EQ(result, expected); +} + +TEST(ANY_PACKED_TYPE_TAG_BYTE_PREFIX__decimal_minus_122_point_08) { + using namespace sourcemeta::jsonbinpack; + sourcemeta::core::InputByteStream stream{0x5f, 0xdf, 0xbe, 0x01, 0x07}; + Decoder decoder{stream}; + const auto result = decoder.ANY_PACKED_TYPE_TAG_BYTE_PREFIX({}); + EXPECT_TRUE(result.is_decimal()); + const sourcemeta::core::JSON expected{sourcemeta::core::Decimal{"-122.08"}}; + EXPECT_EQ(result, expected); +} diff --git a/test/runtime/decode_number_test.cc b/test/runtime/decode_number_test.cc index 69dd2032..683826ed 100644 --- a/test/runtime/decode_number_test.cc +++ b/test/runtime/decode_number_test.cc @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -130,3 +131,66 @@ TEST(DOUBLE_VARINT_TUPLE_31_point_4) { const sourcemeta::core::JSON expected{31.4}; EXPECT_EQ(result, expected); } + +TEST(NUMBER_VARINT_TUPLE_decimal_2_point_1) { + sourcemeta::core::InputByteStream stream{0x2a, 0x03}; + sourcemeta::jsonbinpack::Decoder decoder{stream}; + const auto result = decoder.NUMBER_VARINT_TUPLE({}); + EXPECT_TRUE(result.is_decimal()); + const sourcemeta::core::JSON expected{sourcemeta::core::Decimal{"2.1"}}; + EXPECT_EQ(result, expected); +} + +TEST(NUMBER_VARINT_TUPLE_decimal_minus_122_point_08) { + sourcemeta::core::InputByteStream stream{0xdf, 0xbe, 0x01, 0x07}; + sourcemeta::jsonbinpack::Decoder decoder{stream}; + const auto result = decoder.NUMBER_VARINT_TUPLE({}); + EXPECT_TRUE(result.is_decimal()); + const sourcemeta::core::JSON expected{sourcemeta::core::Decimal{"-122.08"}}; + EXPECT_EQ(result, expected); +} + +TEST(NUMBER_VARINT_TUPLE_decimal_positive_exponent) { + sourcemeta::core::InputByteStream stream{0x02, 0x09}; + sourcemeta::jsonbinpack::Decoder decoder{stream}; + const auto result = decoder.NUMBER_VARINT_TUPLE({}); + EXPECT_TRUE(result.is_decimal()); + const sourcemeta::core::JSON expected{sourcemeta::core::Decimal{"1e2"}}; + EXPECT_EQ(result, expected); +} + +TEST(NUMBER_VARINT_TUPLE_decimal_0_point_1) { + sourcemeta::core::InputByteStream stream{0x02, 0x03}; + sourcemeta::jsonbinpack::Decoder decoder{stream}; + const auto result = decoder.NUMBER_VARINT_TUPLE({}); + EXPECT_TRUE(result.is_decimal()); + const sourcemeta::core::JSON expected{sourcemeta::core::Decimal{"0.1"}}; + EXPECT_EQ(result, expected); +} + +TEST(NUMBER_VARINT_TUPLE_decimal_zero) { + sourcemeta::core::InputByteStream stream{0x00, 0x01}; + sourcemeta::jsonbinpack::Decoder decoder{stream}; + const auto result = decoder.NUMBER_VARINT_TUPLE({}); + EXPECT_TRUE(result.is_decimal()); + const sourcemeta::core::JSON expected{sourcemeta::core::Decimal{"0"}}; + EXPECT_EQ(result, expected); +} + +TEST(NUMBER_VARINT_TUPLE_real_3_point_14) { + sourcemeta::core::InputByteStream stream{0xf4, 0x04, 0x06}; + sourcemeta::jsonbinpack::Decoder decoder{stream}; + const auto result = decoder.NUMBER_VARINT_TUPLE({}); + EXPECT_TRUE(result.is_real()); + const sourcemeta::core::JSON expected{3.14}; + EXPECT_EQ(result, expected); +} + +TEST(NUMBER_VARINT_TUPLE_real_5) { + sourcemeta::core::InputByteStream stream{0x0a, 0x00}; + sourcemeta::jsonbinpack::Decoder decoder{stream}; + const auto result = decoder.NUMBER_VARINT_TUPLE({}); + EXPECT_TRUE(result.is_real()); + const sourcemeta::core::JSON expected{5.0}; + EXPECT_EQ(result, expected); +} diff --git a/test/runtime/encode_any_test.cc b/test/runtime/encode_any_test.cc index 9a41c487..aa4e426e 100644 --- a/test/runtime/encode_any_test.cc +++ b/test/runtime/encode_any_test.cc @@ -2,6 +2,7 @@ #include // std::byte #include #include +#include #include #include #include @@ -918,3 +919,28 @@ TEST(ANY_PACKED_TYPE_TAG_BYTE_PREFIX__object_62_xs_shared) { EXPECT_EQ(stream.bytes(), expected); } } + +TEST(ANY_PACKED_TYPE_TAG_BYTE_PREFIX__decimal_2_point_1) { + using namespace sourcemeta::jsonbinpack; + sourcemeta::core::JSON document{sourcemeta::core::Decimal{"2.1"}}; + sourcemeta::core::OutputByteStream stream{}; + + Encoder encoder{stream}; + encoder.ANY_PACKED_TYPE_TAG_BYTE_PREFIX(document, {}); + EXPECT_EQ(stream.bytes(), + (std::vector{std::byte{0x5f}, std::byte{0x2a}, + std::byte{0x03}})); +} + +TEST(ANY_PACKED_TYPE_TAG_BYTE_PREFIX__decimal_minus_122_point_08) { + using namespace sourcemeta::jsonbinpack; + sourcemeta::core::JSON document{sourcemeta::core::Decimal{"-122.08"}}; + sourcemeta::core::OutputByteStream stream{}; + + Encoder encoder{stream}; + encoder.ANY_PACKED_TYPE_TAG_BYTE_PREFIX(document, {}); + EXPECT_EQ( + stream.bytes(), + (std::vector{std::byte{0x5f}, std::byte{0xdf}, std::byte{0xbe}, + std::byte{0x01}, std::byte{0x07}})); +} diff --git a/test/runtime/encode_number_test.cc b/test/runtime/encode_number_test.cc index 0ad4543a..a33e803d 100644 --- a/test/runtime/encode_number_test.cc +++ b/test/runtime/encode_number_test.cc @@ -1,6 +1,7 @@ #include // std::byte #include #include +#include #include #include #include // std::vector @@ -161,3 +162,77 @@ TEST(DOUBLE_VARINT_TUPLE_31_point_4) { (std::vector{std::byte{0xf4}, std::byte{0x04}, std::byte{0x01}})); } + +TEST(NUMBER_VARINT_TUPLE_decimal_2_point_1) { + const sourcemeta::core::JSON document{sourcemeta::core::Decimal{"2.1"}}; + sourcemeta::core::OutputByteStream stream{}; + sourcemeta::jsonbinpack::Encoder encoder{stream}; + encoder.NUMBER_VARINT_TUPLE(document, {}); + EXPECT_EQ(stream.bytes(), + (std::vector{std::byte{0x2a}, std::byte{0x03}})); +} + +TEST(NUMBER_VARINT_TUPLE_decimal_minus_122_point_08) { + const sourcemeta::core::JSON document{sourcemeta::core::Decimal{"-122.08"}}; + sourcemeta::core::OutputByteStream stream{}; + sourcemeta::jsonbinpack::Encoder encoder{stream}; + encoder.NUMBER_VARINT_TUPLE(document, {}); + EXPECT_EQ(stream.bytes(), + (std::vector{std::byte{0xdf}, std::byte{0xbe}, + std::byte{0x01}, std::byte{0x07}})); +} + +TEST(NUMBER_VARINT_TUPLE_decimal_trailing_zeros_reduce) { + const sourcemeta::core::JSON document{sourcemeta::core::Decimal{"2.10"}}; + sourcemeta::core::OutputByteStream stream{}; + sourcemeta::jsonbinpack::Encoder encoder{stream}; + encoder.NUMBER_VARINT_TUPLE(document, {}); + EXPECT_EQ(stream.bytes(), + (std::vector{std::byte{0x2a}, std::byte{0x03}})); +} + +TEST(NUMBER_VARINT_TUPLE_decimal_positive_exponent) { + const sourcemeta::core::JSON document{sourcemeta::core::Decimal{"1e2"}}; + sourcemeta::core::OutputByteStream stream{}; + sourcemeta::jsonbinpack::Encoder encoder{stream}; + encoder.NUMBER_VARINT_TUPLE(document, {}); + EXPECT_EQ(stream.bytes(), + (std::vector{std::byte{0x02}, std::byte{0x09}})); +} + +TEST(NUMBER_VARINT_TUPLE_decimal_0_point_1) { + const sourcemeta::core::JSON document{sourcemeta::core::Decimal{"0.1"}}; + sourcemeta::core::OutputByteStream stream{}; + sourcemeta::jsonbinpack::Encoder encoder{stream}; + encoder.NUMBER_VARINT_TUPLE(document, {}); + EXPECT_EQ(stream.bytes(), + (std::vector{std::byte{0x02}, std::byte{0x03}})); +} + +TEST(NUMBER_VARINT_TUPLE_decimal_zero) { + const sourcemeta::core::JSON document{sourcemeta::core::Decimal{"0"}}; + sourcemeta::core::OutputByteStream stream{}; + sourcemeta::jsonbinpack::Encoder encoder{stream}; + encoder.NUMBER_VARINT_TUPLE(document, {}); + EXPECT_EQ(stream.bytes(), + (std::vector{std::byte{0x00}, std::byte{0x01}})); +} + +TEST(NUMBER_VARINT_TUPLE_real_3_point_14) { + const sourcemeta::core::JSON document{3.14}; + sourcemeta::core::OutputByteStream stream{}; + sourcemeta::jsonbinpack::Encoder encoder{stream}; + encoder.NUMBER_VARINT_TUPLE(document, {}); + EXPECT_EQ(stream.bytes(), + (std::vector{std::byte{0xf4}, std::byte{0x04}, + std::byte{0x06}})); +} + +TEST(NUMBER_VARINT_TUPLE_real_5) { + const sourcemeta::core::JSON document{5.0}; + sourcemeta::core::OutputByteStream stream{}; + sourcemeta::jsonbinpack::Encoder encoder{stream}; + encoder.NUMBER_VARINT_TUPLE(document, {}); + EXPECT_EQ(stream.bytes(), + (std::vector{std::byte{0x0a}, std::byte{0x00}})); +} diff --git a/test/runtime/v1_number_loader_test.cc b/test/runtime/v1_number_loader_test.cc index 77395605..f3a22348 100644 --- a/test/runtime/v1_number_loader_test.cc +++ b/test/runtime/v1_number_loader_test.cc @@ -15,3 +15,15 @@ TEST(DOUBLE_VARINT_TUPLE) { using namespace sourcemeta::jsonbinpack; EXPECT_TRUE(std::holds_alternative(result)); } + +TEST(NUMBER_VARINT_TUPLE) { + const sourcemeta::core::JSON input = sourcemeta::core::parse_json(R"JSON({ + "$schema": "tag:sourcemeta.com,2024:jsonbinpack/encoding/v1", + "binpackEncoding": "NUMBER_VARINT_TUPLE", + "binpackOptions": {} + })JSON"); + + const auto result{sourcemeta::jsonbinpack::load(input)}; + using namespace sourcemeta::jsonbinpack; + EXPECT_TRUE(std::holds_alternative(result)); +} diff --git a/vendor/blaze/CMakeLists.txt b/vendor/blaze/CMakeLists.txt index 0c07dcff..b6723e97 100644 --- a/vendor/blaze/CMakeLists.txt +++ b/vendor/blaze/CMakeLists.txt @@ -132,6 +132,8 @@ if(PROJECT_IS_TOP_LEVEL) "${PROJECT_SOURCE_DIR}/test/evaluator/evaluator_2019_09_invalid.json" "${PROJECT_SOURCE_DIR}/test/evaluator/evaluator_2020_12.json" "${PROJECT_SOURCE_DIR}/test/evaluator/evaluator_2020_12_invalid.json" + "${PROJECT_SOURCE_DIR}/test/evaluator/evaluator_openapi_3_0.json" + "${PROJECT_SOURCE_DIR}/test/evaluator/evaluator_openapi_3_0_invalid.json" "${PROJECT_SOURCE_DIR}/test/evaluator/evaluator_openapi_3_1.json" "${PROJECT_SOURCE_DIR}/test/evaluator/evaluator_openapi_3_2.json" "${PROJECT_SOURCE_DIR}/test/output/output_standard_basic.json" diff --git a/vendor/blaze/DEPENDENCIES b/vendor/blaze/DEPENDENCIES index 5723a721..aa4dbae8 100644 --- a/vendor/blaze/DEPENDENCIES +++ b/vendor/blaze/DEPENDENCIES @@ -1,3 +1,3 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 22e6e9521ed93ef268839bf03e41287f482839bb +core https://github.com/sourcemeta/core af2b932ef6284c880f47012749b78bb75526f7a4 jsonschema-test-suite https://github.com/json-schema-org/JSON-Schema-Test-Suite 6648e8194c69697b2e1a15fe76a06a480b183a51 diff --git a/vendor/blaze/ports/javascript/describe.mjs b/vendor/blaze/ports/javascript/describe.mjs index 00609784..4d2718df 100644 --- a/vendor/blaze/ports/javascript/describe.mjs +++ b/vendor/blaze/ports/javascript/describe.mjs @@ -18,6 +18,8 @@ import { ASSERTION_UNIQUE, ASSERTION_DIVISIBLE, ASSERTION_TYPE_INTEGER_BOUNDED, ASSERTION_TYPE_INTEGER_BOUNDED_STRICT, ASSERTION_TYPE_INTEGER_LOWER_BOUND, ASSERTION_TYPE_INTEGER_LOWER_BOUND_STRICT, + ASSERTION_NUMBER_INTEGER_BOUNDED, + ASSERTION_NUMBER_TYPE, ASSERTION_STRING_TYPE, ASSERTION_PROPERTY_TYPE, ASSERTION_PROPERTY_TYPE_EVALUATE, ASSERTION_PROPERTY_TYPE_STRICT, ASSERTION_PROPERTY_TYPE_STRICT_EVALUATE, @@ -702,6 +704,12 @@ export function describe(valid, instruction, evaluatePath, return message; } + if (keyword === 'x-format-assertion' && typeof annotation === 'boolean') { + return annotation + ? 'A sibling `format` keyword was expected to be enforced as an assertion' + : 'A sibling `format` keyword was expected to be collected as an annotation'; + } + return 'The unrecognized keyword ' + escapeString(keyword) + ' was collected as the annotation ' + stringifyValue(annotation); } @@ -832,6 +840,21 @@ export function describe(valid, instruction, evaluatePath, return 'The value was expected to be an integer above the given minimum'; } + if (opcode === ASSERTION_NUMBER_TYPE) { + return 'The ' + valueTypeName(target) + ' value ' + + stringifyValue(target) + + ' was expected to be exactly representable as an IEEE 754 ' + + (value === 0 ? 'single' : 'double') + + ' precision floating point number'; + } + + if (opcode === ASSERTION_NUMBER_INTEGER_BOUNDED) { + return 'The ' + valueTypeName(target) + ' value ' + + stringifyValue(target) + + ' was expected to be an integer between ' + value[0] + + ' and ' + value[1]; + } + if (opcode === ASSERTION_OBJECT_PROPERTIES_SIMPLE) { return 'The object value was expected to validate against the defined property subschemas'; } @@ -1049,6 +1072,30 @@ export function describe(valid, instruction, evaluatePath, 'string is short enough to satisfy a negative maximum length'; } + // The lower bounds below compile the same way when the bound is too large + // to hold, as nothing can be big enough to satisfy one + if (keyword === 'minLength') { + return 'The value was expected to be of a type other than string, as no ' + + 'string is long enough to satisfy a minimum length that large'; + } + + if (keyword === 'minItems') { + return 'The value was expected to be of a type other than array, as no ' + + 'array has enough items to satisfy a minimum size that large'; + } + + if (keyword === 'minProperties') { + return 'The value was expected to be of a type other than object, as no ' + + 'object has enough properties to satisfy a minimum size that large'; + } + + // `contains` compiles the same way when its `minContains` is too large to + // hold, as no array has that many items for the subschema to match + if (keyword === 'contains') { + return 'The value was expected to be of a type other than array, as no ' + + 'array has enough matching items to satisfy a minimum count that large'; + } + return describeNotTypesCheck(valid, targetType, value); } diff --git a/vendor/blaze/ports/javascript/index.mjs b/vendor/blaze/ports/javascript/index.mjs index 3a1d47e4..e804a445 100644 --- a/vendor/blaze/ports/javascript/index.mjs +++ b/vendor/blaze/ports/javascript/index.mjs @@ -7,7 +7,7 @@ import { CONTROL_JUMP, CONTROL_DYNAMIC_ANCHOR_JUMP } from './opcodes.mjs'; -const JSON_VERSION = 7; +const JSON_VERSION = 9; const DEPTH_LIMIT = 300; const URI_REGEX = /^[a-zA-Z][a-zA-Z0-9+\-.]*:[^\s]*$/; @@ -300,48 +300,50 @@ function compileInstructionToCode(instruction, captures, visited, budget) { case 34: return fb(34); case 35: return fb(35); case 36: return fb(36); case 37: return fb(37); case 38: return fb(38); case 39: return fb(39); case 40: return fb(40); - case 41: { var r=R('t'); return r?IO+r+'if(t===void 0)return true;var a=_jt(t);return a==='+value+'||('+value+'===2&&_ii(t));':null; } + case 41: return fb(41); case 42: return fb(42); - case 43: { var r=R('t'); return r?IO+r+'if(t===void 0)return true;return _es(t)==='+value+';':null; } + case 43: { var r=R('t'); return r?IO+r+'if(t===void 0)return true;var a=_jt(t);return a==='+value+'||('+value+'===2&&_ii(t));':null; } case 44: return fb(44); - case 45: { var r=R('t'); return r?IO+r+'if(t===void 0)return true;return('+value+'&(1<<_es(t)))!==0;':null; } - case 46: return fb(46); case 47: return fb(47); case 48: return fb(48); - case 49: return fb(49); - case 50: case 51: case 52: case 53: case 54: return 'return true;'; - case 55: { var r=R('t'); if(!r)return null; if(!children||children.length===0)return 'return false;'; var c=r; for(var j=0;j0)c+=seq(children,'t'); return c+'return true;'; } - case 62: { var r=R('t'); if(!r)return null; var c=r+TO+'return true;if(!Object.hasOwn(t,'+JSON.stringify(value)+'))return true;'; if(children&&children.length>0)c+=seq(children,'t'); return c+'return true;'; } - case 63: { var r=R('t'); if(!r)return null; var c=r+'if(!Array.isArray(t)||t.length<='+value+')return true;'; if(children&&children.length>0)c+=seq(children,'t'); return c+'return true;'; } - case 64: return fb(64); case 65: return fb(65); - case 66: { var r=R('t'); if(!r)return null; if(!children||children.length===0)return r+'return true;'; var mi=captures.length; captures.push(value); var gf=''; for(var gi=0;gi0){gf+='function(i,d,_t,_v){'+lb(gc,'i')+'return true;},';}else{gf+='null,';}} return r+TO+'return true;var __cg=['+gf+'];for(var k in t){var __mi=_c['+mi+'][k];if(__mi!==void 0){var __cf=__cg[__mi];if(__cf&&!__cf(t,d,_t,_v))return false;}}return true;'; } - case 67: { var r=R('t'); if(!r)return null; if(!children||children.length===0)return r+'return true;'; var mi=captures.length; captures.push(value); var gf=''; for(var gi=0;gi0){gf+='function(i,d,_t,_v){'+lb(gc,'i')+'return true;},';}else{gf+='null,';}} return r+TO+'return true;var __cg=['+gf+'];for(var k in t){var __mi=_c['+mi+'][k];if(__mi===void 0)return false;var __cf=__cg[__mi];if(__cf&&!__cf(t,d,_t,_v))return false;}return true;'; } - case 68: { var r=R('t'); if(!r)return null; if(!children||children.length===0)return r+'return true;'; return r+TO+'return true;for(var k in t){'+lb(children,'t[k]')+'}return true;'; } - case 69: return fb(69); case 70: return fb(70); case 71: return fb(71); case 72: return fb(72); case 73: return fb(73); - case 74: return fb(74); case 75: return fb(75); case 76: return fb(76); - case 77: return fb(77); - case 78: { var r=R('t'); return r?r+TO+'return true;for(var k in t){if(_es(t[k])!=='+value+')return false;}return true;':null; } - case 79: return fb(79); case 80: return fb(80); case 81: return fb(81); - case 82: return fb(82); - case 83: { var r=R('t'); if(!r)return null; if(!children||children.length===0)return r+'return true;'; return r+'if(!Array.isArray(t))return true;for(var j=0;j=t.length)return true;for(var j='+value+';j0)c+=seq(children,'i'); return c+'return true;'; } - case 96: { var c=IO+'if(!Object.hasOwn(i,'+JSON.stringify(value)+'))return true;'; if(children&&children.length>0)c+=seq(children,'i'); return c+'return true;'; } - case 97: { var c='if(_jt(i)!=='+value+')return true;'; if(children&&children.length>0)c+=seq(children,'i'); return c+'return true;'; } - case 98: return 'return true;'; - case 99: return fb(99); - case 100: { if(!value)return 'return true;'; if(visited&&visited.has(instruction))return fb(100); if(!visited)visited=new Set(); visited.add(instruction); var r=R('t'); if(!r)return fb(100); var c=r; for(var j=0;j0)c+=seq(children,'t'); return c+'return true;'; } + case 64: { var r=R('t'); if(!r)return null; var c=r+TO+'return true;if(!Object.hasOwn(t,'+JSON.stringify(value)+'))return true;'; if(children&&children.length>0)c+=seq(children,'t'); return c+'return true;'; } + case 65: { var r=R('t'); if(!r)return null; var c=r+'if(!Array.isArray(t)||t.length<='+value+')return true;'; if(children&&children.length>0)c+=seq(children,'t'); return c+'return true;'; } + case 66: return fb(66); case 67: return fb(67); + case 68: { var r=R('t'); if(!r)return null; if(!children||children.length===0)return r+'return true;'; var mi=captures.length; captures.push(value); var gf=''; for(var gi=0;gi0){gf+='function(i,d,_t,_v){'+lb(gc,'i')+'return true;},';}else{gf+='null,';}} return r+TO+'return true;var __cg=['+gf+'];for(var k in t){var __mi=_c['+mi+'][k];if(__mi!==void 0){var __cf=__cg[__mi];if(__cf&&!__cf(t,d,_t,_v))return false;}}return true;'; } + case 69: { var r=R('t'); if(!r)return null; if(!children||children.length===0)return r+'return true;'; var mi=captures.length; captures.push(value); var gf=''; for(var gi=0;gi0){gf+='function(i,d,_t,_v){'+lb(gc,'i')+'return true;},';}else{gf+='null,';}} return r+TO+'return true;var __cg=['+gf+'];for(var k in t){var __mi=_c['+mi+'][k];if(__mi===void 0)return false;var __cf=__cg[__mi];if(__cf&&!__cf(t,d,_t,_v))return false;}return true;'; } + case 70: { var r=R('t'); if(!r)return null; if(!children||children.length===0)return r+'return true;'; return r+TO+'return true;for(var k in t){'+lb(children,'t[k]')+'}return true;'; } + case 71: return fb(71); case 72: return fb(72); case 73: return fb(73); case 74: return fb(74); case 75: return fb(75); + case 76: return fb(76); case 77: return fb(77); case 78: return fb(78); + case 79: return fb(79); + case 80: { var r=R('t'); return r?r+TO+'return true;for(var k in t){if(_es(t[k])!=='+value+')return false;}return true;':null; } + case 81: return fb(81); case 82: return fb(82); case 83: return fb(83); + case 84: return fb(84); + case 85: { var r=R('t'); if(!r)return null; if(!children||children.length===0)return r+'return true;'; return r+'if(!Array.isArray(t))return true;for(var j=0;j=t.length)return true;for(var j='+value+';j0)c+=seq(children,'i'); return c+'return true;'; } + case 98: { var c=IO+'if(!Object.hasOwn(i,'+JSON.stringify(value)+'))return true;'; if(children&&children.length>0)c+=seq(children,'i'); return c+'return true;'; } + case 99: { var c='if(_jt(i)!=='+value+')return true;'; if(children&&children.length>0)c+=seq(children,'i'); return c+'return true;'; } + case 100: return 'return true;'; + case 101: return fb(101); + case 102: { if(!value)return 'return true;'; if(visited&&visited.has(instruction))return fb(102); if(!visited)visited=new Set(); visited.add(instruction); var r=R('t'); if(!r)return fb(102); var c=r; for(var j=0;j= range[0] && target <= range[1]; + if (evaluator.callbackMode) evaluator.callbackPop(instruction, __result); + return __result; +}; + +function AssertionNumberType(instruction, instance, depth, template, evaluator) { + const target = resolveInstance(instance, instruction[2]); + if (typeof target !== 'number' && typeof target !== 'bigint') return true; + if (evaluator.callbackMode) evaluator.callbackPush(instruction); + const __result = numberTypeMatches(target, instruction[6]); + if (evaluator.callbackMode) evaluator.callbackPop(instruction, __result); + return __result; +}; + function AssertionTypeIntegerBoundedStrict(instruction, instance, depth, template, evaluator) { const target = resolveInstance(instance, instruction[2]); if (evaluator.callbackMode) evaluator.callbackPush(instruction); @@ -2433,8 +2469,8 @@ function LoopItemsPropertiesExactlyTypeStrictHash(instruction, instance, depth, if (evaluator.callbackMode) evaluator.callbackPush(instruction); const target = resolveInstance(instance, instruction[2]); if (!Array.isArray(target)) { - if (evaluator.callbackMode) evaluator.callbackPop(instruction, false); - return false; + if (evaluator.callbackMode) evaluator.callbackPop(instruction, true); + return true; } const expectedType = instruction[6][0]; const entries = instruction[6][1][0]; @@ -2702,67 +2738,69 @@ const handlers = [ AssertionTypeIntegerBoundedStrict, // 37 AssertionTypeIntegerLowerBound, // 38 AssertionTypeIntegerLowerBoundStrict, // 39 - AssertionStringType, // 40 - AssertionPropertyType, // 41 - AssertionPropertyTypeEvaluate, // 42 - AssertionPropertyTypeStrict, // 43 - AssertionPropertyTypeStrictEvaluate, // 44 - AssertionPropertyTypeStrictAny, // 45 - AssertionPropertyTypeStrictAnyEvaluate, // 46 - AssertionArrayPrefix, // 47 - AssertionArrayPrefixEvaluate, // 48 - AssertionObjectPropertiesSimple, // 49 - AnnotationEmit, // 50 - AnnotationEmitWrapped, // 51 - AnnotationToParent, // 52 - AnnotationBasenameToParent, // 53 - Evaluate, // 54 - LogicalNot, // 55 - LogicalNotEvaluate, // 56 - LogicalOr, // 57 - LogicalAnd, // 58 - LogicalXor, // 59 - LogicalCondition, // 60 - LogicalWhenType, // 61 - LogicalWhenDefines, // 62 - LogicalWhenArraySizeGreater, // 63 - LoopPropertiesUnevaluated, // 64 - LoopPropertiesUnevaluatedExcept, // 65 - LoopPropertiesMatch, // 66 - LoopPropertiesMatchClosed, // 67 - LoopProperties, // 68 - LoopPropertiesEvaluate, // 69 - LoopPropertiesRegex, // 70 - LoopPropertiesRegexClosed, // 71 - LoopPropertiesStartsWith, // 72 - LoopPropertiesExcept, // 73 - LoopPropertiesType, // 74 - LoopPropertiesTypeEvaluate, // 75 - LoopPropertiesExactlyTypeStrict, // 76 - LoopPropertiesExactlyTypeStrictHash, // 77 - LoopPropertiesTypeStrict, // 78 - LoopPropertiesTypeStrictEvaluate, // 79 - LoopPropertiesTypeStrictAny, // 80 - LoopPropertiesTypeStrictAnyEvaluate, // 81 - LoopKeys, // 82 - LoopItems, // 83 - LoopItemsFrom, // 84 - LoopItemsUnevaluated, // 85 - LoopItemsType, // 86 - LoopItemsTypeStrict, // 87 - LoopItemsTypeStrictAny, // 88 - LoopItemsPropertiesExactlyTypeStrictHash, // 89 - LoopItemsPropertiesExactlyTypeStrictHash, // 90 - LoopItemsIntegerBounded, // 91 - LoopItemsIntegerBoundedSized, // 92 - LoopContains, // 93 - ControlGroup, // 94 - ControlGroupWhenDefines, // 95 - ControlGroupWhenDefinesDirect, // 96 - ControlGroupWhenType, // 97 - ControlEvaluate, // 98 - ControlDynamicAnchorJump, // 99 - ControlJump // 100 + AssertionNumberIntegerBounded, // 40 + AssertionNumberType, // 41 + AssertionStringType, // 42 + AssertionPropertyType, // 43 + AssertionPropertyTypeEvaluate, // 44 + AssertionPropertyTypeStrict, // 45 + AssertionPropertyTypeStrictEvaluate, // 46 + AssertionPropertyTypeStrictAny, // 47 + AssertionPropertyTypeStrictAnyEvaluate, // 48 + AssertionArrayPrefix, // 49 + AssertionArrayPrefixEvaluate, // 50 + AssertionObjectPropertiesSimple, // 51 + AnnotationEmit, // 52 + AnnotationEmitWrapped, // 53 + AnnotationToParent, // 54 + AnnotationBasenameToParent, // 55 + Evaluate, // 56 + LogicalNot, // 57 + LogicalNotEvaluate, // 58 + LogicalOr, // 59 + LogicalAnd, // 60 + LogicalXor, // 61 + LogicalCondition, // 62 + LogicalWhenType, // 63 + LogicalWhenDefines, // 64 + LogicalWhenArraySizeGreater, // 65 + LoopPropertiesUnevaluated, // 66 + LoopPropertiesUnevaluatedExcept, // 67 + LoopPropertiesMatch, // 68 + LoopPropertiesMatchClosed, // 69 + LoopProperties, // 70 + LoopPropertiesEvaluate, // 71 + LoopPropertiesRegex, // 72 + LoopPropertiesRegexClosed, // 73 + LoopPropertiesStartsWith, // 74 + LoopPropertiesExcept, // 75 + LoopPropertiesType, // 76 + LoopPropertiesTypeEvaluate, // 77 + LoopPropertiesExactlyTypeStrict, // 78 + LoopPropertiesExactlyTypeStrictHash, // 79 + LoopPropertiesTypeStrict, // 80 + LoopPropertiesTypeStrictEvaluate, // 81 + LoopPropertiesTypeStrictAny, // 82 + LoopPropertiesTypeStrictAnyEvaluate, // 83 + LoopKeys, // 84 + LoopItems, // 85 + LoopItemsFrom, // 86 + LoopItemsUnevaluated, // 87 + LoopItemsType, // 88 + LoopItemsTypeStrict, // 89 + LoopItemsTypeStrictAny, // 90 + LoopItemsPropertiesExactlyTypeStrictHash, // 91 + LoopItemsPropertiesExactlyTypeStrictHash, // 92 + LoopItemsIntegerBounded, // 93 + LoopItemsIntegerBoundedSized, // 94 + LoopContains, // 95 + ControlGroup, // 96 + ControlGroupWhenDefines, // 97 + ControlGroupWhenDefinesDirect, // 98 + ControlGroupWhenType, // 99 + ControlEvaluate, // 100 + ControlDynamicAnchorJump, // 101 + ControlJump // 102 ]; function AssertionTypeArrayBounded_fast(instruction, instance, depth, template, evaluator) { @@ -3873,7 +3911,7 @@ function LoopItemsUnevaluated_fast(instruction, instance, depth, template, evalu function LoopItemsPropertiesExactlyTypeStrictHash_fast(instruction, instance, depth, template, evaluator) { const target = resolveInstance(instance, instruction[2]); - if (!Array.isArray(target)) return false; + if (!Array.isArray(target)) return true; const expectedType = instruction[6][0]; const entries = instruction[6][1][0]; const expectedCount = entries.length; @@ -3946,6 +3984,19 @@ function AssertionTypeIntegerBounded_fast(instruction, instance, depth, template return (typeof target === 'bigint' || Number.isInteger(target)) && target >= range[0] && target <= range[1]; } +function AssertionNumberIntegerBounded_fast(instruction, instance, depth, template, evaluator) { + const target = resolveInstance(instance, instruction[2]); + if (typeof target !== 'number' && typeof target !== 'bigint') return true; + const range = instruction[6]; + return (typeof target === 'bigint' || Number.isInteger(target)) && target >= range[0] && target <= range[1]; +} + +function AssertionNumberType_fast(instruction, instance, depth, template, evaluator) { + const target = resolveInstance(instance, instruction[2]); + if (typeof target !== 'number' && typeof target !== 'bigint') return true; + return numberTypeMatches(target, instruction[6]); +} + function AssertionTypeIntegerBoundedStrict_fast(instruction, instance, depth, template, evaluator) { const target = resolveInstance(instance, instruction[2]); const range = instruction[6]; @@ -3966,35 +4017,35 @@ function AssertionTypeIntegerLowerBoundStrict_fast(instruction, instance, depth, const fastHandlers = handlers.slice(); fastHandlers[16] = AssertionTypeArrayBounded_fast; -fastHandlers[88] = LoopItemsTypeStrictAny_fast; -fastHandlers[43] = AssertionPropertyTypeStrict_fast; +fastHandlers[90] = LoopItemsTypeStrictAny_fast; +fastHandlers[45] = AssertionPropertyTypeStrict_fast; fastHandlers[11] = AssertionTypeStrict_fast; fastHandlers[4] = AssertionDefinesAllStrict_fast; fastHandlers[27] = AssertionEqual_fast; -fastHandlers[66] = LoopPropertiesMatch_fast; -fastHandlers[57] = LogicalOr_fast; -fastHandlers[100] = ControlJump_fast; +fastHandlers[68] = LoopPropertiesMatch_fast; +fastHandlers[59] = LogicalOr_fast; +fastHandlers[102] = ControlJump_fast; fastHandlers[29] = AssertionEqualsAnyStringHash_fast; -fastHandlers[59] = LogicalXor_fast; +fastHandlers[61] = LogicalXor_fast; fastHandlers[2] = AssertionDefinesStrict_fast; -fastHandlers[83] = LoopItems_fast; -fastHandlers[67] = LoopPropertiesMatchClosed_fast; +fastHandlers[85] = LoopItems_fast; +fastHandlers[69] = LoopPropertiesMatchClosed_fast; fastHandlers[14] = AssertionTypeStringBounded_fast; -fastHandlers[58] = LogicalAnd_fast; +fastHandlers[60] = LogicalAnd_fast; fastHandlers[8] = AssertionPropertyDependencies_fast; fastHandlers[10] = AssertionTypeAny_fast; -fastHandlers[60] = LogicalCondition_fast; -fastHandlers[73] = LoopPropertiesExcept_fast; +fastHandlers[62] = LogicalCondition_fast; +fastHandlers[75] = LoopPropertiesExcept_fast; fastHandlers[20] = AssertionRegex_fast; -fastHandlers[68] = LoopProperties_fast; +fastHandlers[70] = LoopProperties_fast; fastHandlers[1] = AssertionDefines_fast; -fastHandlers[61] = LogicalWhenType_fast; -fastHandlers[62] = LogicalWhenDefines_fast; +fastHandlers[63] = LogicalWhenType_fast; +fastHandlers[64] = LogicalWhenDefines_fast; fastHandlers[0] = AssertionFail_fast; -fastHandlers[93] = LoopContains_fast; -fastHandlers[55] = LogicalNot_fast; -fastHandlers[86] = LoopItemsType_fast; -fastHandlers[87] = LoopItemsTypeStrict_fast; +fastHandlers[95] = LoopContains_fast; +fastHandlers[57] = LogicalNot_fast; +fastHandlers[88] = LoopItemsType_fast; +fastHandlers[89] = LoopItemsTypeStrict_fast; fastHandlers[28] = AssertionEqualsAny_fast; fastHandlers[3] = AssertionDefinesAll_fast; fastHandlers[5] = AssertionDefinesExactly_fast; @@ -4023,44 +4074,46 @@ fastHandlers[36] = AssertionTypeIntegerBounded_fast; fastHandlers[37] = AssertionTypeIntegerBoundedStrict_fast; fastHandlers[38] = AssertionTypeIntegerLowerBound_fast; fastHandlers[39] = AssertionTypeIntegerLowerBoundStrict_fast; -fastHandlers[40] = AssertionStringType_fast; -fastHandlers[41] = AssertionPropertyType_fast; -fastHandlers[42] = AssertionPropertyTypeEvaluate_fast; -fastHandlers[44] = AssertionPropertyTypeStrictEvaluate_fast; -fastHandlers[45] = AssertionPropertyTypeStrictAny_fast; -fastHandlers[46] = AssertionPropertyTypeStrictAnyEvaluate_fast; -fastHandlers[47] = AssertionArrayPrefix_fast; -fastHandlers[48] = AssertionArrayPrefixEvaluate_fast; -fastHandlers[49] = AssertionObjectPropertiesSimple_fast; -fastHandlers[50] = AnnotationEmit_fast; -fastHandlers[51] = AnnotationEmitWrapped_fast; -fastHandlers[52] = AnnotationToParent_fast; -fastHandlers[53] = AnnotationBasenameToParent_fast; -fastHandlers[54] = Evaluate_fast; -fastHandlers[56] = LogicalNotEvaluate_fast; -fastHandlers[63] = LogicalWhenArraySizeGreater_fast; -fastHandlers[64] = LoopPropertiesUnevaluated_fast; -fastHandlers[65] = LoopPropertiesUnevaluatedExcept_fast; -fastHandlers[69] = LoopPropertiesEvaluate_fast; -fastHandlers[70] = LoopPropertiesRegex_fast; -fastHandlers[71] = LoopPropertiesRegexClosed_fast; -fastHandlers[72] = LoopPropertiesStartsWith_fast; -fastHandlers[74] = LoopPropertiesType_fast; -fastHandlers[75] = LoopPropertiesTypeEvaluate_fast; -fastHandlers[76] = LoopPropertiesExactlyTypeStrict_fast; -fastHandlers[77] = LoopPropertiesExactlyTypeStrictHash_fast; -fastHandlers[78] = LoopPropertiesTypeStrict_fast; -fastHandlers[79] = LoopPropertiesTypeStrictEvaluate_fast; -fastHandlers[80] = LoopPropertiesTypeStrictAny_fast; -fastHandlers[81] = LoopPropertiesTypeStrictAnyEvaluate_fast; -fastHandlers[82] = LoopKeys_fast; -fastHandlers[84] = LoopItemsFrom_fast; -fastHandlers[85] = LoopItemsUnevaluated_fast; -fastHandlers[89] = LoopItemsPropertiesExactlyTypeStrictHash_fast; -fastHandlers[90] = LoopItemsPropertiesExactlyTypeStrictHash_fast; -fastHandlers[91] = LoopItemsIntegerBounded_fast; -fastHandlers[92] = LoopItemsIntegerBoundedSized_fast; -fastHandlers[99] = ControlDynamicAnchorJump_fast; +fastHandlers[40] = AssertionNumberIntegerBounded_fast; +fastHandlers[41] = AssertionNumberType_fast; +fastHandlers[42] = AssertionStringType_fast; +fastHandlers[43] = AssertionPropertyType_fast; +fastHandlers[44] = AssertionPropertyTypeEvaluate_fast; +fastHandlers[46] = AssertionPropertyTypeStrictEvaluate_fast; +fastHandlers[47] = AssertionPropertyTypeStrictAny_fast; +fastHandlers[48] = AssertionPropertyTypeStrictAnyEvaluate_fast; +fastHandlers[49] = AssertionArrayPrefix_fast; +fastHandlers[50] = AssertionArrayPrefixEvaluate_fast; +fastHandlers[51] = AssertionObjectPropertiesSimple_fast; +fastHandlers[52] = AnnotationEmit_fast; +fastHandlers[53] = AnnotationEmitWrapped_fast; +fastHandlers[54] = AnnotationToParent_fast; +fastHandlers[55] = AnnotationBasenameToParent_fast; +fastHandlers[56] = Evaluate_fast; +fastHandlers[58] = LogicalNotEvaluate_fast; +fastHandlers[65] = LogicalWhenArraySizeGreater_fast; +fastHandlers[66] = LoopPropertiesUnevaluated_fast; +fastHandlers[67] = LoopPropertiesUnevaluatedExcept_fast; +fastHandlers[71] = LoopPropertiesEvaluate_fast; +fastHandlers[72] = LoopPropertiesRegex_fast; +fastHandlers[73] = LoopPropertiesRegexClosed_fast; +fastHandlers[74] = LoopPropertiesStartsWith_fast; +fastHandlers[76] = LoopPropertiesType_fast; +fastHandlers[77] = LoopPropertiesTypeEvaluate_fast; +fastHandlers[78] = LoopPropertiesExactlyTypeStrict_fast; +fastHandlers[79] = LoopPropertiesExactlyTypeStrictHash_fast; +fastHandlers[80] = LoopPropertiesTypeStrict_fast; +fastHandlers[81] = LoopPropertiesTypeStrictEvaluate_fast; +fastHandlers[82] = LoopPropertiesTypeStrictAny_fast; +fastHandlers[83] = LoopPropertiesTypeStrictAnyEvaluate_fast; +fastHandlers[84] = LoopKeys_fast; +fastHandlers[86] = LoopItemsFrom_fast; +fastHandlers[87] = LoopItemsUnevaluated_fast; +fastHandlers[91] = LoopItemsPropertiesExactlyTypeStrictHash_fast; +fastHandlers[92] = LoopItemsPropertiesExactlyTypeStrictHash_fast; +fastHandlers[93] = LoopItemsIntegerBounded_fast; +fastHandlers[94] = LoopItemsIntegerBoundedSized_fast; +fastHandlers[101] = ControlDynamicAnchorJump_fast; import { describe } from './describe.mjs'; diff --git a/vendor/blaze/ports/javascript/opcodes.mjs b/vendor/blaze/ports/javascript/opcodes.mjs index 9fdfa858..54a1eccf 100644 --- a/vendor/blaze/ports/javascript/opcodes.mjs +++ b/vendor/blaze/ports/javascript/opcodes.mjs @@ -38,67 +38,69 @@ export const ASSERTION_TYPE_INTEGER_BOUNDED = 36; export const ASSERTION_TYPE_INTEGER_BOUNDED_STRICT = 37; export const ASSERTION_TYPE_INTEGER_LOWER_BOUND = 38; export const ASSERTION_TYPE_INTEGER_LOWER_BOUND_STRICT = 39; -export const ASSERTION_STRING_TYPE = 40; -export const ASSERTION_PROPERTY_TYPE = 41; -export const ASSERTION_PROPERTY_TYPE_EVALUATE = 42; -export const ASSERTION_PROPERTY_TYPE_STRICT = 43; -export const ASSERTION_PROPERTY_TYPE_STRICT_EVALUATE = 44; -export const ASSERTION_PROPERTY_TYPE_STRICT_ANY = 45; -export const ASSERTION_PROPERTY_TYPE_STRICT_ANY_EVALUATE = 46; -export const ASSERTION_ARRAY_PREFIX = 47; -export const ASSERTION_ARRAY_PREFIX_EVALUATE = 48; -export const ASSERTION_OBJECT_PROPERTIES_SIMPLE = 49; -export const ANNOTATION_EMIT = 50; -export const ANNOTATION_EMIT_WRAPPED = 51; -export const ANNOTATION_TO_PARENT = 52; -export const ANNOTATION_BASENAME_TO_PARENT = 53; -export const EVALUATE = 54; -export const LOGICAL_NOT = 55; -export const LOGICAL_NOT_EVALUATE = 56; -export const LOGICAL_OR = 57; -export const LOGICAL_AND = 58; -export const LOGICAL_XOR = 59; -export const LOGICAL_CONDITION = 60; -export const LOGICAL_WHEN_TYPE = 61; -export const LOGICAL_WHEN_DEFINES = 62; -export const LOGICAL_WHEN_ARRAY_SIZE_GREATER = 63; -export const LOOP_PROPERTIES_UNEVALUATED = 64; -export const LOOP_PROPERTIES_UNEVALUATED_EXCEPT = 65; -export const LOOP_PROPERTIES_MATCH = 66; -export const LOOP_PROPERTIES_MATCH_CLOSED = 67; -export const LOOP_PROPERTIES = 68; -export const LOOP_PROPERTIES_EVALUATE = 69; -export const LOOP_PROPERTIES_REGEX = 70; -export const LOOP_PROPERTIES_REGEX_CLOSED = 71; -export const LOOP_PROPERTIES_STARTS_WITH = 72; -export const LOOP_PROPERTIES_EXCEPT = 73; -export const LOOP_PROPERTIES_TYPE = 74; -export const LOOP_PROPERTIES_TYPE_EVALUATE = 75; -export const LOOP_PROPERTIES_EXACTLY_TYPE_STRICT = 76; -export const LOOP_PROPERTIES_EXACTLY_TYPE_STRICT_HASH = 77; -export const LOOP_PROPERTIES_TYPE_STRICT = 78; -export const LOOP_PROPERTIES_TYPE_STRICT_EVALUATE = 79; -export const LOOP_PROPERTIES_TYPE_STRICT_ANY = 80; -export const LOOP_PROPERTIES_TYPE_STRICT_ANY_EVALUATE = 81; -export const LOOP_KEYS = 82; -export const LOOP_ITEMS = 83; -export const LOOP_ITEMS_FROM = 84; -export const LOOP_ITEMS_UNEVALUATED = 85; -export const LOOP_ITEMS_TYPE = 86; -export const LOOP_ITEMS_TYPE_STRICT = 87; -export const LOOP_ITEMS_TYPE_STRICT_ANY = 88; -export const LOOP_ITEMS_PROPERTIES_EXACTLY_TYPE_STRICT_HASH = 89; -export const LOOP_ITEMS_PROPERTIES_EXACTLY_TYPE_STRICT_HASH3 = 90; -export const LOOP_ITEMS_INTEGER_BOUNDED = 91; -export const LOOP_ITEMS_INTEGER_BOUNDED_SIZED = 92; -export const LOOP_CONTAINS = 93; -export const CONTROL_GROUP = 94; -export const CONTROL_GROUP_WHEN_DEFINES = 95; -export const CONTROL_GROUP_WHEN_DEFINES_DIRECT = 96; -export const CONTROL_GROUP_WHEN_TYPE = 97; -export const CONTROL_EVALUATE = 98; -export const CONTROL_DYNAMIC_ANCHOR_JUMP = 99; -export const CONTROL_JUMP = 100; +export const ASSERTION_NUMBER_INTEGER_BOUNDED = 40; +export const ASSERTION_NUMBER_TYPE = 41; +export const ASSERTION_STRING_TYPE = 42; +export const ASSERTION_PROPERTY_TYPE = 43; +export const ASSERTION_PROPERTY_TYPE_EVALUATE = 44; +export const ASSERTION_PROPERTY_TYPE_STRICT = 45; +export const ASSERTION_PROPERTY_TYPE_STRICT_EVALUATE = 46; +export const ASSERTION_PROPERTY_TYPE_STRICT_ANY = 47; +export const ASSERTION_PROPERTY_TYPE_STRICT_ANY_EVALUATE = 48; +export const ASSERTION_ARRAY_PREFIX = 49; +export const ASSERTION_ARRAY_PREFIX_EVALUATE = 50; +export const ASSERTION_OBJECT_PROPERTIES_SIMPLE = 51; +export const ANNOTATION_EMIT = 52; +export const ANNOTATION_EMIT_WRAPPED = 53; +export const ANNOTATION_TO_PARENT = 54; +export const ANNOTATION_BASENAME_TO_PARENT = 55; +export const EVALUATE = 56; +export const LOGICAL_NOT = 57; +export const LOGICAL_NOT_EVALUATE = 58; +export const LOGICAL_OR = 59; +export const LOGICAL_AND = 60; +export const LOGICAL_XOR = 61; +export const LOGICAL_CONDITION = 62; +export const LOGICAL_WHEN_TYPE = 63; +export const LOGICAL_WHEN_DEFINES = 64; +export const LOGICAL_WHEN_ARRAY_SIZE_GREATER = 65; +export const LOOP_PROPERTIES_UNEVALUATED = 66; +export const LOOP_PROPERTIES_UNEVALUATED_EXCEPT = 67; +export const LOOP_PROPERTIES_MATCH = 68; +export const LOOP_PROPERTIES_MATCH_CLOSED = 69; +export const LOOP_PROPERTIES = 70; +export const LOOP_PROPERTIES_EVALUATE = 71; +export const LOOP_PROPERTIES_REGEX = 72; +export const LOOP_PROPERTIES_REGEX_CLOSED = 73; +export const LOOP_PROPERTIES_STARTS_WITH = 74; +export const LOOP_PROPERTIES_EXCEPT = 75; +export const LOOP_PROPERTIES_TYPE = 76; +export const LOOP_PROPERTIES_TYPE_EVALUATE = 77; +export const LOOP_PROPERTIES_EXACTLY_TYPE_STRICT = 78; +export const LOOP_PROPERTIES_EXACTLY_TYPE_STRICT_HASH = 79; +export const LOOP_PROPERTIES_TYPE_STRICT = 80; +export const LOOP_PROPERTIES_TYPE_STRICT_EVALUATE = 81; +export const LOOP_PROPERTIES_TYPE_STRICT_ANY = 82; +export const LOOP_PROPERTIES_TYPE_STRICT_ANY_EVALUATE = 83; +export const LOOP_KEYS = 84; +export const LOOP_ITEMS = 85; +export const LOOP_ITEMS_FROM = 86; +export const LOOP_ITEMS_UNEVALUATED = 87; +export const LOOP_ITEMS_TYPE = 88; +export const LOOP_ITEMS_TYPE_STRICT = 89; +export const LOOP_ITEMS_TYPE_STRICT_ANY = 90; +export const LOOP_ITEMS_PROPERTIES_EXACTLY_TYPE_STRICT_HASH = 91; +export const LOOP_ITEMS_PROPERTIES_EXACTLY_TYPE_STRICT_HASH3 = 92; +export const LOOP_ITEMS_INTEGER_BOUNDED = 93; +export const LOOP_ITEMS_INTEGER_BOUNDED_SIZED = 94; +export const LOOP_CONTAINS = 95; +export const CONTROL_GROUP = 96; +export const CONTROL_GROUP_WHEN_DEFINES = 97; +export const CONTROL_GROUP_WHEN_DEFINES_DIRECT = 98; +export const CONTROL_GROUP_WHEN_TYPE = 99; +export const CONTROL_EVALUATE = 100; +export const CONTROL_DYNAMIC_ANCHOR_JUMP = 101; +export const CONTROL_JUMP = 102; export const INSTRUCTION_NAMES = { "AssertionFail": ASSERTION_FAIL, @@ -141,6 +143,8 @@ export const INSTRUCTION_NAMES = { "AssertionTypeIntegerBoundedStrict": ASSERTION_TYPE_INTEGER_BOUNDED_STRICT, "AssertionTypeIntegerLowerBound": ASSERTION_TYPE_INTEGER_LOWER_BOUND, "AssertionTypeIntegerLowerBoundStrict": ASSERTION_TYPE_INTEGER_LOWER_BOUND_STRICT, + "AssertionNumberIntegerBounded": ASSERTION_NUMBER_INTEGER_BOUNDED, + "AssertionNumberType": ASSERTION_NUMBER_TYPE, "AssertionStringType": ASSERTION_STRING_TYPE, "AssertionPropertyType": ASSERTION_PROPERTY_TYPE, "AssertionPropertyTypeEvaluate": ASSERTION_PROPERTY_TYPE_EVALUATE, diff --git a/vendor/blaze/schemas/canonical-draft3.json b/vendor/blaze/schemas/canonical-draft3.json index f973823c..c269051b 100644 --- a/vendor/blaze/schemas/canonical-draft3.json +++ b/vendor/blaze/schemas/canonical-draft3.json @@ -5,132 +5,96 @@ "description": "Meta-schema that validates whether a given JSON Schema Draft 3 document is in canonical form", "examples": [ { - "$ref": "#/foo" - }, - { - "enum": [ 1, 2, 3 ] - }, - { - "type": "string", - "minLength": 0 + "extends": [ + { + "$ref": "#/definitions/0" + } + ], + "definitions": { + "0": { + "enum": [ 1, 2, 3 ] + } + } } ], - "$ref": "#/$defs/schema", + "$ref": "#/$defs/document", "$defs": { - "metadata": { - "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "default": true - }, - "patternProperties": { - "^x-": true - } - }, - "core": { + "document": { "x-lint-exclude": "simple_properties_identifiers", + "type": "object", + "required": [ "extends", "definitions" ], "properties": { "$schema": { "type": "string" }, + "extends": { + "type": "array", + "maxItems": 1, + "minItems": 1, + "items": { + "$ref": "#/$defs/edge" + } + }, "definitions": { - "$comment": "TODO: `definitions` is permitted on any subschema as an interim measure, because the canonicaliser preserves it wherever the author wrote it. The desired shape is a single flat `definitions` at the root holding every subschema as a node, with `$ref`s as the edges between them, after which this should be tightened to the root only", "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^(0|[1-9][0-9]*)$" + }, "additionalProperties": { - "$ref": "#/$defs/schema" + "$ref": "#/$defs/node" } } - } - }, - "subproperty": { - "$ref": "#/$defs/schema", - "if": { - "type": "object", - "not": { - "anyOf": [ - { - "maxProperties": 0 - }, - { - "x-lint-exclude": "simple_properties_identifiers", - "required": [ "$ref" ], - "properties": { - "$ref": true - } - } - ] - } }, - "then": { - "required": [ "required" ], - "properties": { - "required": { - "type": "boolean" - } - } - } + "additionalProperties": false }, - "extends": { + "edge": { "x-lint-exclude": "simple_properties_identifiers", "type": "object", - "allOf": [ - { - "$ref": "#/$defs/metadata" - }, - { - "$ref": "#/$defs/core" - } - ], - "required": [ "extends" ], + "required": [ "$ref" ], "properties": { - "extends": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/schema" - } - }, - "required": { - "type": "boolean" + "$ref": { + "type": "string", + "pattern": "^#/definitions/(0|[1-9][0-9]*)$" } }, - "unevaluatedProperties": false + "additionalProperties": false }, - "union": { - "x-lint-exclude": "simple_properties_identifiers", - "type": "object", - "allOf": [ + "subproperty": { + "anyOf": [ { - "$ref": "#/$defs/metadata" + "$ref": "#/$defs/edge" }, { - "$ref": "#/$defs/core" - } - ], - "required": [ "type" ], - "properties": { - "type": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/schema" - } + "type": "object", + "required": [ "required", "extends" ], + "properties": { + "required": { + "type": "boolean" + }, + "extends": { + "type": "array", + "maxItems": 1, + "minItems": 1, + "items": { + "$ref": "#/$defs/edge" + } + } + }, + "additionalProperties": false } - }, - "unevaluatedProperties": false + ] }, - "leaf": { + "node": { "anyOf": [ { "const": {} }, + { + "type": "boolean" + }, { "x-lint-exclude": "simple_properties_identifiers", - "$ref": "#/$defs/metadata", "type": "object", "required": [ "$ref" ], "properties": { @@ -138,19 +102,10 @@ "type": "string" } }, - "unevaluatedProperties": false + "additionalProperties": false }, { - "x-lint-exclude": "simple_properties_identifiers", "type": "object", - "allOf": [ - { - "$ref": "#/$defs/metadata" - }, - { - "$ref": "#/$defs/core" - } - ], "required": [ "enum" ], "properties": { "enum": { @@ -191,19 +146,10 @@ "uniqueItems": true } }, - "unevaluatedProperties": false + "additionalProperties": false }, { - "x-lint-exclude": "simple_properties_identifiers", "type": "object", - "allOf": [ - { - "$ref": "#/$defs/metadata" - }, - { - "$ref": "#/$defs/core" - } - ], "required": [ "type", "minLength" ], "properties": { "type": { @@ -224,19 +170,10 @@ "type": "string" } }, - "unevaluatedProperties": false + "additionalProperties": false }, { - "x-lint-exclude": "simple_properties_identifiers", "type": "object", - "allOf": [ - { - "$ref": "#/$defs/metadata" - }, - { - "$ref": "#/$defs/core" - } - ], "required": [ "type", "divisibleBy" ], "properties": { "type": { @@ -253,19 +190,10 @@ "type": "number" } }, - "unevaluatedProperties": false + "additionalProperties": false }, { - "x-lint-exclude": "simple_properties_identifiers", "type": "object", - "allOf": [ - { - "$ref": "#/$defs/metadata" - }, - { - "$ref": "#/$defs/core" - } - ], "required": [ "type" ], "properties": { "type": { @@ -288,19 +216,10 @@ "type": "boolean" } }, - "unevaluatedProperties": false + "additionalProperties": false }, { - "x-lint-exclude": "simple_properties_identifiers", "type": "object", - "allOf": [ - { - "$ref": "#/$defs/metadata" - }, - { - "$ref": "#/$defs/core" - } - ], "required": [ "type", "properties", @@ -320,40 +239,24 @@ "patternProperties": { "type": "object", "additionalProperties": { - "$ref": "#/$defs/schema" + "$ref": "#/$defs/edge" } }, "additionalProperties": { - "anyOf": [ - { - "$ref": "#/$defs/schema" - }, - { - "type": "boolean" - } - ] + "$ref": "#/$defs/edge" } }, - "unevaluatedProperties": false + "additionalProperties": false }, { - "x-lint-exclude": "simple_properties_identifiers", "type": "object", - "allOf": [ - { - "$ref": "#/$defs/metadata" - }, - { - "$ref": "#/$defs/core" - } - ], "required": [ "type", "items", "minItems", "uniqueItems" ], "properties": { "type": { "const": "array" }, "items": { - "$ref": "#/$defs/schema" + "$ref": "#/$defs/edge" }, "minItems": { "type": "integer", @@ -367,19 +270,10 @@ "type": "boolean" } }, - "unevaluatedProperties": false + "additionalProperties": false }, { - "x-lint-exclude": "simple_properties_identifiers", "type": "object", - "allOf": [ - { - "$ref": "#/$defs/metadata" - }, - { - "$ref": "#/$defs/core" - } - ], "required": [ "type", "items", @@ -395,18 +289,11 @@ "type": "array", "minItems": 1, "items": { - "$ref": "#/$defs/schema" + "$ref": "#/$defs/edge" } }, "additionalItems": { - "anyOf": [ - { - "$ref": "#/$defs/schema" - }, - { - "type": "boolean" - } - ] + "$ref": "#/$defs/edge" }, "minItems": { "type": "integer", @@ -420,32 +307,38 @@ "type": "boolean" } }, - "unevaluatedProperties": false - } - ] - }, - "schema": { - "anyOf": [ - { - "$ref": "#/$defs/leaf" + "additionalProperties": false }, { - "$ref": "#/$defs/union" + "type": "object", + "required": [ "type" ], + "properties": { + "type": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/edge" + } + } + }, + "additionalProperties": false }, { - "$ref": "#/$defs/extends" + "type": "object", + "required": [ "extends" ], + "properties": { + "extends": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/edge" + } + } + }, + "additionalProperties": false }, { - "x-lint-exclude": "simple_properties_identifiers", "type": "object", - "allOf": [ - { - "$ref": "#/$defs/metadata" - }, - { - "$ref": "#/$defs/core" - } - ], "required": [ "disallow" ], "properties": { "disallow": { @@ -453,22 +346,11 @@ "maxItems": 1, "minItems": 1, "items": { - "$comment": "TODO: `disallow` should only ever wrap a single-kind leaf. We additionally permit `extends` and `type` unions here as an interim escape hatch: negating a conjunction or disjunction whose wrapper is targeted by a `$ref` cannot be pushed to the leaves, because doing so would dissolve the referenced node. The proper fix is to invert such references so the `disallow` wraps a `$ref` leaf instead, after which both can be dropped from this list", - "anyOf": [ - { - "$ref": "#/$defs/leaf" - }, - { - "$ref": "#/$defs/union" - }, - { - "$ref": "#/$defs/extends" - } - ] + "$ref": "#/$defs/edge" } } }, - "unevaluatedProperties": false + "additionalProperties": false } ] } diff --git a/vendor/blaze/src/alterschema/schema_helpers.h b/vendor/blaze/src/alterschema/schema_helpers.h index 736fd875..cd8848d2 100644 --- a/vendor/blaze/src/alterschema/schema_helpers.h +++ b/vendor/blaze/src/alterschema/schema_helpers.h @@ -29,6 +29,7 @@ inline auto parse_schema_type_string(const sourcemeta::core::JSON::String &type, } else if (type == "number") { result.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); result.set(std::to_underlying(sourcemeta::core::JSON::Type::Real)); + result.set(std::to_underlying(sourcemeta::core::JSON::Type::Decimal)); } else if (type == "integer") { result.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); } else if (type == "string") { diff --git a/vendor/blaze/src/alterschema/wrap.cc b/vendor/blaze/src/alterschema/wrap.cc index 2b65fca3..63eeeea9 100644 --- a/vendor/blaze/src/alterschema/wrap.cc +++ b/vendor/blaze/src/alterschema/wrap.cc @@ -80,7 +80,7 @@ auto wrap(const sourcemeta::core::JSON &schema, sourcemeta::core::URI uri{identifier}; try { - schema_reidentify(copy, identifier, resolver, location.dialect); + schema_reidentify(copy, identifier, walker, resolver, location.dialect); // Otherwise we will get an error with the `WRAPPER_IDENTIFIER`, which will // be confusing to end users diff --git a/vendor/blaze/src/canonicalizer/CMakeLists.txt b/vendor/blaze/src/canonicalizer/CMakeLists.txt index e22d6c20..81dd3e87 100644 --- a/vendor/blaze/src/canonicalizer/CMakeLists.txt +++ b/vendor/blaze/src/canonicalizer/CMakeLists.txt @@ -83,6 +83,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT blaze NAME canonicalizer rules/maximum_can_equal_integer_fold.h rules/maximum_can_equal_true_drop.h rules/maximum_real_for_integer.h + rules/metadata_keywords_drop.h rules/min_contains_without_contains.h rules/min_items_given_min_contains.h rules/min_length_implicit.h @@ -122,6 +123,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT blaze NAME canonicalizer rules/type_with_applicator_to_extends.h rules/unevaluated_items_to_items.h rules/unevaluated_properties_to_additional_properties.h + rules/unknown_keywords_drop.h rules/unknown_keywords_prefix.h rules/unknown_local_ref.h rules/unnecessary_allof_ref_wrapper_draft.h diff --git a/vendor/blaze/src/canonicalizer/canonicalize.cc b/vendor/blaze/src/canonicalizer/canonicalize.cc index 08094eab..55bf5185 100644 --- a/vendor/blaze/src/canonicalizer/canonicalize.cc +++ b/vendor/blaze/src/canonicalizer/canonicalize.cc @@ -8,7 +8,8 @@ #include "schema_helpers.h" #include // std::sort, std::unique, std::ranges::contains, - // std::ranges::none_of + // std::ranges::none_of, std::ranges::sort, + // std::ranges::find, std::ranges::find_if #include // std::array #include // std::popcount #include // assert @@ -18,8 +19,10 @@ #include // std::uint64_t #include // std::hash, std::ref #include // std::numeric_limits +#include // std::map #include // std::make_unique, std::unique_ptr #include // std::optional, std::nullopt +#include // std::set #include // std::string #include // std::string_view #include // std::tuple @@ -324,6 +327,699 @@ auto eliminate_identifiers(sourcemeta::core::JSON &schema, } } +/// Order two pointers by their tokens, comparing array positions as numbers +/// rather than as text, so that `/type/2` comes before `/type/10` +auto pointer_before(const sourcemeta::core::Pointer &left, + const sourcemeta::core::Pointer &right) -> bool { + for (std::size_t index = 0; index < left.size() && index < right.size(); + index++) { + const auto &first{left.at(index)}; + const auto &second{right.at(index)}; + if (first.is_property() != second.is_property()) { + return second.is_property(); + } + + if (first.is_property()) { + if (first.to_property() != second.to_property()) { + return first.to_property() < second.to_property(); + } + } else if (first.to_index() != second.to_index()) { + return first.to_index() < second.to_index(); + } + } + + return left.size() < right.size(); +} + +/// Whether the given reference points at an entry this pass produced. A +/// reference that leaves the document keeps its absolute URI instead +auto entry_number(const sourcemeta::core::JSON &reference) + -> std::optional { + static const std::string_view PREFIX{"#/definitions/"}; + if (!reference.is_string()) { + return std::nullopt; + } + + const auto &value{reference.to_string()}; + if (!value.starts_with(PREFIX)) { + return std::nullopt; + } + + const auto suffix{value.substr(PREFIX.size())}; + if (suffix.empty() || + suffix.find_first_not_of("0123456789") != std::string::npos) { + return std::nullopt; + } + + return static_cast(std::stoull(suffix)); +} + +/// The reference an entry amounts to, when the entry is nothing but a +/// reference: either on its own, or behind the one-element `extends` that +/// this pass leaves in place of a lifted subschema. Such a subschema is an +/// edge of the graph rather than a node of it +auto edge_reference(const sourcemeta::core::JSON &body) + -> const sourcemeta::core::JSON * { + if (!body.is_object() || body.size() != 1) { + return nullptr; + } + + if (body.defines("$ref")) { + return &body.at("$ref"); + } + + if (!body.defines("extends")) { + return nullptr; + } + + const auto &branches{body.at("extends")}; + if (!branches.is_array() || branches.size() != 1) { + return nullptr; + } + + const auto &branch{branches.at(0)}; + if (branch.is_object() && branch.size() == 1 && branch.defines("$ref")) { + return &branch.at("$ref"); + } + + return nullptr; +} + +/// Whether the given body is an edge rather than a node. Only a reference +/// that stays inside the document can reach an entry, so one that leaves it +/// becomes an entry of its own and every edge really is an edge of the graph +auto is_edge(const sourcemeta::core::JSON &body) -> bool { + const auto *reference{edge_reference(body)}; + return reference != nullptr && reference->is_string() && + reference->to_string().starts_with('#'); +} + +/// What a subschema says, with the keywords that carry the graph itself taken +/// out. `definitions` holds other nodes rather than part of this one, and a +/// `required` marker belongs to the parent `properties` +auto without_containers(sourcemeta::core::JSON body) -> sourcemeta::core::JSON { + if (body.is_object()) { + body.erase("$schema"); + body.erase("definitions"); + body.erase("required"); + } + + return body; +} + +/// Every place inside a node body where a reference can sit, as a pointer to +/// the `$ref` itself. +/// +/// Whether a `$ref` is a reference at all depends on where it sits. The value +/// of an `enum` or a `default`, and of any extension keyword, is instance data +/// rather than a schema, so an object in there that happens to have a `$ref` +/// member says nothing about references and must be left exactly as it is. +/// Looking for `$ref` anywhere in a body would rewrite such a value, or fail +/// on one that names an entry that does not exist. +/// +/// Members are read in key order so that the result never depends on the order +/// an object happens to store them in +auto reference_slots(const sourcemeta::core::JSON &body) + -> std::vector { + static const std::set MAPPINGS{ + "properties", "patternProperties", "dependencies"}; + static const std::set SINGLES{ + "additionalProperties", "additionalItems"}; + // `items` holds either one subschema or a tuple of them, and the three + // Draft 3 keywords that take branches hold an array once the rules have run, + // though hyper-schema can leave a lone subschema behind + static const std::set BRANCHES{ + "items", "type", "extends", "disallow"}; + + std::vector result; + const auto collect{[&result](const sourcemeta::core::JSON ¤t, + const core::Pointer &position, + const auto &self) -> void { + if (!current.is_object()) { + return; + } + + std::vector keys; + keys.reserve(current.size()); + for (const auto &member : current.as_object()) { + keys.push_back(member.first); + } + + std::ranges::sort(keys); + for (const auto &key : keys) { + const auto &value{current.at(key)}; + if (key == "$ref") { + if (value.is_string()) { + result.push_back(position.concat(key)); + } + } else if (MAPPINGS.contains(key) && value.is_object()) { + std::vector names; + names.reserve(value.size()); + for (const auto &member : value.as_object()) { + names.push_back(member.first); + } + + std::ranges::sort(names); + for (const auto &name : names) { + self(value.at(name), position.concat(key).concat(name), self); + } + } else if (SINGLES.contains(key)) { + self(value, position.concat(key), self); + } else if (BRANCHES.contains(key)) { + if (value.is_array()) { + for (std::size_t index = 0; index < value.size(); index++) { + self(value.at(index), + position.concat(key).concat( + static_cast(index)), + self); + } + } else { + self(value, position.concat(key), self); + } + } + } + }}; + + collect(body, core::Pointer{}, collect); + return result; +} +/// Rewrite a Draft 3 document into its graph form: every subschema becomes an +/// entry of a single `definitions` at the root, nesting is replaced by +/// references between those entries, and the root becomes a wrapper that +/// references the entry standing for the document. +/// +/// Like identifier elimination, this only applies to documents that are Draft 3 +/// throughout, and it runs once the rules have finished reshaping the schema +auto lift_subschemas(sourcemeta::core::JSON &schema, + const sourcemeta::core::SchemaWalker &walker, + const sourcemeta::core::SchemaResolver &resolver, + const std::string_view default_dialect, + const std::string_view default_id) -> void { + if (!schema.is_object()) { + return; + } + + std::vector nodes; + // Every reference the document declares, before edges are followed through + std::vector> links; + std::vector> reference_targets; + + { + const core::SchemaFrame frame{core::SchemaFrame::Mode::References, + schema, + walker, + resolver, + default_dialect, + default_id, + core::SchemaFrame::IdentifierMode::Fallback}; + + // Hyper-schema comes this far too. Nothing its vocabulary declares takes + // part in whether an instance is valid, so the rules have dropped all of + // it by now, and what is left is Draft 3 proper however the document + // spells its dialect + if (frame.any_subschema( + [](const core::SchemaFrame::Location &location) -> bool { + return !is_draft3(location.base_dialect); + })) { + return; + } + + frame.for_each_subschema( + [&nodes, &schema](const core::SchemaFrame::Location &location) -> void { + // The test runs on what the body would be once the container + // keywords are stripped, so that a root already in graph form is + // recognised as the wrapper it is + if (is_edge( + without_containers(core::get(schema, location.pointer)))) { + return; + } + + auto pointer{core::to_pointer(location.pointer)}; + if (!std::ranges::contains(nodes, pointer)) { + nodes.push_back(std::move(pointer)); + } + }); + + if (nodes.empty()) { + return; + } + + // The frame is free to enumerate subschemas in any order, so fix one here + // and let nothing downstream depend on which order that was + std::ranges::sort(nodes, pointer_before); + + frame.for_each_reference( + [&links, &frame]( + const core::SchemaReferenceType, const core::WeakPointer &origin, + const core::SchemaFrame::Reference &reference) -> void { + assert(!origin.empty() && origin.back().is_property()); + if (origin.back().to_property() != "$ref") { + return; + } + + const auto destination{frame.traverse(reference.destination)}; + if (destination.has_value()) { + links.emplace_back( + core::to_pointer(origin), + core::to_pointer(destination.value().get().pointer)); + } + }); + } + + // A reference can target a subschema that is itself an edge, so follow such + // a chain through to the node it ultimately reaches + static const core::JSON::String REF_KEYWORD{"$ref"}; + static const core::JSON::String EXTENDS_KEYWORD{"extends"}; + + const auto resolve{[&nodes, &links](const core::Pointer &start) + -> std::optional { + auto target{start}; + for (std::size_t hop = 0; hop <= links.size(); hop++) { + const auto match{std::ranges::find(nodes, target)}; + if (match != nodes.end()) { + return static_cast(match - nodes.begin()); + } + + const auto direct{ + std::ranges::find_if(links, [&target](const auto &entry) -> bool { + return entry.first == target.concat({REF_KEYWORD}); + })}; + if (direct != links.end()) { + target = direct->second; + continue; + } + + const auto wrapped{ + std::ranges::find_if(links, [&target](const auto &entry) -> bool { + return entry.first == + target.concat({EXTENDS_KEYWORD, + static_cast(0), + REF_KEYWORD}); + })}; + if (wrapped != links.end()) { + target = wrapped->second; + continue; + } + + break; + } + + return std::nullopt; + }}; + + // A reference whose chain goes round in circles without ever reaching a + // subschema that says anything never gets to assert anything either, so it + // is the empty schema written the long way round. Each one is replaced by an + // empty schema and the pass starts over, since the document it framed has + // changed underneath it. Every round takes a reference away, so this ends + std::vector vacuous; + for (const auto &entry : links) { + const auto destination{resolve(entry.second)}; + if (destination.has_value()) { + reference_targets.emplace_back(entry.first, destination.value()); + } else { + vacuous.push_back(entry.first.initial()); + } + } + + if (!vacuous.empty()) { + for (const auto &pointer : vacuous) { + core::set(schema, pointer, sourcemeta::core::JSON::make_object()); + } + + lift_subschemas(schema, walker, resolver, default_dialect, default_id); + return; + } + + // The innermost node that contains the given pointer, if any + const auto enclosing{ + [&nodes](const core::Pointer &pointer) -> std::optional { + std::optional result; + for (std::size_t index = 0; index < nodes.size(); index++) { + if (pointer.starts_with(nodes.at(index)) && + (!result.has_value() || + nodes.at(index).size() > nodes.at(result.value()).size())) { + result = index; + } + } + + return result; + }}; + + const auto link{[](const std::size_t index, + const sourcemeta::core::JSON &target, + const core::Pointer &position) -> sourcemeta::core::JSON { + auto reference{sourcemeta::core::JSON::make_object()}; + reference.assign("$ref", sourcemeta::core::JSON{"#/definitions/" + + std::to_string(index)}); + + // `required` is read by the parent `properties`, and Draft 3 ignores + // the siblings of a `$ref`, so the marker can neither travel into the + // entry nor sit next to the reference. It stays behind, wrapping it + const auto *marker{target.is_object() ? target.try_at("required") + : nullptr}; + // Anywhere but a property entry the marker means nothing at all, so + // a subschema under `items` or `additionalProperties` loses it + const auto under_properties{ + position.size() >= 2 && + position.at(position.size() - 2).is_property() && + position.at(position.size() - 2).to_property() == "properties"}; + if (marker == nullptr || !under_properties) { + return reference; + } + + auto wrapper{sourcemeta::core::JSON::make_object()}; + wrapper.assign("required", *marker); + auto branches{sourcemeta::core::JSON::make_array()}; + branches.push_back(std::move(reference)); + wrapper.assign("extends", std::move(branches)); + return wrapper; + }}; + + const auto entry_node{resolve(core::Pointer{})}; + if (!entry_node.has_value()) { + return; + } + + const auto root{entry_node.value()}; + + // The entries that survive are renamed below, in the order the graph is + // walked, so the names given here never reach the result. They are simply + // named by position + auto definitions{sourcemeta::core::JSON::make_object()}; + for (std::size_t index = 0; index < nodes.size(); index++) { + const auto &pointer{nodes.at(index)}; + auto body{core::get(schema, pointer)}; + + for (std::size_t other = 0; other < nodes.size(); other++) { + if (other == index || !nodes.at(other).starts_with(pointer) || + enclosing(nodes.at(other).initial()) != index) { + continue; + } + + const auto position{nodes.at(other).resolve_from(pointer)}; + core::set(body, position, + link(other, core::get(schema, nodes.at(other)), position)); + } + + for (const auto &entry : reference_targets) { + if (!entry.first.starts_with(pointer) || + enclosing(entry.first.initial()) != index) { + continue; + } + + core::set(body, entry.first.resolve_from(pointer), + sourcemeta::core::JSON{"#/definitions/" + + std::to_string(entry.second)}); + } + + if (body.is_object()) { + body.erase("$schema"); + body.erase("required"); + // Every entry of a `definitions` is a node of its own, so the container + // is not part of any body. Leaving it in would make a later pass lift + // the same entries again + body.erase("definitions"); + } + + definitions.assign(std::to_string(index), std::move(body)); + } + + // Collapsing an edge can leave a body that is an edge itself, and so can + // merging two entries into one, so each step gives the other more to do. + // Repeat them until neither changes anything, otherwise canonicalising a + // second time would keep going where the first time stopped + auto entry_point{root}; + for (std::size_t round = 0; round <= nodes.size(); round++) { + const auto before{definitions}; + + // A body only turns into an edge once its own children have been lifted, + // so edges are collapsed here rather than when the nodes were chosen: + // every reference to such an entry is pointed at whatever it referenced, + // and the entry itself goes away + std::map collapsed; + for (const auto &entry : definitions.as_object()) { + const auto *reference{edge_reference(entry.second)}; + if (reference == nullptr) { + continue; + } + + const auto target{entry_number(*reference)}; + if (target.has_value()) { + collapsed.emplace(std::stoull(entry.first), target.value()); + } + } + + const auto destination{[&collapsed](std::size_t index) -> std::size_t { + for (std::size_t hop = 0; hop <= collapsed.size(); hop++) { + const auto match{collapsed.find(index)}; + if (match == collapsed.cend()) { + break; + } + + index = match->second; + } + + return index; + }}; + + // What each surviving entry is called once the collapsed ones are gone + std::map renamed; + for (const auto &entry : definitions.as_object()) { + const auto index{std::stoull(entry.first)}; + if (!collapsed.contains(index)) { + renamed.emplace(index, renamed.size()); + } + } + + const auto rewrite{[&destination, + &renamed](sourcemeta::core::JSON &body) -> void { + for (const auto &slot : reference_slots(body)) { + const auto entry{entry_number(core::get(body, slot))}; + if (!entry.has_value()) { + continue; + } + + const auto target{renamed.at(destination(entry.value()))}; + core::set( + body, slot, + sourcemeta::core::JSON{"#/definitions/" + std::to_string(target)}); + } + }}; + + auto surviving{sourcemeta::core::JSON::make_object()}; + for (const auto &entry : renamed) { + auto body{definitions.at(std::to_string(entry.first))}; + rewrite(body); + surviving.assign(std::to_string(entry.second), std::move(body)); + } + + definitions = std::move(surviving); + const auto collapsed_entry{renamed.at(destination(entry_point))}; + + // Entries that say the same thing become one entry, which is what makes + // this a graph rather than a tree: every empty schema, for instance, ends + // up as a single node that everything else references. + // + // Two entries say the same thing when their bodies match once references + // are set aside, and when the entries those references lead to also say the + // same thing. That is a fixpoint, so start by assuming every body that + // matches is the same entry, then keep splitting entries whose references + // disagree until nothing splits any more + std::vector bodies; + bodies.reserve(definitions.size()); + for (std::size_t index = 0; index < definitions.size(); index++) { + bodies.push_back(definitions.at(std::to_string(index))); + } + + // The order the references come out in decides the numbering, so it must + // not depend on the order an object happens to store its members in. + // Reading them by sorted key gives the same answer however the object was + // built + const auto targets{[](const sourcemeta::core::JSON &body, + std::vector &result) -> void { + for (const auto &slot : reference_slots(body)) { + const auto entry{entry_number(core::get(body, slot))}; + if (entry.has_value()) { + result.push_back(entry.value()); + } + } + }}; + + // Only the references this pass produced are set aside. A reference that + // leaves the document names no entry, so it stays part of what the body + // says: two entries that point at different documents are not the same + const auto skeleton{ + [](const sourcemeta::core::JSON &body) -> sourcemeta::core::JSON { + auto result{body}; + for (const auto &slot : reference_slots(result)) { + if (entry_number(core::get(result, slot)).has_value()) { + core::set(result, slot, sourcemeta::core::JSON{""}); + } + } + + return result; + }}; + + std::vector> outgoing{bodies.size()}; + std::vector classes(bodies.size(), 0); + std::vector shapes; + for (std::size_t index = 0; index < bodies.size(); index++) { + targets(bodies.at(index), outgoing.at(index)); + auto shape{skeleton(bodies.at(index))}; + const auto match{std::ranges::find(shapes, shape)}; + if (match == shapes.end()) { + classes.at(index) = shapes.size(); + shapes.push_back(std::move(shape)); + } else { + classes.at(index) = static_cast(match - shapes.begin()); + } + } + + while (true) { + std::vector> signatures; + std::vector refined(bodies.size(), 0); + for (std::size_t index = 0; index < bodies.size(); index++) { + std::vector signature{classes.at(index)}; + for (const auto target : outgoing.at(index)) { + signature.push_back(classes.at(target)); + } + + const auto match{std::ranges::find(signatures, signature)}; + if (match == signatures.end()) { + refined.at(index) = signatures.size(); + signatures.push_back(std::move(signature)); + } else { + refined.at(index) = + static_cast(match - signatures.begin()); + } + } + + if (refined == classes) { + break; + } + + classes = std::move(refined); + } + + // One entry stands for each group, and the order comes from walking the + // graph from the entry point, so that it depends on what the nodes say + // rather than on where they happened to come from + std::map representative; + for (std::size_t index = 0; index < bodies.size(); index++) { + representative.emplace(classes.at(index), index); + } + + std::vector walked; + const auto walk{[&classes, &outgoing, &representative, &walked]( + const std::size_t group, const auto &self) -> void { + if (std::ranges::contains(walked, group)) { + return; + } + + walked.push_back(group); + for (const auto target : outgoing.at(representative.at(group))) { + self(classes.at(target), self); + } + }}; + + walk(classes.at(collapsed_entry), walk); + for (std::size_t index = 0; index < bodies.size(); index++) { + walk(classes.at(index), walk); + } + + std::map position; + for (std::size_t index = 0; index < walked.size(); index++) { + position.emplace(walked.at(index), index); + } + + const auto relabel{[&classes, &position](const sourcemeta::core::JSON &body) + -> sourcemeta::core::JSON { + auto result{body}; + for (const auto &slot : reference_slots(result)) { + const auto entry{entry_number(core::get(result, slot))}; + if (!entry.has_value()) { + continue; + } + + core::set(result, slot, + sourcemeta::core::JSON{ + "#/definitions/" + + std::to_string(position.at(classes.at(entry.value())))}); + } + + return result; + }}; + + // Merging entries can leave a disjunction or a conjunction listing the same + // entry twice, which says nothing new. The rules already drop duplicates in + // these three keywords, but they run before any of this + const auto unique{[](sourcemeta::core::JSON &body) -> void { + if (!body.is_object()) { + return; + } + + for (const auto *keyword : {"type", "extends", "disallow"}) { + auto *entries{body.try_at(keyword)}; + if (entries == nullptr || !entries->is_array()) { + continue; + } + + auto result{sourcemeta::core::JSON::make_array()}; + for (const auto &entry : entries->as_array()) { + bool seen{false}; + for (const auto &kept : result.as_array()) { + if (kept == entry) { + seen = true; + break; + } + } + + if (!seen) { + result.push_back(entry); + } + } + + body.assign(keyword, std::move(result)); + } + }}; + + // The entries are written in the order they are numbered, so that the same + // graph always comes out spelled the same way. Following the order the + // groups were named in would instead follow where the nodes came from, and + // a document already in graph form names its nodes after the entries it + // holds, leaving `10` ahead of `2` + auto merged{sourcemeta::core::JSON::make_object()}; + for (std::size_t index = 0; index < walked.size(); index++) { + auto body{relabel(bodies.at(representative.at(walked.at(index))))}; + unique(body); + merged.assign(std::to_string(index), std::move(body)); + } + + const auto next{position.at(classes.at(collapsed_entry))}; + const auto settled{next == entry_point && merged == before}; + definitions = std::move(merged); + entry_point = next; + if (settled) { + break; + } + } + + auto result{sourcemeta::core::JSON::make_object()}; + if (schema.defines("$schema")) { + result.assign("$schema", schema.at("$schema")); + } + + auto wrapper{sourcemeta::core::JSON::make_array()}; + auto reference{sourcemeta::core::JSON::make_object()}; + reference.assign("$ref", sourcemeta::core::JSON{"#/definitions/" + + std::to_string(entry_point)}); + wrapper.push_back(std::move(reference)); + result.assign("extends", std::move(wrapper)); + result.assign("definitions", std::move(definitions)); + schema.into(std::move(result)); +} + #include "helpers.h" #include "rules/additional_items_implicit.h" @@ -392,6 +1088,7 @@ auto eliminate_identifiers(sourcemeta::core::JSON &schema, #include "rules/flatten_nested_allof.h" #include "rules/flatten_nested_anyof.h" #include "rules/flatten_nested_extends.h" +#include "rules/hyper_schema_keywords_drop.h" #include "rules/if_then_else_implicit.h" #include "rules/if_without_then_else.h" #include "rules/ignored_metaschema.h" @@ -405,6 +1102,7 @@ auto eliminate_identifiers(sourcemeta::core::JSON &schema, #include "rules/maximum_can_equal_integer_fold.h" #include "rules/maximum_can_equal_true_drop.h" #include "rules/maximum_real_for_integer.h" +#include "rules/metadata_keywords_drop.h" #include "rules/min_contains_without_contains.h" #include "rules/min_items_given_min_contains.h" #include "rules/min_length_implicit.h" @@ -444,6 +1142,7 @@ auto eliminate_identifiers(sourcemeta::core::JSON &schema, #include "rules/type_with_applicator_to_extends.h" #include "rules/unevaluated_items_to_items.h" #include "rules/unevaluated_properties_to_additional_properties.h" +#include "rules/unknown_keywords_drop.h" #include "rules/unknown_keywords_prefix.h" #include "rules/unknown_local_ref.h" #include "rules/unknown_type_names.h" @@ -474,6 +1173,8 @@ auto canonicalize(sourcemeta::core::JSON &schema, rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); + rules.push_back(make_rule()); + rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); @@ -540,6 +1241,7 @@ auto canonicalize(sourcemeta::core::JSON &schema, rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); + rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); @@ -601,6 +1303,10 @@ auto canonicalize(sourcemeta::core::JSON &schema, // Pointers, which some of them do not yet preserve when they move or copy // the subschemas those pointers go through eliminate_identifiers(schema, walker, resolver, default_dialect, default_id); + + // Lifting comes last: it depends on the identifiers being gone, since a + // subschema cannot be moved out of a scope that still sets a base URI + lift_subschemas(schema, walker, resolver, default_dialect, default_id); } } // namespace sourcemeta::blaze diff --git a/vendor/blaze/src/canonicalizer/helpers.h b/vendor/blaze/src/canonicalizer/helpers.h index aebd2821..416fb1ee 100644 --- a/vendor/blaze/src/canonicalizer/helpers.h +++ b/vendor/blaze/src/canonicalizer/helpers.h @@ -13,11 +13,126 @@ inline auto is_in_place_applicator(const SchemaKeywordType type) -> bool { type == SchemaKeywordType::ApplicatorValueInPlaceNegate; } -// Walk up from a schema location, continuing as long as the traversal -// predicate returns true for each keyword type encountered. Returns a -// reference to the pointer of the ancestor where the match callback returned -// true, or nullopt if no match was found or the traversal predicate stopped -// the walk. +// TODO: Move upstream, so that the compiler can share this predicate rather +// than repeating the same check +// Draft 3 and Draft 4 read "integer" as a number written without a fractional +// part, so `3.0` is not one there, and `type: "integer"` says something that +// an `enum` of integral values cannot. Draft 6 onwards widened it to any +// number whose fractional part is zero, and the dialects before Draft 3 read +// it that way too. Kept in step with `integral_reals_are_integers` in the +// compiler, which decides the same question when evaluating, other than the +// OpenAPI 3.0 Schema Object that the compiler's copy also names. No rule here +// reaches that dialect, as every one of them asks for a JSON Schema +// vocabulary, so adding it would be a line no test could hold. A rule that +// does come to reach it needs it here too +inline auto integral_reals_are_integers(const SchemaVocabularies &vocabularies) + -> bool { + return !vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4_HYPER}); +} + +// Whether an instance of a type the schema admits can equal this value. +// Equality reads a number by its value, so an integral number matches however +// it was written, which makes this a different question from whether the value +// is itself of an admitted type: an option of `1.0` is what an instance of `1` +// equals, and that instance is an integer in every dialect +inline auto type_can_equal(const sourcemeta::core::JSON::TypeSet &types, + const sourcemeta::core::JSON &value) -> bool { + using Type = sourcemeta::core::JSON::Type; + if (!value.is_number()) { + return types.test(std::to_underlying(value.type())); + } + + return (value.is_integral() && + types.test(std::to_underlying(Type::Integer))) || + types.test(std::to_underlying(Type::Real)) || + types.test(std::to_underlying(Type::Decimal)); +} + +// Whether every instance that can equal this value is of a type the schema +// admits, which is what leaves `type` with nothing to say that the value does +// not. Draft 3 and Draft 4 do not read a number written with a fractional part +// as an integer, so `type: "integer"` still turns away the `1.0` and the `1e0` +// that an option of `1` lets through +inline auto type_always_equals(const sourcemeta::core::JSON::TypeSet &types, + const sourcemeta::core::JSON &value, + const SchemaVocabularies &vocabularies) -> bool { + using Type = sourcemeta::core::JSON::Type; + if (!value.is_number()) { + return types.test(std::to_underlying(value.type())); + } + + // A number written with a fractional or an exponent part is stored as a real + // or as a decimal, so a type that names every number covers each spelling, + // and only an integral value also has an integer one to cover + if (types.test(std::to_underlying(Type::Real)) && + types.test(std::to_underlying(Type::Decimal))) { + return !value.is_integral() || + types.test(std::to_underlying(Type::Integer)); + } + + return value.is_integral() && types.test(std::to_underlying(Type::Integer)) && + integral_reals_are_integers(vocabularies); +} + +// Whether the `type` of a schema says nothing its `enum` options do not, which +// is what lets the type go. Draft 3 reads a bare `any` and an empty subschema +// as holding for every instance, so neither says anything, while a subschema +// this cannot read may say something the options do not +inline auto type_is_implied_by_enum(const sourcemeta::core::JSON &type, + const sourcemeta::core::JSON &options, + const SchemaVocabularies &vocabularies) + -> bool { + if (vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER})) { + if (type.is_string() && type.to_string() == "any") { + return true; + } + + if (type.is_array()) { + bool has_tautology{false}; + bool has_unknown_subschema{false}; + for (const auto &entry : type.as_array()) { + if (entry.is_string() && entry.to_string() == "any") { + has_tautology = true; + break; + } + + if (entry.is_object()) { + if (entry.empty()) { + has_tautology = true; + break; + } + + has_unknown_subschema = true; + } + } + + if (has_tautology) { + return true; + } + + if (has_unknown_subschema) { + return false; + } + } + } + + const auto types{parse_schema_type(type)}; + if (!types.any()) { + return false; + } + + return std::ranges::all_of( + options.as_array(), [&types, &vocabularies](const auto &option) -> bool { + return type_always_equals(types, option, vocabularies); + }); +} + // Whether a `type` value only consists of simple type names that can be // parsed into a complete set of instance types. Draft 0 to Draft 3 unions // may contain subschemas or `any`, in which case the parsed set is an @@ -119,6 +234,11 @@ inline auto into_unsatisfiable(sourcemeta::core::JSON &schema, } } +// Walk up from a schema location, continuing as long as the traversal +// predicate returns true for each keyword type encountered. Returns a +// reference to the pointer of the ancestor where the match callback returned +// true, or nullopt if no match was found or the traversal predicate stopped +// the walk. template auto walk_up(const sourcemeta::core::JSON &root, const SchemaFrame &frame, const SchemaFrame::Location &location, const SchemaWalker &walker, @@ -170,6 +290,105 @@ auto walk_up_in_place_applicators(const sourcemeta::core::JSON &root, is_in_place_applicator, matches); } +// Walk up the in-place applicator chain of a location once, reporting whether +// any ancestor matches and collecting every scope the walk passes through on +// the way. A reference landing on any of those scopes is one that reaches this +// location without moving through the instance, which is what makes them worth +// collecting. The two answers come from the same walk because the chain and +// the predicate that bounds it are the same either way +template +auto walk_up_in_place_applicators_collecting_scopes( + const sourcemeta::core::JSON &root, const SchemaFrame &frame, + const SchemaFrame::Location &location, const SchemaWalker &walker, + const SchemaResolver &resolver, const MatchCallback &matches, + std::set &scopes) -> bool { + scopes.insert(location.pointer); + auto current_pointer{location.pointer}; + auto current_parent{location.parent}; + + while (current_parent.has_value()) { + const auto &parent_pointer{current_parent.value()}; + const auto relative_pointer{current_pointer.resolve_from(parent_pointer)}; + assert(!relative_pointer.empty() && relative_pointer.at(0).is_property()); + const auto parent{frame.traverse(parent_pointer)}; + assert(parent.has_value()); + const auto &parent_vocabularies{ + frame.vocabularies(parent.value().get(), resolver)}; + if (!is_in_place_applicator( + walker(relative_pointer.at(0).to_property(), parent_vocabularies) + .type)) { + return false; + } + + if (matches(sourcemeta::core::get(root, parent_pointer), + parent_vocabularies)) { + return true; + } + + scopes.insert(parent_pointer); + current_pointer = parent_pointer; + current_parent = parent.value().get().parent; + } + + return false; +} + +// The lexical walk stops at a subschema nothing encloses, which is what a +// definition under `$defs` looks like. Whichever scope references that +// definition still sees the annotations it produces, so a keyword that reads +// them, such as `unevaluatedItems`, can sit on the far side of the reference. +// This repeats the walk from every scope that reaches this location through a +// reference, so such a keyword is found wherever it hides +template +auto walk_up_in_place_applicators_across_references( + const sourcemeta::core::JSON &root, const SchemaFrame &frame, + const SchemaFrame::Location &location, const SchemaWalker &walker, + const SchemaResolver &resolver, const MatchCallback &matches) -> bool { + std::vector> pending{ + std::cref(location)}; + std::set visited{location.pointer}; + + while (!pending.empty()) { + const auto ¤t{pending.back().get()}; + pending.pop_back(); + + // The caller already checked the location it asked about, but a scope we + // arrived at through a reference still has to be checked on its own + if (current.pointer != location.pointer && + matches(sourcemeta::core::get(root, current.pointer), + frame.vocabularies(current, resolver))) { + return true; + } + + std::set scopes; + if (walk_up_in_place_applicators_collecting_scopes( + root, frame, current, walker, resolver, matches, scopes)) { + return true; + } + + frame.for_each_reference( + [&](const SchemaReferenceType, + const sourcemeta::core::WeakPointer &origin, + const SchemaFrame::Reference &reference) -> void { + const auto destination{frame.traverse(reference.destination)}; + if (!destination.has_value() || + !scopes.contains(destination.value().get().pointer)) { + return; + } + + const auto source{frame.traverse(origin.initial())}; + if (!source.has_value() || + !visited.insert(source.value().get().pointer).second) { + return; + } + + pending.emplace_back(std::cref(source.value().get())); + }); + } + + return false; +} + #define ONLY_CONTINUE_IF(condition) \ if (!(condition)) { \ return false; \ diff --git a/vendor/blaze/src/canonicalizer/rules/additional_items_implicit.h b/vendor/blaze/src/canonicalizer/rules/additional_items_implicit.h index f4fd2dad..0c62c928 100644 --- a/vendor/blaze/src/canonicalizer/rules/additional_items_implicit.h +++ b/vendor/blaze/src/canonicalizer/rules/additional_items_implicit.h @@ -14,6 +14,7 @@ class AdditionalItemsImplicit final : public SchemaTransformRule { const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF(vocabularies.contains_any( {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7}) && @@ -24,8 +25,9 @@ class AdditionalItemsImplicit final : public SchemaTransformRule { const auto *items{schema.try_at("items")}; ONLY_CONTINUE_IF(items && items->is_array() && !schema.defines("additionalItems")); - this->is_draft3_ = - vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3); + this->is_draft3_ = vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}); return true; } diff --git a/vendor/blaze/src/canonicalizer/rules/dependencies_to_extends_disallow.h b/vendor/blaze/src/canonicalizer/rules/dependencies_to_extends_disallow.h index 8f08ecb5..feab4882 100644 --- a/vendor/blaze/src/canonicalizer/rules/dependencies_to_extends_disallow.h +++ b/vendor/blaze/src/canonicalizer/rules/dependencies_to_extends_disallow.h @@ -13,7 +13,9 @@ class DependenciesToExtendsDisallow final : public SchemaTransformRule { const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( - vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3) && + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && schema.is_object()); const auto *dependencies{schema.try_at("dependencies")}; diff --git a/vendor/blaze/src/canonicalizer/rules/disallow_to_array_of_schemas.h b/vendor/blaze/src/canonicalizer/rules/disallow_to_array_of_schemas.h index 479037c7..e3bbaae4 100644 --- a/vendor/blaze/src/canonicalizer/rules/disallow_to_array_of_schemas.h +++ b/vendor/blaze/src/canonicalizer/rules/disallow_to_array_of_schemas.h @@ -12,15 +12,18 @@ class DisallowToArrayOfSchemas final : public SchemaTransformRule { const sourcemeta::core::SchemaFrame::Location &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { - ONLY_CONTINUE_IF(vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3}) && - schema.is_object() && schema.defines("disallow")); + ONLY_CONTINUE_IF( + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && + schema.is_object() && schema.defines("disallow")); - this->convert_to_schemas_ = - vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3); + this->convert_to_schemas_ = vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}); const auto &disallow{schema.at("disallow")}; if (disallow.is_string()) { diff --git a/vendor/blaze/src/canonicalizer/rules/divisible_by_implicit.h b/vendor/blaze/src/canonicalizer/rules/divisible_by_implicit.h index f7705e93..72a4aaca 100644 --- a/vendor/blaze/src/canonicalizer/rules/divisible_by_implicit.h +++ b/vendor/blaze/src/canonicalizer/rules/divisible_by_implicit.h @@ -11,10 +11,12 @@ class DivisibleByImplicit final : public SchemaTransformRule { const sourcemeta::core::SchemaFrame::Location &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { - ONLY_CONTINUE_IF(vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3}) && - schema.is_object() && !schema.defines("divisibleBy")); + ONLY_CONTINUE_IF( + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && + schema.is_object() && !schema.defines("divisibleBy")); const auto *type{schema.try_at("type")}; ONLY_CONTINUE_IF(type && type->is_string() && diff --git a/vendor/blaze/src/canonicalizer/rules/draft3_type_any.h b/vendor/blaze/src/canonicalizer/rules/draft3_type_any.h index 581a555d..96fe8697 100644 --- a/vendor/blaze/src/canonicalizer/rules/draft3_type_any.h +++ b/vendor/blaze/src/canonicalizer/rules/draft3_type_any.h @@ -12,7 +12,9 @@ class Draft3TypeAny final : public SchemaTransformRule { const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( - vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3) && + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && schema.is_object()); const auto *type{schema.try_at("type")}; diff --git a/vendor/blaze/src/canonicalizer/rules/empty_dependencies_drop.h b/vendor/blaze/src/canonicalizer/rules/empty_dependencies_drop.h index 45a262ec..875b2208 100644 --- a/vendor/blaze/src/canonicalizer/rules/empty_dependencies_drop.h +++ b/vendor/blaze/src/canonicalizer/rules/empty_dependencies_drop.h @@ -13,6 +13,7 @@ class EmptyDependenciesDrop final : public SchemaTransformRule { const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF(vocabularies.contains_any( {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7}) && diff --git a/vendor/blaze/src/canonicalizer/rules/enum_drop_redundant_validation.h b/vendor/blaze/src/canonicalizer/rules/enum_drop_redundant_validation.h index 75615cec..14d721f7 100644 --- a/vendor/blaze/src/canonicalizer/rules/enum_drop_redundant_validation.h +++ b/vendor/blaze/src/canonicalizer/rules/enum_drop_redundant_validation.h @@ -23,11 +23,18 @@ class EnumDropRedundantValidation final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7, SchemaVocabularies::Known::JSON_SCHEMA_2019_09_VALIDATION, SchemaVocabularies::Known::JSON_SCHEMA_2020_12_VALIDATION}) && - schema.is_object() && !schema.defines("type")); + schema.is_object()); const auto *enum_value{schema.try_at("enum")}; ONLY_CONTINUE_IF(enum_value && enum_value->is_array()); + // `enum_with_type` takes away a `type` the options already imply, so one + // that is still here says something they do not, and belongs in a branch + // of its own rather than beside them + const auto *type{schema.try_at("type")}; + ONLY_CONTINUE_IF(type == nullptr || !type_is_implied_by_enum( + *type, *enum_value, vocabularies)); + this->keywords_.clear(); this->wrap_keywords_.clear(); this->is_pre_draft4_ = vocabularies.contains_any( diff --git a/vendor/blaze/src/canonicalizer/rules/enum_filter_by_type.h b/vendor/blaze/src/canonicalizer/rules/enum_filter_by_type.h index c5d34f0b..138178b0 100644 --- a/vendor/blaze/src/canonicalizer/rules/enum_filter_by_type.h +++ b/vendor/blaze/src/canonicalizer/rules/enum_filter_by_type.h @@ -17,6 +17,7 @@ class EnumFilterByType final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7, @@ -32,23 +33,12 @@ class EnumFilterByType final : public SchemaTransformRule { const auto declared_types{parse_schema_type(*type)}; ONLY_CONTINUE_IF(declared_types.any()); - const bool integer_matches_integral{ - vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7, - SchemaVocabularies::Known::JSON_SCHEMA_2019_09_VALIDATION, - SchemaVocabularies::Known::JSON_SCHEMA_2020_12_VALIDATION}) && - declared_types.test( - std::to_underlying(sourcemeta::core::JSON::Type::Integer))}; this->matching_indices_.clear(); bool has_mismatch{false}; std::size_t index{0}; for (const auto &value : enum_value->as_array()) { - const bool matches{ - declared_types.test(std::to_underlying(value.type())) || - (integer_matches_integral && value.is_integral())}; - if (matches) { + if (type_can_equal(declared_types, value)) { this->matching_indices_.push_back(index); } else { has_mismatch = true; diff --git a/vendor/blaze/src/canonicalizer/rules/enum_split_by_type.h b/vendor/blaze/src/canonicalizer/rules/enum_split_by_type.h index 1d32f627..2570419a 100644 --- a/vendor/blaze/src/canonicalizer/rules/enum_split_by_type.h +++ b/vendor/blaze/src/canonicalizer/rules/enum_split_by_type.h @@ -28,7 +28,8 @@ class EnumSplitByType final : public SchemaTransformRule { {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3})}; + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER})}; ONLY_CONTINUE_IF((any_of_dialect || type_union_dialect) && schema.is_object()); diff --git a/vendor/blaze/src/canonicalizer/rules/enum_with_type.h b/vendor/blaze/src/canonicalizer/rules/enum_with_type.h index 60ba163e..bf1db260 100644 --- a/vendor/blaze/src/canonicalizer/rules/enum_with_type.h +++ b/vendor/blaze/src/canonicalizer/rules/enum_with_type.h @@ -27,54 +27,7 @@ class EnumWithType final : public SchemaTransformRule { const auto *enum_value{schema.try_at("enum")}; ONLY_CONTINUE_IF(enum_value && enum_value->is_array()); - if (vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER})) { - if (type->is_string() && type->to_string() == "any") { - return true; - } - - if (type->is_array()) { - bool has_tautology{false}; - bool has_unknown_subschema{false}; - for (const auto &entry : type->as_array()) { - if (entry.is_string() && entry.to_string() == "any") { - has_tautology = true; - break; - } - if (entry.is_object()) { - if (entry.empty()) { - has_tautology = true; - break; - } - has_unknown_subschema = true; - } - } - - if (has_tautology) { - return true; - } - - if (has_unknown_subschema) { - return false; - } - } - } - - const auto current_types{parse_schema_type(*type)}; - ONLY_CONTINUE_IF(current_types.any()); - const bool integer_matches_integral{ - vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7}) && - current_types.test( - std::to_underlying(sourcemeta::core::JSON::Type::Integer))}; - ONLY_CONTINUE_IF(std::ranges::all_of( - enum_value->as_array(), - [¤t_types, integer_matches_integral](const auto &item) -> auto { - return current_types.test(std::to_underlying(item.type())) || - (integer_matches_integral && item.is_integral()); - })); + ONLY_CONTINUE_IF(type_is_implied_by_enum(*type, *enum_value, vocabularies)); return true; } diff --git a/vendor/blaze/src/canonicalizer/rules/equal_numeric_bounds_to_enum.h b/vendor/blaze/src/canonicalizer/rules/equal_numeric_bounds_to_enum.h index b377741a..78d5a161 100644 --- a/vendor/blaze/src/canonicalizer/rules/equal_numeric_bounds_to_enum.h +++ b/vendor/blaze/src/canonicalizer/rules/equal_numeric_bounds_to_enum.h @@ -21,10 +21,17 @@ class EqualNumericBoundsToEnum final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0}) && schema.is_object()); + // An integer only where the dialect reads one as any number whose + // fractional part is zero. Draft 3 and Draft 4 read it as a number written + // without a fractional part, so there `type: "integer"` rejects `3.0` + // where `enum: [ 3 ]` accepts it, the two being equal as numbers. Keeping + // the type beside the enum is not an option either, as the canonical form + // has no entry that carries both const auto *type{schema.try_at("type")}; - ONLY_CONTINUE_IF( - type && type->is_string() && - (type->to_string() == "integer" || type->to_string() == "number")); + ONLY_CONTINUE_IF(type && type->is_string() && + (type->to_string() == "number" || + (type->to_string() == "integer" && + integral_reals_are_integers(vocabularies)))); const auto *minimum{schema.try_at("minimum")}; ONLY_CONTINUE_IF(minimum && minimum->is_number()); const auto *maximum{schema.try_at("maximum")}; diff --git a/vendor/blaze/src/canonicalizer/rules/exclusive_maximum_boolean_integer_fold.h b/vendor/blaze/src/canonicalizer/rules/exclusive_maximum_boolean_integer_fold.h index b19f814d..869cd0ef 100644 --- a/vendor/blaze/src/canonicalizer/rules/exclusive_maximum_boolean_integer_fold.h +++ b/vendor/blaze/src/canonicalizer/rules/exclusive_maximum_boolean_integer_fold.h @@ -14,6 +14,7 @@ class ExclusiveMaximumBooleanIntegerFold final : public SchemaTransformRule { const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF(vocabularies.contains_any( {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4}) && schema.is_object()); diff --git a/vendor/blaze/src/canonicalizer/rules/exclusive_minimum_boolean_integer_fold.h b/vendor/blaze/src/canonicalizer/rules/exclusive_minimum_boolean_integer_fold.h index f66428a4..82db3e15 100644 --- a/vendor/blaze/src/canonicalizer/rules/exclusive_minimum_boolean_integer_fold.h +++ b/vendor/blaze/src/canonicalizer/rules/exclusive_minimum_boolean_integer_fold.h @@ -14,6 +14,7 @@ class ExclusiveMinimumBooleanIntegerFold final : public SchemaTransformRule { const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF(vocabularies.contains_any( {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4}) && schema.is_object()); diff --git a/vendor/blaze/src/canonicalizer/rules/extends_to_array.h b/vendor/blaze/src/canonicalizer/rules/extends_to_array.h index c0813696..949604c0 100644 --- a/vendor/blaze/src/canonicalizer/rules/extends_to_array.h +++ b/vendor/blaze/src/canonicalizer/rules/extends_to_array.h @@ -11,12 +11,14 @@ class ExtendsToArray final : public SchemaTransformRule { const sourcemeta::core::SchemaFrame::Location &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { - ONLY_CONTINUE_IF(vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3}) && - schema.is_object()); + ONLY_CONTINUE_IF( + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && + schema.is_object()); const auto *extends{schema.try_at("extends")}; ONLY_CONTINUE_IF(extends && !extends->is_array()); diff --git a/vendor/blaze/src/canonicalizer/rules/hyper_schema_keywords_drop.h b/vendor/blaze/src/canonicalizer/rules/hyper_schema_keywords_drop.h new file mode 100644 index 00000000..ef148659 --- /dev/null +++ b/vendor/blaze/src/canonicalizer/rules/hyper_schema_keywords_drop.h @@ -0,0 +1,59 @@ +class HyperSchemaKeywordsDrop final : public SchemaTransformRule { +public: + using reframe_after_transform = std::true_type; + HyperSchemaKeywordsDrop() + : SchemaTransformRule{"hyper_schema_keywords_drop"} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &vocabularies, + const sourcemeta::core::SchemaFrame &, + const sourcemeta::core::SchemaFrame::Location &, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> bool override { + // The canonical form says what makes an instance valid, and none of these + // takes part in that: `links` describes link relations, `readonly` and + // `mediaType` describe the instance to a reader, and the rest describe how + // a document is addressed. Two documents that differ in none but these + // accept the same instances, so the canonical form is the one without + // them. + // + // These are the keywords the Draft 3 hyper-schema meta-schema adds to the + // Draft 3 one, named here rather than asked of the walker: a document on a + // pre-vocabulary dialect puts every keyword it knows in the one vocabulary + // that dialect stands for, `$schema` and `properties` along with `links` + // + // Only Draft 3 is taken this far. The canonical form of every other + // dialect still describes a nested document, which leaves these keywords + // somewhere to sit, so dropping them there would be a change of its own + static const std::set HYPER_SCHEMA_KEYWORDS{ + "contentEncoding", "fragmentResolution", "links", "mediaType", + "pathStart", "readonly", "root"}; + + ONLY_CONTINUE_IF( + vocabularies.contains( + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER) && + schema.is_object()); + + std::vector keywords; + for (const auto &entry : schema.as_object()) { + if (HYPER_SCHEMA_KEYWORDS.contains(entry.first)) { + keywords.push_back(entry.first); + } + } + + ONLY_CONTINUE_IF(!keywords.empty()); + this->keywords_ = std::move(keywords); + return true; + } + + auto transform(sourcemeta::core::JSON &schema) const -> void override { + for (const auto &keyword : this->keywords_) { + schema.erase(keyword); + } + } + +private: + mutable std::vector keywords_; +}; diff --git a/vendor/blaze/src/canonicalizer/rules/implicit_contains_keywords.h b/vendor/blaze/src/canonicalizer/rules/implicit_contains_keywords.h index 8a7079e3..263919fe 100644 --- a/vendor/blaze/src/canonicalizer/rules/implicit_contains_keywords.h +++ b/vendor/blaze/src/canonicalizer/rules/implicit_contains_keywords.h @@ -25,17 +25,15 @@ class ImplicitContainsKeywords final : public SchemaTransformRule { } else { ONLY_CONTINUE_IF(!schema.defines("minContains") && !schema.defines("maxContains")); - ONLY_CONTINUE_IF( - !walk_up_in_place_applicators( - root, frame, location, walker, resolver, - [](const sourcemeta::core::JSON &ancestor, - const SchemaVocabularies &ancestor_vocabularies) -> bool { - return ancestor.defines("unevaluatedItems") && - ancestor_vocabularies.contains( - SchemaVocabularies::Known:: - JSON_SCHEMA_2020_12_UNEVALUATED); - }) - .has_value()); + ONLY_CONTINUE_IF(!walk_up_in_place_applicators_across_references( + root, frame, location, walker, resolver, + [](const sourcemeta::core::JSON &ancestor, + const SchemaVocabularies &ancestor_vocabularies) -> bool { + return ancestor.defines("unevaluatedItems") && + ancestor_vocabularies.contains( + SchemaVocabularies::Known:: + JSON_SCHEMA_2020_12_UNEVALUATED); + })); } return true; diff --git a/vendor/blaze/src/canonicalizer/rules/implicit_object_keywords.h b/vendor/blaze/src/canonicalizer/rules/implicit_object_keywords.h index b34cdce1..4119f364 100644 --- a/vendor/blaze/src/canonicalizer/rules/implicit_object_keywords.h +++ b/vendor/blaze/src/canonicalizer/rules/implicit_object_keywords.h @@ -99,6 +99,7 @@ class ImplicitObjectKeywords final : public SchemaTransformRule { !schema.defines("patternProperties") && vocabularies.contains_any( {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7, @@ -137,6 +138,7 @@ class ImplicitObjectKeywords final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0})); @@ -146,6 +148,7 @@ class ImplicitObjectKeywords final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7})}; @@ -156,7 +159,8 @@ class ImplicitObjectKeywords final : public SchemaTransformRule { {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3}); + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}); this->has_work_ = this->add_pattern_properties_ || this->add_property_names_ || this->add_min_properties_ || @@ -172,6 +176,7 @@ class ImplicitObjectKeywords final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7, @@ -191,6 +196,7 @@ class ImplicitObjectKeywords final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4})}; this->add_unique_items_ = diff --git a/vendor/blaze/src/canonicalizer/rules/items_implicit.h b/vendor/blaze/src/canonicalizer/rules/items_implicit.h index bc061cad..e582d772 100644 --- a/vendor/blaze/src/canonicalizer/rules/items_implicit.h +++ b/vendor/blaze/src/canonicalizer/rules/items_implicit.h @@ -31,19 +31,17 @@ class ItemsImplicit final : public SchemaTransformRule { vocabularies.contains_any( {SchemaVocabularies::Known::JSON_SCHEMA_2020_12_UNEVALUATED, SchemaVocabularies::Known::JSON_SCHEMA_2019_09_APPLICATOR}))); - ONLY_CONTINUE_IF( - !walk_up_in_place_applicators( - root, frame, location, walker, resolver, - [](const sourcemeta::core::JSON &ancestor, - const SchemaVocabularies &ancestor_vocabularies) -> bool { - return ancestor.defines("unevaluatedItems") && - ancestor_vocabularies.contains_any( - {SchemaVocabularies::Known:: - JSON_SCHEMA_2020_12_UNEVALUATED, - SchemaVocabularies::Known:: - JSON_SCHEMA_2019_09_APPLICATOR}); - }) - .has_value()); + ONLY_CONTINUE_IF(!walk_up_in_place_applicators_across_references( + root, frame, location, walker, resolver, + [](const sourcemeta::core::JSON &ancestor, + const SchemaVocabularies &ancestor_vocabularies) -> bool { + return ancestor.defines("unevaluatedItems") && + ancestor_vocabularies.contains_any( + {SchemaVocabularies::Known:: + JSON_SCHEMA_2020_12_UNEVALUATED, + SchemaVocabularies::Known:: + JSON_SCHEMA_2019_09_APPLICATOR}); + })); return true; } diff --git a/vendor/blaze/src/canonicalizer/rules/metadata_keywords_drop.h b/vendor/blaze/src/canonicalizer/rules/metadata_keywords_drop.h new file mode 100644 index 00000000..95d0d6d9 --- /dev/null +++ b/vendor/blaze/src/canonicalizer/rules/metadata_keywords_drop.h @@ -0,0 +1,57 @@ +class MetadataKeywordsDrop final : public SchemaTransformRule { +public: + using reframe_after_transform = std::true_type; + MetadataKeywordsDrop() : SchemaTransformRule{"metadata_keywords_drop"} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &vocabularies, + const sourcemeta::core::SchemaFrame &, + const sourcemeta::core::SchemaFrame::Location &, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> bool override { + // The canonical form says what makes an instance valid, and none of these + // takes part in that: they name a schema, describe it, and say what to + // put where an instance says nothing. Two documents that differ in none + // but these accept the same instances, so the canonical form is the one + // without them. + // + // These are the metadata keywords of the Draft 3 meta-schema, named here + // rather than asked of the walker: a document on a pre-vocabulary dialect + // puts every keyword it knows in the one vocabulary that dialect stands + // for, `title` and `type` alike + // + // Only Draft 3 is taken this far. The canonical form of every other + // dialect still describes a nested document, which leaves these keywords + // somewhere to sit, so dropping them there would be a change of its own + static const std::set METADATA_KEYWORDS{ + "default", "description", "title"}; + + ONLY_CONTINUE_IF( + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && + schema.is_object()); + + std::vector keywords; + for (const auto &entry : schema.as_object()) { + if (METADATA_KEYWORDS.contains(entry.first)) { + keywords.push_back(entry.first); + } + } + + ONLY_CONTINUE_IF(!keywords.empty()); + this->keywords_ = std::move(keywords); + return true; + } + + auto transform(sourcemeta::core::JSON &schema) const -> void override { + for (const auto &keyword : this->keywords_) { + schema.erase(keyword); + } + } + +private: + mutable std::vector keywords_; +}; diff --git a/vendor/blaze/src/canonicalizer/rules/min_length_implicit.h b/vendor/blaze/src/canonicalizer/rules/min_length_implicit.h index 597962de..03d24591 100644 --- a/vendor/blaze/src/canonicalizer/rules/min_length_implicit.h +++ b/vendor/blaze/src/canonicalizer/rules/min_length_implicit.h @@ -19,6 +19,7 @@ class MinLengthImplicit final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0}) && diff --git a/vendor/blaze/src/canonicalizer/rules/required_property_implicit.h b/vendor/blaze/src/canonicalizer/rules/required_property_implicit.h index 41110f91..8a303c63 100644 --- a/vendor/blaze/src/canonicalizer/rules/required_property_implicit.h +++ b/vendor/blaze/src/canonicalizer/rules/required_property_implicit.h @@ -13,7 +13,9 @@ class RequiredPropertyImplicit final : public SchemaTransformRule { const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( - vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3) && + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && schema.is_object()); const auto *type{schema.try_at("type")}; diff --git a/vendor/blaze/src/canonicalizer/rules/type_boolean_as_enum.h b/vendor/blaze/src/canonicalizer/rules/type_boolean_as_enum.h index 2c2624c7..ed383590 100644 --- a/vendor/blaze/src/canonicalizer/rules/type_boolean_as_enum.h +++ b/vendor/blaze/src/canonicalizer/rules/type_boolean_as_enum.h @@ -19,6 +19,7 @@ class TypeBooleanAsEnum final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0}) && diff --git a/vendor/blaze/src/canonicalizer/rules/type_inherit_in_place.h b/vendor/blaze/src/canonicalizer/rules/type_inherit_in_place.h index cfc60e18..b3d8ee64 100644 --- a/vendor/blaze/src/canonicalizer/rules/type_inherit_in_place.h +++ b/vendor/blaze/src/canonicalizer/rules/type_inherit_in_place.h @@ -18,6 +18,7 @@ class TypeInheritInPlace final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0})); diff --git a/vendor/blaze/src/canonicalizer/rules/type_null_as_enum.h b/vendor/blaze/src/canonicalizer/rules/type_null_as_enum.h index 3b2aa68b..74249b71 100644 --- a/vendor/blaze/src/canonicalizer/rules/type_null_as_enum.h +++ b/vendor/blaze/src/canonicalizer/rules/type_null_as_enum.h @@ -19,6 +19,7 @@ class TypeNullAsEnum final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0}) && diff --git a/vendor/blaze/src/canonicalizer/rules/type_union_implicit.h b/vendor/blaze/src/canonicalizer/rules/type_union_implicit.h index 2a67e086..49a38aa3 100644 --- a/vendor/blaze/src/canonicalizer/rules/type_union_implicit.h +++ b/vendor/blaze/src/canonicalizer/rules/type_union_implicit.h @@ -11,12 +11,14 @@ class TypeUnionImplicit final : public SchemaTransformRule { const sourcemeta::core::SchemaWalker &walker, const sourcemeta::core::SchemaResolver &resolver) const -> bool override { ONLY_CONTINUE_IF(schema.is_object() && !schema.empty()); - ONLY_CONTINUE_IF(!vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3}) || - !schema.defines("disallow")); + ONLY_CONTINUE_IF( + !vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) || + !schema.defines("disallow")); ONLY_CONTINUE_IF(vocabularies.contains_any( {SchemaVocabularies::Known::JSON_SCHEMA_2020_12_VALIDATION, SchemaVocabularies::Known::JSON_SCHEMA_2019_09_VALIDATION, @@ -24,6 +26,7 @@ class TypeUnionImplicit final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0})); diff --git a/vendor/blaze/src/canonicalizer/rules/type_union_to_schemas.h b/vendor/blaze/src/canonicalizer/rules/type_union_to_schemas.h index eb5af68e..2b99e747 100644 --- a/vendor/blaze/src/canonicalizer/rules/type_union_to_schemas.h +++ b/vendor/blaze/src/canonicalizer/rules/type_union_to_schemas.h @@ -11,12 +11,14 @@ class TypeUnionToSchemas final : public SchemaTransformRule { const sourcemeta::core::SchemaFrame::Location &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { - ONLY_CONTINUE_IF(vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3}) && - schema.is_object()); + ONLY_CONTINUE_IF( + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && + schema.is_object()); const auto *type{schema.try_at("type")}; ONLY_CONTINUE_IF(type && type->is_array()); diff --git a/vendor/blaze/src/canonicalizer/rules/type_with_applicator_to_allof.h b/vendor/blaze/src/canonicalizer/rules/type_with_applicator_to_allof.h index 5a2c2c79..009a8ace 100644 --- a/vendor/blaze/src/canonicalizer/rules/type_with_applicator_to_allof.h +++ b/vendor/blaze/src/canonicalizer/rules/type_with_applicator_to_allof.h @@ -154,6 +154,22 @@ class TypeWithApplicatorToAllOf final : public SchemaTransformRule { return true; } + // Merging only collapses the sibling applicators into the `allOf` that + // is already there, and the condition has to come out false as soon as + // the transform returns. So merging is the right move only when those + // siblings are the whole reason we are here: typed keywords with no + // `type` to sit under, and references that need a branch of their own, + // all survive a merge and have to go through the extraction path instead + const bool mergeable_siblings{std::ranges::any_of( + APPLICATORS_WITHOUT_ALLOF, [&schema](const auto *applicator) -> bool { + return schema.defines(applicator); + })}; + if (!mergeable_siblings || has_orphaned_typed_keywords || + modern_ref_needs_wrapping || this->has_modern_ref_ || + this->has_dynamic_ref_ || this->has_recursive_ref_) { + return true; + } + const auto all_refs_fixed{!frame.any_reference_from( location.pointer, [&](const sourcemeta::core::SchemaReferenceType, diff --git a/vendor/blaze/src/canonicalizer/rules/type_with_applicator_to_extends.h b/vendor/blaze/src/canonicalizer/rules/type_with_applicator_to_extends.h index b5b8b2f0..90027144 100644 --- a/vendor/blaze/src/canonicalizer/rules/type_with_applicator_to_extends.h +++ b/vendor/blaze/src/canonicalizer/rules/type_with_applicator_to_extends.h @@ -12,12 +12,14 @@ class TypeWithApplicatorToExtends final : public SchemaTransformRule { const sourcemeta::core::SchemaFrame::Location &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { - ONLY_CONTINUE_IF(vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3}) && - schema.is_object()); + ONLY_CONTINUE_IF( + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_0, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && + schema.is_object()); const auto *extends_value{schema.try_at("extends")}; const bool has_extends{(extends_value != nullptr) && diff --git a/vendor/blaze/src/canonicalizer/rules/unknown_keywords_drop.h b/vendor/blaze/src/canonicalizer/rules/unknown_keywords_drop.h new file mode 100644 index 00000000..cba90e8c --- /dev/null +++ b/vendor/blaze/src/canonicalizer/rules/unknown_keywords_drop.h @@ -0,0 +1,50 @@ +class UnknownKeywordsDrop final : public SchemaTransformRule { +public: + using reframe_after_transform = std::true_type; + UnknownKeywordsDrop() : SchemaTransformRule{"unknown_keywords_drop"} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &vocabularies, + const sourcemeta::core::SchemaFrame &, + const sourcemeta::core::SchemaFrame::Location &, + const sourcemeta::core::SchemaWalker &walker, + const sourcemeta::core::SchemaResolver &) const -> bool override { + // A keyword the dialect does not define says nothing about whether an + // instance is valid, so the canonical form is the one without it. Every + // other dialect keeps such a keyword under an `x-` prefix instead, which + // `unknown_keywords_prefix` does, as their canonical forms describe a + // nested document that has somewhere to put it + ONLY_CONTINUE_IF( + vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && + schema.is_object()); + + std::vector keywords; + for (const auto &entry : schema.as_object()) { + // If there is any i.e. optional vocabulary we don't recognise, then this + // seemingly unknown keyword might belong to one of those, and thus it + // might not be safe to flag it + if (walker(entry.first, vocabularies).type == + SchemaKeywordType::Unknown && + !vocabularies.has_unknown()) { + keywords.push_back(entry.first); + } + } + + ONLY_CONTINUE_IF(!keywords.empty()); + this->keywords_ = std::move(keywords); + return true; + } + + auto transform(sourcemeta::core::JSON &schema) const -> void override { + for (const auto &keyword : this->keywords_) { + schema.erase(keyword); + } + } + +private: + mutable std::vector keywords_; +}; diff --git a/vendor/blaze/src/canonicalizer/rules/unknown_keywords_prefix.h b/vendor/blaze/src/canonicalizer/rules/unknown_keywords_prefix.h index f50bcc90..b737b4bd 100644 --- a/vendor/blaze/src/canonicalizer/rules/unknown_keywords_prefix.h +++ b/vendor/blaze/src/canonicalizer/rules/unknown_keywords_prefix.h @@ -10,7 +10,14 @@ class UnknownKeywordsPrefix final : public SchemaTransformRule { const SchemaFrame::Location &, const SchemaWalker &walker, const SchemaResolver &) const -> bool override { - ONLY_CONTINUE_IF(schema.is_object()); + // Draft 3 deletes an unknown keyword outright instead, which + // `unknown_keywords_drop` does, as its canonical form has nowhere to put + // one + ONLY_CONTINUE_IF( + !vocabularies.contains_any( + {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER}) && + schema.is_object()); std::vector locations; for (const auto &entry : schema.as_object()) { if (entry.first.starts_with("x-")) { diff --git a/vendor/blaze/src/canonicalizer/rules/unsatisfiable_empty_enum.h b/vendor/blaze/src/canonicalizer/rules/unsatisfiable_empty_enum.h index e0089bec..7b2a716b 100644 --- a/vendor/blaze/src/canonicalizer/rules/unsatisfiable_empty_enum.h +++ b/vendor/blaze/src/canonicalizer/rules/unsatisfiable_empty_enum.h @@ -17,6 +17,7 @@ class UnsatisfiableEmptyEnum final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7, diff --git a/vendor/blaze/src/canonicalizer/rules/unsatisfiable_exclusive_equal_bounds.h b/vendor/blaze/src/canonicalizer/rules/unsatisfiable_exclusive_equal_bounds.h index f459b2b6..515aeed4 100644 --- a/vendor/blaze/src/canonicalizer/rules/unsatisfiable_exclusive_equal_bounds.h +++ b/vendor/blaze/src/canonicalizer/rules/unsatisfiable_exclusive_equal_bounds.h @@ -14,6 +14,7 @@ class UnsatisfiableExclusiveEqualBounds final : public SchemaTransformRule { const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF(vocabularies.contains_any( {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4}) && schema.is_object()); diff --git a/vendor/blaze/src/canonicalizer/rules/unsatisfiable_type_and_enum.h b/vendor/blaze/src/canonicalizer/rules/unsatisfiable_type_and_enum.h index 916ef0f5..e3d78867 100644 --- a/vendor/blaze/src/canonicalizer/rules/unsatisfiable_type_and_enum.h +++ b/vendor/blaze/src/canonicalizer/rules/unsatisfiable_type_and_enum.h @@ -18,6 +18,7 @@ class UnsatisfiableTypeAndEnum final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_1, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_2, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3, + SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3_HYPER, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7, @@ -33,19 +34,9 @@ class UnsatisfiableTypeAndEnum final : public SchemaTransformRule { const auto declared_types{parse_schema_type(*type)}; ONLY_CONTINUE_IF(declared_types.any()); - const bool integer_matches_integral{ - vocabularies.contains_any( - {SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, - SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7, - SchemaVocabularies::Known::JSON_SCHEMA_2019_09_VALIDATION, - SchemaVocabularies::Known::JSON_SCHEMA_2020_12_VALIDATION}) && - declared_types.test( - std::to_underlying(sourcemeta::core::JSON::Type::Integer))}; ONLY_CONTINUE_IF(std::ranges::none_of( - enum_value->as_array(), - [&declared_types, integer_matches_integral](const auto &value) -> auto { - return declared_types.test(std::to_underlying(value.type())) || - (integer_matches_integral && value.is_integral()); + enum_value->as_array(), [&declared_types](const auto &value) -> auto { + return type_can_equal(declared_types, value); })); this->unsatisfiable_ = unsatisfiable_schema(vocabularies); return true; diff --git a/vendor/blaze/src/canonicalizer/schema_helpers.h b/vendor/blaze/src/canonicalizer/schema_helpers.h index e9412516..dab11fa4 100644 --- a/vendor/blaze/src/canonicalizer/schema_helpers.h +++ b/vendor/blaze/src/canonicalizer/schema_helpers.h @@ -30,6 +30,7 @@ inline auto parse_schema_type_string(const sourcemeta::core::JSON::String &type, } else if (type == "number") { result.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); result.set(std::to_underlying(sourcemeta::core::JSON::Type::Real)); + result.set(std::to_underlying(sourcemeta::core::JSON::Type::Decimal)); } else if (type == "integer") { result.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); } else if (type == "string") { diff --git a/vendor/blaze/src/compiler/CMakeLists.txt b/vendor/blaze/src/compiler/CMakeLists.txt index 987106ef..3167c2ac 100644 --- a/vendor/blaze/src/compiler/CMakeLists.txt +++ b/vendor/blaze/src/compiler/CMakeLists.txt @@ -1,10 +1,11 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT blaze NAME compiler FOLDER "Blaze/Compiler" - PRIVATE_HEADERS error.h unevaluated.h + PRIVATE_HEADERS error.h SOURCES compile.cc compile_json.cc compile_helpers.h keyword_iterator.h postprocess.h - default_compiler.cc unevaluated.cc + unevaluated.h + default_compiler.cc default_compiler_2020_12.h default_compiler_2019_09.h default_compiler_draft7.h diff --git a/vendor/blaze/src/compiler/compile.cc b/vendor/blaze/src/compiler/compile.cc index fabb4bf7..ca9aa0de 100644 --- a/vendor/blaze/src/compiler/compile.cc +++ b/vendor/blaze/src/compiler/compile.cc @@ -18,6 +18,7 @@ #include "compile_helpers.h" #include "keyword_iterator.h" #include "postprocess.h" +#include "unevaluated.h" namespace { @@ -31,17 +32,27 @@ auto is_metaschema_reference(const sourcemeta::core::WeakPointer &origin) // Draft 6 introduced boolean schemas. Draft 4 and earlier have none, and the // only places they accept a boolean are `additionalProperties` and -// `additionalItems`, whose own definitions spell that out +// `additionalItems`, whose own definitions spell that out. The OpenAPI 3.0 +// Schema Object has none either, and offers a boolean at +// `additionalProperties` alone auto booleans_are_schemas( const sourcemeta::core::SchemaVocabularies &vocabularies) -> bool { using Known = sourcemeta::core::SchemaVocabularies::Known; return !vocabularies.contains_any( {Known::JSON_SCHEMA_DRAFT_3, Known::JSON_SCHEMA_DRAFT_3_HYPER, - Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER}); + Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER, + Known::OPENAPI_3_0_BASE}); } +// Draft 7 and earlier let `$ref` override whatever sits beside it, so such a +// sibling is not a keyword of the schema at all and nothing about it, not even +// its shape, is this compiler's business // Draft 4 and earlier spell these as flags on a sibling bound rather than as -// bounds of their own, and their meta-schemas ask for that sibling to be there +// bounds of their own, and their meta-schemas ask for that sibling to be +// there. The OpenAPI 3.0 Schema Object is deliberately absent: it spells them +// as flags the way Draft 4 does, but its meta-schema declares no dependency +// between a flag and the bound it modifies, so one standing on its own is +// well-formed there and merely has nothing to modify auto exclusive_bounds_need_a_sibling( const sourcemeta::core::SchemaVocabularies &vocabularies) -> bool { using Known = sourcemeta::core::SchemaVocabularies::Known; @@ -57,7 +68,17 @@ auto is_schema(const sourcemeta::core::JSON &value, const bool allow_boolean) // The reason the keyword's value is not the shape the meta-schema asks for, or // `nullptr` when it is. The location the error carries names the keyword, so -// these read as statements about it +// these read as statements about it. +// +// What a dialect's meta-schema states is what settles this, rather than what +// its prose asks for, so that a schema the meta-schema accepts is one that +// compiles. A dialect whose prose carries a rule its meta-schema leaves +// unencoded therefore goes unenforced here: OpenAPI 3.0 asks that `items` be +// present whenever `type` is `"array"`, and that a Discriminator Object stand +// beside a composite keyword, while its meta-schema expresses neither, and it +// omits the lower bound that would keep `allOf`, `anyOf` and `oneOf` from +// standing empty. Catching those belongs to a linter, which is free to be +// stricter than the document the dialect publishes auto keyword_shape_error( const sourcemeta::core::JSON::String &keyword, const sourcemeta::core::SchemaKeywordType type, const bool known, @@ -72,6 +93,11 @@ auto keyword_shape_error( "This keyword was expected to be set to a boolean"}; static constexpr auto EXPECTED_NUMBER{ "This keyword was expected to be set to a number"}; + static constexpr auto EXPECTED_OBJECT{ + "This keyword was expected to be set to an object"}; + static constexpr auto EXPECTED_DIALECT_OBJECT{ + "This keyword was expected to be set to an object of the shape this " + "dialect defines"}; static constexpr auto EXPECTED_NON_NEGATIVE_INTEGER{ "This keyword was expected to be set to a non-negative integer"}; static constexpr auto EXPECTED_SCHEMA{ @@ -96,12 +122,86 @@ auto keyword_shape_error( return value.is_string() ? nullptr : EXPECTED_STRING; } if (keyword == "uniqueItems" || keyword == "deprecated" || - keyword == "readOnly" || keyword == "writeOnly") { + keyword == "readOnly" || keyword == "writeOnly" || + keyword == "nullable") { return value.is_boolean() ? nullptr : EXPECTED_BOOLEAN; } if (keyword == "examples") { return value.is_array() ? nullptr : EXPECTED_ARRAY; } + // The OpenAPI dialects define these three as objects of their own. What + // follows is what their meta-schemas state, other than the `format` + // annotations two of their members carry, which assert nothing by default + if (keyword == "discriminator" || keyword == "xml" || + keyword == "externalDocs") { + if (!value.is_object()) { + return EXPECTED_OBJECT; + } + + // Each OpenAPI version defines these objects for itself, and the later + // ones carry members this does not know about, so only the dialect this + // was written against is held to the shape below + if (!vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known::OPENAPI_3_0_BASE)) { + return nullptr; + } + + if (keyword == "discriminator") { + const auto *property_name{value.try_at("propertyName")}; + if (property_name == nullptr || !property_name->is_string()) { + return EXPECTED_DIALECT_OBJECT; + } + + const auto *mapping{value.try_at("mapping")}; + if (mapping != nullptr && + (!mapping->is_object() || + std::ranges::any_of(mapping->as_object(), + [](const auto &entry) -> bool { + return !entry.second.is_string(); + }))) { + return EXPECTED_DIALECT_OBJECT; + } + + // Unlike the other two, this one its meta-schema leaves open + return nullptr; + } + + if (keyword == "externalDocs") { + const auto *url{value.try_at("url")}; + if (url == nullptr || !url->is_string()) { + return EXPECTED_DIALECT_OBJECT; + } + } + + for (const auto &entry : value.as_object()) { + if (entry.first.starts_with("x-")) { + continue; + } + + const auto expected_string{ + keyword == "externalDocs" + ? (entry.first == "description" || entry.first == "url") + : (entry.first == "name" || entry.first == "namespace" || + entry.first == "prefix")}; + const auto expected_boolean{ + keyword == "xml" && + (entry.first == "attribute" || entry.first == "wrapped")}; + if (expected_string) { + if (!entry.second.is_string()) { + return EXPECTED_DIALECT_OBJECT; + } + } else if (expected_boolean) { + if (!entry.second.is_boolean()) { + return EXPECTED_DIALECT_OBJECT; + } + } else { + // Both of their meta-schemas close them to the members they name + return EXPECTED_DIALECT_OBJECT; + } + } + + return nullptr; + } if (keyword == "maxContains" || keyword == "minContains") { // These only exist from 2019-09 onwards, where a number whose fractional // part is zero counts as an integer @@ -117,7 +217,8 @@ auto keyword_shape_error( JSON_SCHEMA_DRAFT_3_HYPER, sourcemeta::core::SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4, sourcemeta::core::SchemaVocabularies::Known:: - JSON_SCHEMA_DRAFT_4_HYPER}) + JSON_SCHEMA_DRAFT_4_HYPER, + sourcemeta::core::SchemaVocabularies::Known::OPENAPI_3_0_BASE}) ? (value.is_boolean() ? nullptr : EXPECTED_BOOLEAN) : (value.is_number() ? nullptr : EXPECTED_NUMBER)); } @@ -292,11 +393,16 @@ auto compile_subschema(const sourcemeta::blaze::Context &context, const auto *shape_error{keyword_shape_error( keyword, metadata.type, official, schema_context.schema.at(keyword), allow_boolean, schema_context.vocabularies)}; + static const sourcemeta::core::JSON::String KEYWORD_REF{"$ref"}; + const auto overridden_by_ref{ + keyword != KEYWORD_REF && schema_context.schema.defines(KEYWORD_REF) && + ref_object_ignores_siblings(schema_context.vocabularies)}; + // Draft 3 spells these as flags on a sibling bound rather than as bounds of // their own, and its meta-schema asks for that sibling to be there static const sourcemeta::core::JSON::String KEYWORD_MINIMUM{"minimum"}; static const sourcemeta::core::JSON::String KEYWORD_MAXIMUM{"maximum"}; - if (official && + if (!overridden_by_ref && official && exclusive_bounds_need_a_sibling(schema_context.vocabularies) && ((keyword == "exclusiveMinimum" && !schema_context.schema.defines(KEYWORD_MINIMUM)) || @@ -311,7 +417,7 @@ auto compile_subschema(const sourcemeta::blaze::Context &context, "This keyword was expected to accompany the bound it applies to"); } - if (shape_error != nullptr) [[unlikely]] { + if (!overridden_by_ref && shape_error != nullptr) [[unlikely]] { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location( diff --git a/vendor/blaze/src/compiler/compile_helpers.h b/vendor/blaze/src/compiler/compile_helpers.h index dcc61505..ff9eb4ed 100644 --- a/vendor/blaze/src/compiler/compile_helpers.h +++ b/vendor/blaze/src/compiler/compile_helpers.h @@ -9,6 +9,7 @@ #include // assert #include // std::cref #include // std::distance +#include // std::numeric_limits #include // std::optional #include // std::regex, std::regex_match, std::smatch #include // std::out_of_range @@ -254,6 +255,28 @@ inline auto is_numeric_integer_type_check(const Instruction &instruction) sourcemeta::core::JSON::Type::Integer; } +// A multiple of one admits exactly the integers, so it bounds a number the way +// an integer type check does, without saying anything about the type +inline auto is_numeric_integral_multiple_check(const Instruction &instruction) + -> bool { + if (instruction.type != InstructionIndex::AssertionDivisible) { + return false; + } + + const auto &divisor{std::get(instruction.value)}; + if (!divisor.is_integral()) { + return false; + } + + // A real or decimal may spell an integer too large to represent, and such a + // divisor is never one, so a failed conversion just means no fusion + try { + return divisor.as_integer() == 1; + } catch (const std::out_of_range &) { + return false; + } +} + inline auto is_numeric_bound_check(const Instruction &instruction) -> bool { return instruction.type == InstructionIndex::AssertionGreaterEqual || instruction.type == InstructionIndex::AssertionGreater || @@ -277,12 +300,22 @@ inline auto merge_integer_bound(const Instruction &instruction, if (instruction.type == InstructionIndex::AssertionGreaterEqual) { minimum = minimum.has_value() ? std::max(minimum.value(), value) : value; } else if (instruction.type == InstructionIndex::AssertionGreater) { + // Tightening the bound past the representable range would wrap around, so + // leave such a bound to the standalone instruction that can still hold it + if (value == std::numeric_limits::max()) { + return false; + } + const auto adjusted{value + 1}; minimum = minimum.has_value() ? std::max(minimum.value(), adjusted) : adjusted; } else if (instruction.type == InstructionIndex::AssertionLessEqual) { maximum = maximum.has_value() ? std::min(maximum.value(), value) : value; } else if (instruction.type == InstructionIndex::AssertionLess) { + if (value == std::numeric_limits::min()) { + return false; + } + const auto adjusted{value - 1}; maximum = maximum.has_value() ? std::min(maximum.value(), adjusted) : adjusted; @@ -293,6 +326,47 @@ inline auto merge_integer_bound(const Instruction &instruction, return true; } +// Whether the bound fits the integer the instructions carry. One that does not +// sits beyond the size of anything that can be held in memory, so the keyword +// compilers say such a bound outright rather than convert a value that does +// not fit. The comparison reads the number by its value, so it holds whichever +// way the literal came to be stored +inline auto bound_is_representable(const sourcemeta::core::JSON &value) + -> bool { + static const sourcemeta::core::JSON LIMIT{ + std::numeric_limits::max()}; + return value <= LIMIT; +} + +// Draft 3 to 7 let `$ref` replace every sibling keyword, so nothing those +// siblings say ever takes effect, and the OpenAPI 3.0 Schema Object answers +// with Draft 4 here. Core's schema walker frames nothing under one of those +// drafts either, which is what keeps such a sibling from compiling at all +inline auto ref_overrides_sibling_keywords(const SchemaContext &schema_context) + -> bool { + using Known = sourcemeta::core::SchemaVocabularies::Known; + return schema_context.schema.is_object() && + schema_context.schema.defines("$ref") && + schema_context.vocabularies.contains_any( + {Known::JSON_SCHEMA_DRAFT_7, Known::JSON_SCHEMA_DRAFT_7_HYPER, + Known::JSON_SCHEMA_DRAFT_6, Known::JSON_SCHEMA_DRAFT_6_HYPER, + Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER, + Known::JSON_SCHEMA_DRAFT_3, Known::JSON_SCHEMA_DRAFT_3_HYPER, + Known::OPENAPI_3_0_BASE}); +} + +// The OpenAPI 3.0 Schema Object spells a reference as a Reference Object, whose +// every other member the specification says to ignore. The shape of such a +// member is therefore not this dialect's to judge at all, and its own +// meta-schema agrees: it accepts whatever stands beside a `$ref`. Every other +// dialect here lets `$ref` override its siblings without excusing them from +// being well-formed, which their meta-schemas do go on to check +inline auto ref_object_ignores_siblings( + const sourcemeta::core::SchemaVocabularies &vocabularies) -> bool { + return vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known::OPENAPI_3_0_BASE); +} + // Note that the size keywords that let the type level fuse their bound accept // any integral number, including reals with no fractional part like `2.0`, so // this must recognise those too or the fused bound would be silently dropped @@ -577,7 +651,8 @@ inline auto required_properties(const SchemaContext &schema_context) schema_context.vocabularies.contains( Known::JSON_SCHEMA_2019_09_VALIDATION) || schema_context.vocabularies.contains( - Known::JSON_SCHEMA_2020_12_VALIDATION)}; + Known::JSON_SCHEMA_2020_12_VALIDATION) || + schema_context.vocabularies.contains(Known::OPENAPI_3_0_BASE)}; ValueStringSet result; diff --git a/vendor/blaze/src/compiler/default_compiler.cc b/vendor/blaze/src/compiler/default_compiler.cc index 50032724..92779a8e 100644 --- a/vendor/blaze/src/compiler/default_compiler.cc +++ b/vendor/blaze/src/compiler/default_compiler.cc @@ -8,16 +8,76 @@ #include "default_compiler_draft7.h" #include "default_compiler_openapi.h" +#include // std::ranges::any_of #include // assert #include // std::string #include // std::unordered_set +namespace { + +// Whether any element of the array is a schema +auto any_element_is_a_schema(const sourcemeta::core::JSON &value, + const bool allow_boolean) -> bool { + return value.is_array() && + std::ranges::any_of(value.as_array(), + [allow_boolean](const auto &element) -> bool { + return is_schema(element, allow_boolean); + }); +} + +// Whether a schema sits where a keyword of this kind holds one, and so would +// have to compile. A single schema is enough to settle it, as several of these +// keywords let a schema stand beside a value that is not one: Draft 3 `type` +// lists type names among its subschemas, and `dependencies` lists property +// names among them, so asking that every member or element be a schema would +// hand a schema to a compiler that cannot be given one +auto holds_schema(const sourcemeta::core::SchemaKeywordType type, + const sourcemeta::core::JSON &value, const bool allow_boolean) + -> bool { + switch (type) { + case sourcemeta::core::SchemaKeywordType::Unknown: + case sourcemeta::core::SchemaKeywordType::Assertion: + case sourcemeta::core::SchemaKeywordType::Annotation: + case sourcemeta::core::SchemaKeywordType::Reference: + case sourcemeta::core::SchemaKeywordType::Other: + case sourcemeta::core::SchemaKeywordType::Comment: + return false; + // The schemas are the members + case sourcemeta::core::SchemaKeywordType::LocationMembers: + case sourcemeta::core::SchemaKeywordType:: + ApplicatorMembersTraversePropertyStatic: + case sourcemeta::core::SchemaKeywordType:: + ApplicatorMembersTraversePropertyRegex: + case sourcemeta::core::SchemaKeywordType::ApplicatorMembersInPlaceSome: + return value.is_object() && + std::ranges::any_of( + value.as_object(), [allow_boolean](const auto &entry) -> bool { + return is_schema(entry.second, allow_boolean); + }); + // The schemas are the elements, and the value itself is never one + case sourcemeta::core::SchemaKeywordType::ApplicatorElementsTraverseItem: + case sourcemeta::core::SchemaKeywordType::ApplicatorElementsInPlace: + case sourcemeta::core::SchemaKeywordType::ApplicatorElementsInPlaceSome: + case sourcemeta::core::SchemaKeywordType:: + ApplicatorElementsInPlaceSomeNegate: + return any_element_is_a_schema(value, allow_boolean); + // Either the value is the schema, or its elements are + case sourcemeta::core::SchemaKeywordType:: + ApplicatorValueOrElementsTraverseAnyItemOrItem: + case sourcemeta::core::SchemaKeywordType::ApplicatorValueOrElementsInPlace: + return is_schema(value, allow_boolean) || + any_element_is_a_schema(value, allow_boolean); + // The value is the schema + default: + return is_schema(value, allow_boolean); + } +} + // NOLINTNEXTLINE(google-readability-function-size,hicpp-function-size,readability-function-size) -auto sourcemeta::blaze::default_schema_compiler( - const sourcemeta::blaze::Context &context, - const sourcemeta::blaze::SchemaContext &schema_context, - const sourcemeta::blaze::DynamicContext &dynamic_context, - const sourcemeta::blaze::Instructions ¤t) +auto dispatch_keyword(const sourcemeta::blaze::Context &context, + const sourcemeta::blaze::SchemaContext &schema_context, + const sourcemeta::blaze::DynamicContext &dynamic_context, + const sourcemeta::blaze::Instructions ¤t) -> sourcemeta::blaze::Instructions { assert(!dynamic_context.keyword.empty()); @@ -46,6 +106,7 @@ auto sourcemeta::blaze::default_schema_compiler( Known::JSON_SCHEMA_DRAFT_4_HYPER, Known::JSON_SCHEMA_DRAFT_3, Known::JSON_SCHEMA_DRAFT_3_HYPER, + Known::OPENAPI_3_0_BASE, Known::OPENAPI_3_1_BASE, Known::OPENAPI_3_2_BASE, Known::SOURCEMETA_EXTENSION_V1}; @@ -70,13 +131,6 @@ auto sourcemeta::blaze::default_schema_compiler( current); \ } -#define STOP_IF_SIBLING_KEYWORD(vocabulary, _keyword) \ - if (schema_context.vocabularies.contains(vocabulary) && \ - schema_context.schema.is_object() && \ - schema_context.schema.defines(_keyword)) { \ - return {}; \ - } - // ******************************************** // 2020-12 // ******************************************** @@ -293,8 +347,6 @@ auto sourcemeta::blaze::default_schema_compiler( COMPILE_ANY(Known::JSON_SCHEMA_DRAFT_7, Known::JSON_SCHEMA_DRAFT_7_HYPER, "$ref", compiler_draft3_core_ref); - STOP_IF_SIBLING_KEYWORD(Known::JSON_SCHEMA_DRAFT_7, "$ref"); - STOP_IF_SIBLING_KEYWORD(Known::JSON_SCHEMA_DRAFT_7_HYPER, "$ref"); // Any COMPILE_ANY(Known::JSON_SCHEMA_DRAFT_7, Known::JSON_SCHEMA_DRAFT_7_HYPER, @@ -382,8 +434,6 @@ auto sourcemeta::blaze::default_schema_compiler( COMPILE_ANY(Known::JSON_SCHEMA_DRAFT_6, Known::JSON_SCHEMA_DRAFT_6_HYPER, "$ref", compiler_draft3_core_ref); - STOP_IF_SIBLING_KEYWORD(Known::JSON_SCHEMA_DRAFT_6, "$ref"); - STOP_IF_SIBLING_KEYWORD(Known::JSON_SCHEMA_DRAFT_6_HYPER, "$ref"); // Any COMPILE_ANY(Known::JSON_SCHEMA_DRAFT_6, Known::JSON_SCHEMA_DRAFT_6_HYPER, @@ -468,8 +518,6 @@ auto sourcemeta::blaze::default_schema_compiler( COMPILE_ANY(Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER, "$ref", compiler_draft3_core_ref); - STOP_IF_SIBLING_KEYWORD(Known::JSON_SCHEMA_DRAFT_4, "$ref"); - STOP_IF_SIBLING_KEYWORD(Known::JSON_SCHEMA_DRAFT_4_HYPER, "$ref"); // Applicators COMPILE_ANY(Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER, @@ -541,8 +589,6 @@ auto sourcemeta::blaze::default_schema_compiler( COMPILE_ANY(Known::JSON_SCHEMA_DRAFT_3, Known::JSON_SCHEMA_DRAFT_3_HYPER, "$ref", compiler_draft3_core_ref); - STOP_IF_SIBLING_KEYWORD(Known::JSON_SCHEMA_DRAFT_3, "$ref"); - STOP_IF_SIBLING_KEYWORD(Known::JSON_SCHEMA_DRAFT_3_HYPER, "$ref"); // Applicators COMPILE_ANY(Known::JSON_SCHEMA_DRAFT_3, Known::JSON_SCHEMA_DRAFT_3_HYPER, @@ -609,9 +655,101 @@ auto sourcemeta::blaze::default_schema_compiler( COMPILE_ANY(Known::OPENAPI_3_1_BASE, Known::OPENAPI_3_2_BASE, "example", compiler_openapi_noop); + // ******************************************** + // OPENAPI 3.0 + // ******************************************** + + // Every arm below guards on this vocabulary alone, never sharing a + // `COMPILE_ANY` with a JSON Schema one. The OpenAPI 3.0 Schema Object is a + // closed dialect whose keyword set is neither a subset nor a superset of + // Draft 4, so a shared arm would hand it a keyword it must not have. The + // rejection at the end of this function relies on this table naming every + // keyword of the dialect and nothing else + + COMPILE(Known::OPENAPI_3_0_BASE, "$ref", compiler_draft3_core_ref); + + // Applicators + COMPILE(Known::OPENAPI_3_0_BASE, "allOf", compiler_draft4_applicator_allof); + COMPILE(Known::OPENAPI_3_0_BASE, "anyOf", compiler_draft4_applicator_anyof); + COMPILE(Known::OPENAPI_3_0_BASE, "oneOf", compiler_draft4_applicator_oneof); + COMPILE(Known::OPENAPI_3_0_BASE, "not", compiler_draft4_applicator_not); + COMPILE(Known::OPENAPI_3_0_BASE, "properties", + compiler_draft3_applicator_properties); + COMPILE(Known::OPENAPI_3_0_BASE, "additionalProperties", + compiler_draft3_applicator_additionalproperties); + COMPILE(Known::OPENAPI_3_0_BASE, "items", compiler_draft3_applicator_items); + + // Any + COMPILE(Known::OPENAPI_3_0_BASE, "type", compiler_draft3_validation_type); + COMPILE(Known::OPENAPI_3_0_BASE, "enum", compiler_draft3_validation_enum); + + // Object + COMPILE(Known::OPENAPI_3_0_BASE, "required", + compiler_draft4_validation_required); + COMPILE(Known::OPENAPI_3_0_BASE, "maxProperties", + compiler_draft4_validation_maxproperties); + COMPILE(Known::OPENAPI_3_0_BASE, "minProperties", + compiler_draft4_validation_minproperties); + + // Array + COMPILE(Known::OPENAPI_3_0_BASE, "uniqueItems", + compiler_draft3_validation_uniqueitems); + COMPILE(Known::OPENAPI_3_0_BASE, "maxItems", + compiler_draft3_validation_maxitems); + COMPILE(Known::OPENAPI_3_0_BASE, "minItems", + compiler_draft3_validation_minitems); + + // String + COMPILE(Known::OPENAPI_3_0_BASE, "pattern", + compiler_draft3_validation_pattern); + COMPILE(Known::OPENAPI_3_0_BASE, "maxLength", + compiler_draft3_validation_maxlength); + COMPILE(Known::OPENAPI_3_0_BASE, "minLength", + compiler_draft3_validation_minlength); + COMPILE(Known::OPENAPI_3_0_BASE, "format", compiler_draft3_validation_format); + + // Number + COMPILE(Known::OPENAPI_3_0_BASE, "maximum", + compiler_draft3_validation_maximum); + COMPILE(Known::OPENAPI_3_0_BASE, "minimum", + compiler_draft3_validation_minimum); + COMPILE(Known::OPENAPI_3_0_BASE, "multipleOf", + compiler_draft3_validation_divisibleby); + + // `maximum` and `minimum` read these as siblings, as they do on the Draft 4 + // rung, so the keywords themselves emit nothing. They need an arm all the + // same, or the rejection below would refuse them + COMPILE(Known::OPENAPI_3_0_BASE, "exclusiveMaximum", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "exclusiveMinimum", compiler_openapi_noop); + + // `type` reads `nullable` as a sibling, and the rest assert nothing at all + COMPILE(Known::OPENAPI_3_0_BASE, "nullable", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "discriminator", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "xml", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "externalDocs", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "example", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "readOnly", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "writeOnly", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "deprecated", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "title", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "description", compiler_openapi_noop); + COMPILE(Known::OPENAPI_3_0_BASE, "default", compiler_openapi_noop); + #undef COMPILE #undef COMPILE_ANY -#undef STOP_IF_SIBLING_KEYWORD + + // The OpenAPI 3.0 Schema Object is closed: its meta-schema sets + // `additionalProperties` to `false` and admits no member other than the + // keywords it names and those prefixed with `x-`. A member outside that set + // is no keyword we may ignore, so compilation refuses it rather than passing + // it by, and the table above is therefore a complete enumeration of the + // dialect rather than a convenience + if (schema_context.vocabularies.contains(Known::OPENAPI_3_0_BASE) && + !dynamic_context.keyword.starts_with("x-")) { + throw sourcemeta::blaze::CompilerError( + schema_context.base, absolute_schema_location(context, schema_context), + PROHIBITED_KEYWORD_FOR_DIALECT); + } if ((schema_context.vocabularies.contains(Known::JSON_SCHEMA_2019_09_CORE) || schema_context.vocabularies.contains(Known::JSON_SCHEMA_2020_12_CORE)) && @@ -639,3 +777,45 @@ auto sourcemeta::blaze::default_schema_compiler( return {}; } + +} // namespace + +auto sourcemeta::blaze::default_schema_compiler( + const sourcemeta::blaze::Context &context, + const sourcemeta::blaze::SchemaContext &schema_context, + const sourcemeta::blaze::DynamicContext &dynamic_context, + const sourcemeta::blaze::Instructions ¤t) + -> sourcemeta::blaze::Instructions { + if (ref_overrides_sibling_keywords(schema_context)) { + const auto &metadata{ + context.walker(dynamic_context.keyword, schema_context.vocabularies)}; + if (metadata.type != sourcemeta::core::SchemaKeywordType::Reference) { + // Whether the shape an overridden keyword takes is one its dialect + // allows at all is still for the keyword compiler to judge, so such a + // keyword compiles for that judgement alone and what it makes is thrown + // away. One holding a schema is where that stops, as core's schema + // walker frames nothing under an overridden keyword, and an unframed + // schema is not one this compiler can be handed. The dialect that reads + // a reference as an object of its own stops earlier still, as there the + // sibling is not merely overridden but ignored. + // + // Draft 3 has no boolean schemas, but its own definitions of these two + // keywords accept a boolean in place of one + const auto allow_boolean{ + booleans_are_schemas(schema_context.vocabularies) || + dynamic_context.keyword == "additionalProperties" || + dynamic_context.keyword == "additionalItems"}; + if (!ref_object_ignores_siblings(schema_context.vocabularies) && + !holds_schema(metadata.type, + schema_context.schema.at(dynamic_context.keyword), + allow_boolean)) { + [[maybe_unused]] const auto discarded{dispatch_keyword( + context, schema_context, dynamic_context, current)}; + } + + return {}; + } + } + + return dispatch_keyword(context, schema_context, dynamic_context, current); +} diff --git a/vendor/blaze/src/compiler/default_compiler_2019_09.h b/vendor/blaze/src/compiler/default_compiler_2019_09.h index a466478f..14cb7e81 100644 --- a/vendor/blaze/src/compiler/default_compiler_2019_09.h +++ b/vendor/blaze/src/compiler/default_compiler_2019_09.h @@ -3,6 +3,8 @@ #include +#include // std::to_underlying + #include "compile_helpers.h" #include "default_compiler_draft4.h" @@ -149,28 +151,41 @@ auto compiler_2019_09_applicator_contains_with_options( return {}; } + // These bounds accept any integral number, including one spelled with a + // fractional or exponent part like `1.0` or `1e0`, as this dialect reads + // every number with a zero fractional part as an integer. A bound too large + // to hold is left out, as the general check below cannot carry it std::size_t minimum{1}; - if (schema_context.schema.defines("minContains")) { - if (schema_context.schema.at("minContains").is_integer() && - schema_context.schema.at("minContains").is_positive()) { - minimum = static_cast( - schema_context.schema.at("minContains").to_integer()); - } else if (schema_context.schema.at("minContains").is_real() && - schema_context.schema.at("minContains").is_positive()) { + bool minimum_is_unreachable{false}; + if (schema_context.schema.defines("minContains") && + schema_context.schema.at("minContains").is_integral() && + schema_context.schema.at("minContains").is_positive()) { + if (bound_is_representable(schema_context.schema.at("minContains"))) { minimum = static_cast( schema_context.schema.at("minContains").as_integer()); + } else { + minimum_is_unreachable = true; } } std::optional maximum; - if (schema_context.schema.defines("maxContains")) { - if (schema_context.schema.at("maxContains").is_integer() && - schema_context.schema.at("maxContains").is_positive()) { - maximum = schema_context.schema.at("maxContains").to_integer(); - } else if (schema_context.schema.at("maxContains").is_real() && - schema_context.schema.at("maxContains").is_positive()) { - maximum = schema_context.schema.at("maxContains").as_integer(); - } + if (schema_context.schema.defines("maxContains") && + schema_context.schema.at("maxContains").is_integral() && + schema_context.schema.at("maxContains").is_positive() && + bound_is_representable(schema_context.schema.at("maxContains"))) { + maximum = static_cast( + schema_context.schema.at("maxContains").as_integer()); + } + + // A lower bound too large to hold is one no array has enough items to meet, + // so every array fails and non-arrays are left alone, which is what this + // keyword asks for. Carrying such a bound into the general check below would + // instead report a count the schema never stated + if (minimum_is_unreachable) { + ValueTypes types; + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Array)); + return {make(sourcemeta::blaze::InstructionIndex::AssertionNotTypeStrictAny, + context, schema_context, dynamic_context, types)}; } // An unsatisfiable range is deliberately left for the general check below, diff --git a/vendor/blaze/src/compiler/default_compiler_draft3.h b/vendor/blaze/src/compiler/default_compiler_draft3.h index afbe992d..2e9a2411 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft3.h +++ b/vendor/blaze/src/compiler/default_compiler_draft3.h @@ -8,7 +8,9 @@ #include // std::sort, std::ranges::any_of, std::ranges::all_of, std::find_if, std::ranges::none_of #include // assert +#include // std::int32_t, std::int64_t #include // std::initializer_list +#include // std::numeric_limits #include // std::set #include // std::move, std::to_underlying @@ -111,9 +113,83 @@ static constexpr auto EXPECTED_NON_NEGATIVE{ static constexpr auto EXPECTED_TYPE_NAMES{ "This keyword was expected to be set to a known type name, or to a " "non-empty array of unique ones"}; +static constexpr auto UNSUPPORTED_VALUE_FOR_DIALECT{ + "The value this keyword is set to is not supported by this dialect"}; +static constexpr auto PROHIBITED_KEYWORD_FOR_DIALECT{ + "This keyword is prohibited by this dialect"}; +// A subschema that `narrows_type_away` drops is never compiled, so the refusal +// at the end of the dispatch table never reaches its members. Walk them here, +// as a member this dialect does not define is an error rather than one more +// keyword to pass by. The dialect is closed, so the applicators below are the +// complete set of places a further subschema can sit +static inline auto assert_members_are_permitted( + const sourcemeta::core::JSON &schema, + const sourcemeta::blaze::Context &context, + const sourcemeta::blaze::SchemaContext &schema_context, + const std::uint64_t depth = 0) -> void { + if (depth >= context.tweaks.max_depth) [[unlikely]] { + throw sourcemeta::blaze::CompilerDepthLimitError{context.tweaks.max_depth}; + } + + // The OpenAPI 3.0 Schema Object is the only dialect here that treats a + // member it does not define as an error, so it is the only one whose pruned + // subschemas have anything to answer for + if (!schema_context.vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known::OPENAPI_3_0_BASE)) { + return; + } + + if (!schema.is_object()) { + return; + } + + // A Reference Object ignores whatever sits beside `$ref`, so those members + // answer to no keyword set + if (schema.defines("$ref")) { + return; + } + + for (const auto &entry : schema.as_object()) { + if (entry.first.starts_with("x-")) { + continue; + } + + if (context.walker(entry.first, schema_context.vocabularies).type == + sourcemeta::core::SchemaKeywordType::Unknown) { + throw sourcemeta::blaze::CompilerError( + schema_context.base, + absolute_schema_location(context, schema_context), + PROHIBITED_KEYWORD_FOR_DIALECT); + } + + if (entry.first == "not" || entry.first == "items" || + entry.first == "additionalProperties") { + assert_members_are_permitted(entry.second, context, schema_context, + depth + 1); + } else if (entry.first == "allOf" || entry.first == "anyOf" || + entry.first == "oneOf") { + if (entry.second.is_array()) { + for (const auto &element : entry.second.as_array()) { + assert_members_are_permitted(element, context, schema_context, + depth + 1); + } + } + } else if (entry.first == "properties") { + if (entry.second.is_object()) { + for (const auto &property : entry.second.as_object()) { + assert_members_are_permitted(property.second, context, schema_context, + depth + 1); + } + } + } + } +} + static constexpr auto EXPECTED_UNIQUE_VALUES{ "This keyword was expected to be set to a non-empty array of unique " "values"}; +static constexpr auto EXPECTED_NON_EMPTY_VALUES{ + "This keyword was expected to be set to a non-empty array of values"}; static constexpr auto EXPECTED_SCHEMA_OBJECT{ "Every value of this keyword was expected to be a valid schema"}; static constexpr auto EXPECTED_DEPENDENCIES{ @@ -122,24 +198,80 @@ static constexpr auto EXPECTED_DEPENDENCIES{ // Draft 3 and Draft 4 read "integer" as a number written without a fractional // part, so a bound like `2.0` is not one there. Draft 6 onwards widened it to -// any number whose fractional part is zero +// any number whose fractional part is zero. The OpenAPI 3.0 Schema Object +// answers with Draft 4, as its meta-schema is a Draft 4 document. +// +// Note that OpenAPI 3.0.4 says the opposite in prose, that "both `1` and +// `1.0` are equivalent, and are both considered to be integers", reversing +// what 3.0.3 said. We keep the Draft 4 reading for every dated OpenAPI 3.0 +// dialect regardless, because the Schema Object extends the Draft 4 base +// dialect and so is the base dialect's question to answer, not a patch +// release's to redefine auto integral_reals_are_integers( const sourcemeta::core::SchemaVocabularies &vocabularies) -> bool { using Known = sourcemeta::core::SchemaVocabularies::Known; return !vocabularies.contains_any( {Known::JSON_SCHEMA_DRAFT_3, Known::JSON_SCHEMA_DRAFT_3_HYPER, - Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER}); + Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER, + Known::OPENAPI_3_0_BASE}); +} + +// A number too large to hold as a 64-bit integer is kept as a decimal, where +// whether it is an integer stays a question about how the literal was written +// rather than about how it came to be stored +auto is_dialect_integer( + const sourcemeta::core::JSON &value, + const sourcemeta::core::SchemaVocabularies &vocabularies) -> bool { + if (!value.is_integral()) { + return false; + } + + return integral_reals_are_integers(vocabularies) || value.is_integer() || + (value.is_decimal() && value.to_decimal().is_integer()); } // Draft 6 introduced boolean schemas. Draft 4 and earlier have none, and the // only places they accept a boolean are `additionalProperties` and -// `additionalItems`, whose own definitions spell that out +// `additionalItems`, whose own definitions spell that out. The OpenAPI 3.0 +// Schema Object has none either, and offers a boolean at +// `additionalProperties` alone auto booleans_are_schemas( const sourcemeta::core::SchemaVocabularies &vocabularies) -> bool { using Known = sourcemeta::core::SchemaVocabularies::Known; return !vocabularies.contains_any( {Known::JSON_SCHEMA_DRAFT_3, Known::JSON_SCHEMA_DRAFT_3_HYPER, - Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER}); + Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER, + Known::OPENAPI_3_0_BASE}); +} + +// OpenAPI 3.0 bans `type: "null"` and offers `nullable` in its place, which +// widens a sibling `type` and nothing else. It takes effect only where `type` +// is present, so on its own it constrains nothing, and it does not relax any +// other keyword, so a null instance still has to satisfy every sibling. +// The vocabulary has to be in play, or a member by that name in a dialect that +// gives it no meaning would be read as author data one moment and as a keyword +// the next +auto is_nullable(const sourcemeta::core::SchemaVocabularies &vocabularies, + const sourcemeta::core::JSON &schema) -> bool { + using Known = sourcemeta::core::SchemaVocabularies::Known; + return vocabularies.contains(Known::OPENAPI_3_0_BASE) && schema.is_object() && + schema.defines("nullable") && schema.at("nullable").is_boolean() && + schema.at("nullable").to_boolean(); +} + +// Whether a sibling `type` names this type and may be taken as a guarantee +// about the instance. A scalar `type` normally is one, which lets the compiler +// specialise on it, either by folding another keyword's bound into the type +// assertion or by emitting an instruction that assumes the instance's shape. +// `nullable` widens it to also admit null, so there it guarantees nothing and +// every such specialisation has to stand down +auto assume_type(const sourcemeta::core::SchemaVocabularies &vocabularies, + const sourcemeta::core::JSON &schema, + const std::string_view name) -> bool { + return schema.is_object() && schema.defines("type") && + schema.at("type").is_string() && + schema.at("type").to_string() == name && + !is_nullable(vocabularies, schema); } auto is_schema(const sourcemeta::core::JSON &value, const bool allow_boolean) @@ -159,11 +291,22 @@ auto all_are_schemas(const sourcemeta::core::JSON &value, // that does not satisfy that is not a constraint at all, so we ignore it // rather than compile part of it or turn it into a failure auto is_schema_array(const sourcemeta::core::JSON &value, - const bool allow_boolean) -> bool { - return value.is_array() && !value.empty() && + const bool allow_boolean, const bool allow_empty = false) + -> bool { + return value.is_array() && (allow_empty || !value.empty()) && all_are_schemas(value, allow_boolean); } +// Draft 4 onwards give `allOf`, `anyOf` and `oneOf` a `minItems` of one, so an +// empty array is malformed there. The OpenAPI 3.0 Schema Object constrains +// only the member type, leaving an empty one well-formed, and an empty +// conjunction or disjunction has a settled meaning of its own +auto in_place_applicators_may_be_empty( + const sourcemeta::core::SchemaVocabularies &vocabularies) -> bool { + using Known = sourcemeta::core::SchemaVocabularies::Known; + return vocabularies.contains(Known::OPENAPI_3_0_BASE); +} + // Draft 3 describes `type` and `disallow` as a type name or an array of unique // entries, each of which is a type name or a subschema auto is_draft3_type_union(const sourcemeta::core::JSON &value) -> bool { @@ -218,6 +361,14 @@ compile_properties(const sourcemeta::blaze::Context &context, const sourcemeta::blaze::DynamicContext &dynamic_context, const sourcemeta::blaze::Instructions &) -> std::vector> { + // The keywords that fuse with `properties` reach it from the side, past the + // point where an overridden keyword is dropped. A `properties` that `$ref` + // overrides enforces nothing, and nothing under it is framed, so there is + // neither anything to compile here nor anything to fuse with + if (ref_overrides_sibling_keywords(schema_context)) { + return {}; + } + std::vector> properties; for (const auto &entry : schema_context.schema.at("properties").as_object()) { @@ -322,10 +473,8 @@ auto compile_required_assertions(const Context &context, return {}; } - const auto assume_object{schema_context.schema.defines("type") && - schema_context.schema.at("type").is_string() && - schema_context.schema.at("type").to_string() == - "object"}; + const auto assume_object{assume_type(schema_context.vocabularies, + schema_context.schema, "object")}; if (properties_set.empty()) { return {}; @@ -463,7 +612,10 @@ auto properties_as_loop(const Context &context, schema_context.vocabularies.contains( Known::JSON_SCHEMA_2019_09_VALIDATION) || schema_context.vocabularies.contains( - Known::JSON_SCHEMA_2020_12_VALIDATION); + Known::JSON_SCHEMA_2020_12_VALIDATION) || + schema_context.vocabularies.contains(Known::OPENAPI_3_0_BASE); + // `const` is deliberately absent from the OpenAPI 3.0 dialect below, which + // has `enum` but no `const` of its own const auto imports_const = schema_context.vocabularies.contains(Known::JSON_SCHEMA_DRAFT_6) || schema_context.vocabularies.contains(Known::JSON_SCHEMA_DRAFT_7) || @@ -661,6 +813,17 @@ auto extract_integer_bounds(const Instructions &children) return {minimum, maximum}; } +auto integer_lower_bound_extra_index(const Instructions &children) + -> std::size_t { + for (const auto &child : children) { + if (child.type == InstructionIndex::AssertionGreaterEqual) { + return child.extra_index; + } + } + + return 0; +} + auto compiler_draft3_applicator_properties_with_options( const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, const Instructions ¤t, @@ -680,6 +843,11 @@ auto compiler_draft3_applicator_properties_with_options( } if (narrows_type_away(schema_context.schema, {"object"})) { + for (const auto &entry : + schema_context.schema.at(dynamic_context.keyword).as_object()) { + assert_members_are_permitted(entry.second, context, schema_context); + } + return {}; } @@ -739,10 +907,8 @@ auto compiler_draft3_applicator_properties_with_options( ? dynamic_context : relative_dynamic_context()}; - const auto assume_object{schema_context.schema.defines("type") && - schema_context.schema.at("type").is_string() && - schema_context.schema.at("type").to_string() == - "object"}; + const auto assume_object{assume_type(schema_context.vocabularies, + schema_context.schema, "object")}; ValueStringSet required{required_properties(schema_context)}; @@ -941,10 +1107,13 @@ auto compiler_draft3_applicator_properties_with_options( ? InstructionIndex::AssertionTypeIntegerBoundedStrict : InstructionIndex::AssertionTypeIntegerBounded; auto instance_location = substeps.front().relative_instance_location; + const auto minimum_extra_index{ + integer_lower_bound_extra_index(substeps)}; substeps.clear(); auto fused = make(index, context, schema_context, relative_dynamic_context(), bounds); fused.relative_instance_location = std::move(instance_location); + fused.extra_index = minimum_extra_index; substeps.push_back(std::move(fused)); } else if (is_integer_type_lower_bound_pattern(substeps)) { const auto minimum = extract_integer_lower_bound(substeps); @@ -953,11 +1122,14 @@ auto compiler_draft3_applicator_properties_with_options( ? InstructionIndex::AssertionTypeIntegerLowerBoundStrict : InstructionIndex::AssertionTypeIntegerLowerBound; auto instance_location = substeps.front().relative_instance_location; + const auto minimum_extra_index{ + integer_lower_bound_extra_index(substeps)}; substeps.clear(); auto fused = make(index, context, schema_context, relative_dynamic_context(), ValueIntegerBounds{minimum, 0}); fused.relative_instance_location = std::move(instance_location); + fused.extra_index = minimum_extra_index; substeps.push_back(std::move(fused)); } else if (substeps.size() == 2) { bool has_items_bounded{false}; @@ -1287,10 +1459,8 @@ auto compiler_draft3_applicator_patternproperties( inline auto properties_enforce_closed_object(const Context &context, const SchemaContext &schema_context) -> bool { - const bool assume_object{schema_context.schema.defines("type") && - schema_context.schema.at("type").is_string() && - schema_context.schema.at("type").to_string() == - "object"}; + const bool assume_object{assume_type(schema_context.vocabularies, + schema_context.schema, "object")}; if (!assume_object || !schema_context.schema.defines("properties") || !schema_context.schema.at("properties").is_object()) { return false; @@ -1334,6 +1504,9 @@ auto compiler_draft3_applicator_additionalproperties_with_options( const DynamicContext &dynamic_context, const bool annotate, const bool track_evaluation) -> Instructions { if (narrows_type_away(schema_context.schema, {"object"})) { + assert_members_are_permitted( + schema_context.schema.at(dynamic_context.keyword), context, + schema_context); return {}; } @@ -1686,6 +1859,9 @@ auto compiler_draft3_applicator_items_with_options( } if (narrows_type_away(schema_context.schema, {"array"})) { + assert_members_are_permitted( + schema_context.schema.at(dynamic_context.keyword), context, + schema_context); return {}; } @@ -1887,16 +2063,16 @@ auto compiler_draft3_applicator_additionalitems_with_options( assert(schema_context.schema.is_object()); - // Nothing to do here + // This keyword only has a say alongside an `items` array, as every other + // form of `items` already applies to every element. Draft 6 made a boolean a + // schema in its own right, so `items: true` is one of those forms rather + // than an absent `items` if (!schema_context.schema.defines("items") || - schema_context.schema.at("items").is_object()) { + !schema_context.schema.at("items").is_array()) { return {}; } - const auto cursor{(schema_context.schema.defines("items") && - schema_context.schema.at("items").is_array()) - ? schema_context.schema.at("items").size() - : 0}; + const auto cursor{schema_context.schema.at("items").size()}; return compiler_draft3_applicator_additionalitems_from_cursor( context, schema_context, dynamic_context, cursor, annotate, @@ -1934,6 +2110,15 @@ auto compiler_draft3_validation_enum(const Context &context, EXPECTED_UNIQUE_VALUES); } + // The OpenAPI 3.0 Schema Object asks for at least one value, and is alone in + // permitting duplicates among them + if (schema_context.vocabularies.contains(Known::OPENAPI_3_0_BASE) && + schema_context.schema.at(dynamic_context.keyword).empty()) { + throw sourcemeta::blaze::CompilerError( + schema_context.base, absolute_schema_location(context, schema_context), + EXPECTED_NON_EMPTY_VALUES); + } + if (schema_context.schema.at(dynamic_context.keyword).empty()) { return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, schema_context, dynamic_context, ValueNone{})}; @@ -1954,14 +2139,20 @@ auto compiler_draft3_validation_enum(const Context &context, sourcemeta::core::PropertyHashJSON hasher; for (const auto &option : schema_context.schema.at(dynamic_context.keyword).as_array()) { + // Neither 2019-09 onwards nor the OpenAPI 3.0 Schema Object asks for the + // options to be unique, and the comparison below weighs these hashes + // against the deduplicated set of options, so a repeated option must not + // be counted twice + if (!options.insert(option).second) { + continue; + } + if (option.is_string()) { const auto hash{hasher(option.to_string())}; if (hasher.is_perfect(hash)) { perfect_string_hashes.emplace_back(option.to_string(), hash); } } - - options.insert(option); } // Only apply this optimisation on fast validation, as it @@ -2000,9 +2191,8 @@ auto compiler_draft3_validation_maxlength(const Context &context, const DynamicContext &dynamic_context, const Instructions &) -> Instructions { - if (!schema_context.schema.at(dynamic_context.keyword).is_integral() || - (!integral_reals_are_integers(schema_context.vocabularies) && - !schema_context.schema.at(dynamic_context.keyword).is_integer())) { + if (!is_dialect_integer(schema_context.schema.at(dynamic_context.keyword), + schema_context.vocabularies)) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), EXPECTED_INTEGER); @@ -2039,9 +2229,15 @@ auto compiler_draft3_validation_maxlength(const Context &context, // property name, so there we must still emit them ourselves if (context.mode == Mode::FastValidation && !schema_context.is_property_name && - schema_context.schema.defines("type") && - schema_context.schema.at("type").is_string() && - schema_context.schema.at("type").to_string() == "string") { + assume_type(schema_context.vocabularies, schema_context.schema, + "string")) { + return {}; + } + + // A bound too large to hold sits beyond the length of anything that can + // exist, so every string meets it and there is nothing left to assert + if (!bound_is_representable( + schema_context.schema.at(dynamic_context.keyword))) { return {}; } @@ -2059,9 +2255,8 @@ auto compiler_draft3_validation_minlength(const Context &context, const DynamicContext &dynamic_context, const Instructions &) -> Instructions { - if (!schema_context.schema.at(dynamic_context.keyword).is_integral() || - (!integral_reals_are_integers(schema_context.vocabularies) && - !schema_context.schema.at(dynamic_context.keyword).is_integer())) { + if (!is_dialect_integer(schema_context.schema.at(dynamic_context.keyword), + schema_context.vocabularies)) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), EXPECTED_INTEGER); @@ -2077,14 +2272,29 @@ auto compiler_draft3_validation_minlength(const Context &context, return {}; } + // A bound too large to hold is beyond the length of anything that can + // exist, so no string can meet it, and nothing else is affected. A property + // name is always a string, which leaves the type check nothing to spare + if (!bound_is_representable( + schema_context.schema.at(dynamic_context.keyword))) { + if (schema_context.is_property_name) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, + schema_context, dynamic_context, ValueNone{})}; + } + + ValueTypes types; + types.set(std::to_underlying(sourcemeta::core::JSON::Type::String)); + return {make(sourcemeta::blaze::InstructionIndex::AssertionNotTypeStrictAny, + context, schema_context, dynamic_context, types)}; + } + // We'll handle it at the type level as an optimization. Note that the type // compiler bails out before it reads these bounds when it is compiling a // property name, so there we must still emit them ourselves if (context.mode == Mode::FastValidation && !schema_context.is_property_name && - schema_context.schema.defines("type") && - schema_context.schema.at("type").is_string() && - schema_context.schema.at("type").to_string() == "string") { + assume_type(schema_context.vocabularies, schema_context.schema, + "string")) { return {}; } @@ -2103,9 +2313,8 @@ auto compiler_draft3_validation_maxitems(const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, const Instructions &) -> Instructions { - if (!schema_context.schema.at(dynamic_context.keyword).is_integral() || - (!integral_reals_are_integers(schema_context.vocabularies) && - !schema_context.schema.at(dynamic_context.keyword).is_integer())) { + if (!is_dialect_integer(schema_context.schema.at(dynamic_context.keyword), + schema_context.vocabularies)) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), EXPECTED_INTEGER); @@ -2123,9 +2332,15 @@ auto compiler_draft3_validation_maxitems(const Context &context, // We'll handle it at the type level as an optimization if (context.mode == Mode::FastValidation && - schema_context.schema.defines("type") && - schema_context.schema.at("type").is_string() && - schema_context.schema.at("type").to_string() == "array") { + assume_type(schema_context.vocabularies, schema_context.schema, + "array")) { + return {}; + } + + // A bound too large to hold sits beyond the size of anything that can + // exist, so every array meets it and there is nothing left to assert + if (!bound_is_representable( + schema_context.schema.at(dynamic_context.keyword))) { return {}; } @@ -2142,9 +2357,8 @@ auto compiler_draft3_validation_minitems(const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, const Instructions &) -> Instructions { - if (!schema_context.schema.at(dynamic_context.keyword).is_integral() || - (!integral_reals_are_integers(schema_context.vocabularies) && - !schema_context.schema.at(dynamic_context.keyword).is_integer())) { + if (!is_dialect_integer(schema_context.schema.at(dynamic_context.keyword), + schema_context.vocabularies)) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), EXPECTED_INTEGER); @@ -2160,11 +2374,25 @@ auto compiler_draft3_validation_minitems(const Context &context, return {}; } + // A bound too large to hold is beyond the size of anything that can + // exist, so no array can meet it, and nothing else is affected. A property + // name is always a string, which no array bound applies to + if (!bound_is_representable( + schema_context.schema.at(dynamic_context.keyword))) { + if (schema_context.is_property_name) { + return {}; + } + + ValueTypes types; + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Array)); + return {make(sourcemeta::blaze::InstructionIndex::AssertionNotTypeStrictAny, + context, schema_context, dynamic_context, types)}; + } + // We'll handle it at the type level as an optimization if (context.mode == Mode::FastValidation && - schema_context.schema.defines("type") && - schema_context.schema.at("type").is_string() && - schema_context.schema.at("type").to_string() == "array") { + assume_type(schema_context.vocabularies, schema_context.schema, + "array")) { return {}; } @@ -2243,6 +2471,33 @@ auto compiler_draft3_validation_minimum(const Context &context, schema_context.schema.at(dynamic_context.keyword)})}; } +// Whether the name was one this function knows, so that callers can tell an +// unrecognised name apart from one that set no bit +auto assign_type_bit(sourcemeta::blaze::ValueTypes &types, + const std::string_view name) -> bool { + if (name == "null") { + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Null)); + } else if (name == "boolean") { + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Boolean)); + } else if (name == "object") { + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Object)); + } else if (name == "array") { + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Array)); + } else if (name == "number") { + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Real)); + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Decimal)); + } else if (name == "integer") { + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); + } else if (name == "string") { + types.set(std::to_underlying(sourcemeta::core::JSON::Type::String)); + } else { + return false; + } + + return true; +} + auto compiler_draft3_validation_type(const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, @@ -2254,6 +2509,32 @@ auto compiler_draft3_validation_type(const Context &context, schema_context.vocabularies.contains(Known::JSON_SCHEMA_DRAFT_3) || schema_context.vocabularies.contains(Known::JSON_SCHEMA_DRAFT_3_HYPER)}; + // This dialect types `type` as a string drawn from six names. It has no + // array form and no null type, offering `nullable` in place of the latter. + // `assign_type_bit` answers for the seven JSON Schema names, so the null one + // has to be ruled out separately + if (schema_context.vocabularies.contains(Known::OPENAPI_3_0_BASE)) { + ValueTypes recognised{}; + if (!value.is_string() || value.to_string() == "null" || + !assign_type_bit(recognised, value.to_string())) { + throw sourcemeta::blaze::CompilerError( + schema_context.base, + absolute_schema_location(context, schema_context), + UNSUPPORTED_VALUE_FOR_DIALECT); + } + + // A scalar `type` widened by `nullable` is the array form in all but + // spelling, so it takes the same instruction and skips the scalar fast + // paths below, none of which can express a bound alongside a second + // permitted type + if (is_nullable(schema_context.vocabularies, schema_context.schema)) { + ValueTypes types{recognised}; + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Null)); + return {make(sourcemeta::blaze::InstructionIndex::AssertionTypeStrictAny, + context, schema_context, dynamic_context, types)}; + } + } + if (is_draft3) { if (value.is_string() && value.to_string() == "any") { return draft3_any_type_instructions(context, schema_context, @@ -2501,16 +2782,12 @@ auto compiler_draft3_validation_type(const Context &context, context, schema_context, dynamic_context, types)}; } if (type == "integer") { - if (context.mode == Mode::FastValidation && - schema_context.schema.defines("enum") && - schema_context.schema.at("enum").is_array() && - std::ranges::all_of(schema_context.schema.at("enum").as_array(), - [](const auto &candidate) -> auto { - return candidate.is_integer(); - })) { - return {}; - } - + // Draft 4 and earlier have no shortcut for an all-integer `enum` here, + // and neither does the OpenAPI 3.0 Schema Object, unlike every other + // type name. Equality reads a number by its value rather than by how it + // is written, so the options admit an instance carrying a fractional + // part, which none of these dialects counts as an integer, and so this + // assertion always has work left to do return {make(sourcemeta::blaze::InstructionIndex::AssertionTypeStrict, context, schema_context, dynamic_context, sourcemeta::core::JSON::Type::Integer)}; @@ -2633,24 +2910,7 @@ auto compiler_draft3_validation_type(const Context &context, continue; } - const auto &type_string{element.to_string()}; - if (type_string == "null") { - types.set(std::to_underlying(sourcemeta::core::JSON::Type::Null)); - } else if (type_string == "boolean") { - types.set(std::to_underlying(sourcemeta::core::JSON::Type::Boolean)); - } else if (type_string == "object") { - types.set(std::to_underlying(sourcemeta::core::JSON::Type::Object)); - } else if (type_string == "array") { - types.set(std::to_underlying(sourcemeta::core::JSON::Type::Array)); - } else if (type_string == "number") { - types.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); - types.set(std::to_underlying(sourcemeta::core::JSON::Type::Real)); - types.set(std::to_underlying(sourcemeta::core::JSON::Type::Decimal)); - } else if (type_string == "integer") { - types.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); - } else if (type_string == "string") { - types.set(std::to_underlying(sourcemeta::core::JSON::Type::String)); - } else if (!is_draft3) { + if (!assign_type_bit(types, element.to_string()) && !is_draft3) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), @@ -2964,6 +3224,22 @@ auto compiler_draft3_validation_divisibleby( schema_context.schema.at(dynamic_context.keyword)})}; } +// The formats that the OpenAPI 3.1 and 3.2 specifications define for numbers, +// which the rest of this compiler treats apart from the string ones +auto is_openapi_numeric_format(const SchemaContext &schema_context, + const sourcemeta::core::JSON &format) -> bool { + using Known = sourcemeta::core::SchemaVocabularies::Known; + if (!format.is_string() || + (!schema_context.vocabularies.contains(Known::OPENAPI_3_1_BASE) && + !schema_context.vocabularies.contains(Known::OPENAPI_3_2_BASE))) { + return false; + } + + const auto &name{format.to_string()}; + return name == "int32" || name == "int64" || name == "float" || + name == "double"; +} + auto compiler_draft3_validation_format(const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, @@ -2986,7 +3262,6 @@ auto compiler_draft3_validation_format(const Context &context, schema_context.schema.at("x-format-assertion").to_boolean()}; const auto assert_active{context.tweaks.format_assertion || force_assertion}; - if ((is_2019_09_format && assert_active) || is_2020_12_format_assertion || (is_2020_12_format_annotation && assert_active)) { const auto &format{schema_context.schema.at(dynamic_context.keyword)}; @@ -2995,6 +3270,47 @@ auto compiler_draft3_validation_format(const Context &context, } const auto &name{format.to_string()}; + + // These are the only formats the OpenAPI 3.1 and 3.2 specifications + // define themselves, and supporting any other entry of the Format + // Registry they defer to is strictly optional + // TODO: Consider asserting the numeric and string formats that the + // OpenAPI Format Registry defines + if (is_openapi_numeric_format(schema_context, format)) { + Instructions instructions; + if (name == "int32") { + instructions.push_back(make( + sourcemeta::blaze::InstructionIndex::AssertionNumberIntegerBounded, + context, schema_context, dynamic_context, + ValueIntegerBounds{std::numeric_limits::min(), + std::numeric_limits::max()})); + } else if (name == "int64") { + instructions.push_back(make( + sourcemeta::blaze::InstructionIndex::AssertionNumberIntegerBounded, + context, schema_context, dynamic_context, + ValueIntegerBounds{std::numeric_limits::min(), + std::numeric_limits::max()})); + } else { + instructions.push_back( + make(sourcemeta::blaze::InstructionIndex::AssertionNumberType, + context, schema_context, dynamic_context, + name == "float" ? ValueNumberType::Float + : ValueNumberType::Double)); + } + + // Unlike the string formats, the annotation cannot be grouped by the + // instance type, as `ControlGroupWhenType` does not take a number + if (annotations_enabled(context, dynamic_context.keyword)) { + instructions.push_back( + make(sourcemeta::blaze::InstructionIndex::AnnotationEmit, context, + schema_context, dynamic_context, + sourcemeta::core::JSON{ + schema_context.schema.at(dynamic_context.keyword)})); + } + + return instructions; + } + ValueStringType type; if (name == "date-time") { type = ValueStringType::DateTime; @@ -3068,6 +3384,15 @@ auto compiler_draft3_validation_format(const Context &context, sourcemeta::core::JSON{ schema_context.schema.at(dynamic_context.keyword)})}; + // A format that applies to numbers cannot have its annotation grouped by + // the instance type, so emit it the way the assertion path does rather + // than collect it for strings alone + if (is_openapi_numeric_format( + schema_context, + schema_context.schema.at(dynamic_context.keyword))) { + return children; + } + return {make(sourcemeta::blaze::InstructionIndex::ControlGroupWhenType, context, schema_context, relative_dynamic_context(), ValueType::String, std::move(children))}; @@ -3098,7 +3423,52 @@ auto compiler_draft3_validation_format(const Context &context, schema_context.vocabularies.contains(Known::JSON_SCHEMA_DRAFT_7) || schema_context.vocabularies.contains(Known::JSON_SCHEMA_DRAFT_7_HYPER)}; - if (is_draft7) { + if (schema_context.vocabularies.contains(Known::OPENAPI_3_0_BASE)) { + // OpenAPI 3.0 defines these two for numbers, so they cannot resolve to a + // string logical type like the rest of this chain does + if (name == "int32" || name == "int64") { + return {make( + sourcemeta::blaze::InstructionIndex::AssertionNumberIntegerBounded, + context, schema_context, dynamic_context, + name == "int32" + ? ValueIntegerBounds{std::numeric_limits::min(), + std::numeric_limits::max()} + : ValueIntegerBounds{std::numeric_limits::min(), + std::numeric_limits::max()})}; + } + + if (name == "float" || name == "double") { + return {make(sourcemeta::blaze::InstructionIndex::AssertionNumberType, + context, schema_context, dynamic_context, + name == "float" ? ValueNumberType::Float + : ValueNumberType::Double)}; + } + + if (name == "date-time") { + type = ValueStringType::DateTime; + } else if (name == "date") { + type = ValueStringType::Date; + } else if (name == "byte") { + type = ValueStringType::Base64; + } else if (name == "email") { + // The Schema Object is a subset of JSON Schema Draft Wright-00, whose + // own formats apply alongside the ones the specification tabulates + type = ValueStringType::Email; + } else if (name == "hostname") { + type = ValueStringType::Hostname; + } else if (name == "ipv4") { + type = ValueStringType::IPv4; + } else if (name == "ipv6") { + type = ValueStringType::IPv6; + } else if (name == "uri") { + type = ValueStringType::URI; + } else { + // `binary` stands for unencoded binary data, which a JSON string cannot + // carry, and `password` is a hint to obscure the value, so neither of + // the two remaining formats constrains the instance + return {}; + } + } else if (is_draft7) { if (name == "date-time") { type = ValueStringType::DateTime; } else if (name == "date") { diff --git a/vendor/blaze/src/compiler/default_compiler_draft4.h b/vendor/blaze/src/compiler/default_compiler_draft4.h index c69beae5..879169a1 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft4.h +++ b/vendor/blaze/src/compiler/default_compiler_draft4.h @@ -20,10 +20,12 @@ auto compiler_draft4_validation_required(const Context &context, const DynamicContext &dynamic_context, const Instructions ¤t) -> Instructions { - // Draft 4 alone asks that `required` name at least one property + // Draft 4 asks that `required` name at least one property, and the OpenAPI + // 3.0 Schema Object, whose meta-schema is a Draft 4 document, asks the same using Known = sourcemeta::core::SchemaVocabularies::Known; const auto allows_empty{!schema_context.vocabularies.contains_any( - {Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER})}; + {Known::JSON_SCHEMA_DRAFT_4, Known::JSON_SCHEMA_DRAFT_4_HYPER, + Known::OPENAPI_3_0_BASE})}; if (!is_string_array(schema_context.schema.at(dynamic_context.keyword)) || (!allows_empty && schema_context.schema.at(dynamic_context.keyword).empty())) { @@ -42,8 +44,10 @@ auto compiler_draft4_applicator_allof(const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, const Instructions &) -> Instructions { - if (!is_schema_array(schema_context.schema.at(dynamic_context.keyword), - booleans_are_schemas(schema_context.vocabularies))) { + if (!is_schema_array( + schema_context.schema.at(dynamic_context.keyword), + booleans_are_schemas(schema_context.vocabularies), + in_place_applicators_may_be_empty(schema_context.vocabularies))) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), EXPECTED_SCHEMA_ARRAY); @@ -90,13 +94,23 @@ auto compiler_draft4_applicator_anyof(const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, const Instructions &) -> Instructions { - if (!is_schema_array(schema_context.schema.at(dynamic_context.keyword), - booleans_are_schemas(schema_context.vocabularies))) { + if (!is_schema_array( + schema_context.schema.at(dynamic_context.keyword), + booleans_are_schemas(schema_context.vocabularies), + in_place_applicators_may_be_empty(schema_context.vocabularies))) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), EXPECTED_SCHEMA_ARRAY); } + // A disjunction over no branches can never be satisfied. Only a dialect that + // permits the empty array gets here, as the shape check above refuses it + // everywhere else + if (schema_context.schema.at(dynamic_context.keyword).empty()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, + schema_context, dynamic_context, ValueNone{})}; + } + Instructions disjunctors; for (std::uint64_t index = 0; index < schema_context.schema.at(dynamic_context.keyword).size(); @@ -166,13 +180,23 @@ auto compiler_draft4_applicator_oneof(const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, const Instructions &) -> Instructions { - if (!is_schema_array(schema_context.schema.at(dynamic_context.keyword), - booleans_are_schemas(schema_context.vocabularies))) { + if (!is_schema_array( + schema_context.schema.at(dynamic_context.keyword), + booleans_are_schemas(schema_context.vocabularies), + in_place_applicators_may_be_empty(schema_context.vocabularies))) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), EXPECTED_SCHEMA_ARRAY); } + // A disjunction over no branches can never be satisfied. Only a dialect that + // permits the empty array gets here, as the shape check above refuses it + // everywhere else + if (schema_context.schema.at(dynamic_context.keyword).empty()) { + return {make(sourcemeta::blaze::InstructionIndex::AssertionFail, context, + schema_context, dynamic_context, ValueNone{})}; + } + Instructions disjunctors; for (std::uint64_t index = 0; index < schema_context.schema.at(dynamic_context.keyword).size(); @@ -231,9 +255,8 @@ auto compiler_draft4_validation_maxproperties( const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, const Instructions &) -> Instructions { - if (!schema_context.schema.at(dynamic_context.keyword).is_integral() || - (!integral_reals_are_integers(schema_context.vocabularies) && - !schema_context.schema.at(dynamic_context.keyword).is_integer())) { + if (!is_dialect_integer(schema_context.schema.at(dynamic_context.keyword), + schema_context.vocabularies)) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), EXPECTED_INTEGER); @@ -253,9 +276,15 @@ auto compiler_draft4_validation_maxproperties( // We'll handle it at the type level as an optimization if (context.mode == Mode::FastValidation && - schema_context.schema.defines("type") && - schema_context.schema.at("type").is_string() && - schema_context.schema.at("type").to_string() == "object") { + assume_type(schema_context.vocabularies, schema_context.schema, + "object")) { + return {}; + } + + // A bound too large to hold sits beyond the size of anything that can + // exist, so every object meets it and there is nothing left to assert + if (!bound_is_representable( + schema_context.schema.at(dynamic_context.keyword))) { return {}; } @@ -272,9 +301,8 @@ auto compiler_draft4_validation_minproperties( const Context &context, const SchemaContext &schema_context, const DynamicContext &dynamic_context, const Instructions &) -> Instructions { - if (!schema_context.schema.at(dynamic_context.keyword).is_integral() || - (!integral_reals_are_integers(schema_context.vocabularies) && - !schema_context.schema.at(dynamic_context.keyword).is_integer())) { + if (!is_dialect_integer(schema_context.schema.at(dynamic_context.keyword), + schema_context.vocabularies)) { throw sourcemeta::blaze::CompilerError( schema_context.base, absolute_schema_location(context, schema_context), EXPECTED_INTEGER); @@ -292,11 +320,25 @@ auto compiler_draft4_validation_minproperties( return {}; } + // A bound too large to hold is beyond the size of anything that can + // exist, so no object can meet it, and nothing else is affected. A property + // name is always a string, which no object bound applies to + if (!bound_is_representable( + schema_context.schema.at(dynamic_context.keyword))) { + if (schema_context.is_property_name) { + return {}; + } + + ValueTypes types; + types.set(std::to_underlying(sourcemeta::core::JSON::Type::Object)); + return {make(sourcemeta::blaze::InstructionIndex::AssertionNotTypeStrictAny, + context, schema_context, dynamic_context, types)}; + } + // We'll handle it at the type level as an optimization if (context.mode == Mode::FastValidation && - schema_context.schema.defines("type") && - schema_context.schema.at("type").is_string() && - schema_context.schema.at("type").to_string() == "object") { + assume_type(schema_context.vocabularies, schema_context.schema, + "object")) { return {}; } diff --git a/vendor/blaze/src/compiler/default_compiler_draft6.h b/vendor/blaze/src/compiler/default_compiler_draft6.h index 5b1a1e7d..d4482eef 100644 --- a/vendor/blaze/src/compiler/default_compiler_draft6.h +++ b/vendor/blaze/src/compiler/default_compiler_draft6.h @@ -151,20 +151,14 @@ auto compiler_draft6_validation_type(const Context &context, // A preceding array loop at this location already rejects a non-array, // so the type assertion is redundant. The array size bounds are only - // redundant when there are none to enforce, as the loops other than the - // sized one do not check the item count. Only a loop that truly rejects - // a non-array qualifies: some item loops instead pass a non-array - // untouched, so they are deliberately excluded below + // redundant when there are none to enforce, as the loop may be + // carrying bounds fused from a different schema. Only a loop that + // truly rejects a non-array qualifies: every other item loop holds + // vacuously on a non-array, as `items` asserts nothing on one if (context.mode == Mode::FastValidation && minimum == 0 && !maximum.has_value() && !current.empty() && - (current.back().type == - sourcemeta::blaze::InstructionIndex:: - LoopItemsPropertiesExactlyTypeStrictHash || - current.back().type == - sourcemeta::blaze::InstructionIndex:: - LoopItemsPropertiesExactlyTypeStrictHash3 || - current.back().type == sourcemeta::blaze::InstructionIndex:: - LoopItemsIntegerBoundedSized) && + current.back().type == sourcemeta::blaze::InstructionIndex:: + LoopItemsIntegerBoundedSized && current.back().relative_instance_location == to_pointer(dynamic_context.base_instance_location)) { return {}; diff --git a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h index 4dd7271b..acdc3ea9 100644 --- a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h +++ b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler.h @@ -6,7 +6,6 @@ #endif #include -#include #include @@ -21,6 +20,7 @@ #include // std::numeric_limits #include // std::map #include // std::optional, std::nullopt +#include // std::set #include // std::string #include // std::string_view #include // std::tuple @@ -34,6 +34,23 @@ namespace sourcemeta::blaze { +/// @ingroup compiler +struct SchemaUnevaluatedEntry { + /// The absolute pointers of the static keyword dependencies + std::set static_dependencies; + /// The absolute pointers of the dynamic keyword dependencies, along with the + /// references that lead to them + std::set dynamic_dependencies; + /// Whether the entry cannot be fully resolved, which means + /// there might be unknown dynamic dependencies + bool unresolved{false}; +}; + +/// @ingroup compiler +/// The flattened set of unevaluated cases in the schema by absolute URI +using SchemaUnevaluatedEntries = + std::map; + /// @ingroup compiler /// The schema compiler context is the current subschema information you have at /// your disposal to implement a keyword diff --git a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h b/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h deleted file mode 100644 index daab6296..00000000 --- a/vendor/blaze/src/compiler/include/sourcemeta/blaze/compiler_unevaluated.h +++ /dev/null @@ -1,50 +0,0 @@ -#ifndef SOURCEMETA_BLAZE_COMPILER_UNEVALUATED_H -#define SOURCEMETA_BLAZE_COMPILER_UNEVALUATED_H - -#ifndef SOURCEMETA_BLAZE_COMPILER_EXPORT -#include -#endif - -#include -#include -#include - -#include // std::map -#include // std::set - -// TODO: Eventually this file should dissapear and move this analysis as part of -// framing - -namespace sourcemeta::blaze { - -/// @ingroup compiler -struct SchemaUnevaluatedEntry { - /// The absolute pointers of the static keyword dependencies - std::set static_dependencies; - /// The absolute pointers of the dynamic keyword dependencies, along with the - /// references that lead to them - std::set dynamic_dependencies; - /// Whether the entry cannot be fully resolved, which means - /// there might be unknown dynamic dependencies - bool unresolved{false}; -}; - -/// @ingroup compiler -/// The flattened set of unevaluated cases in the schema by absolute URI -using SchemaUnevaluatedEntries = - std::map; - -/// @ingroup compiler -/// -/// This function performs a static analysis pass on `unevaluatedProperties` and -/// `unevaluatedItems` occurences throughout the entire schema (if any). -auto SOURCEMETA_BLAZE_COMPILER_EXPORT -unevaluated(const sourcemeta::core::JSON &schema, - const sourcemeta::core::SchemaFrame &frame, - const sourcemeta::core::SchemaWalker &walker, - const sourcemeta::core::SchemaResolver &resolver) - -> SchemaUnevaluatedEntries; - -} // namespace sourcemeta::blaze - -#endif diff --git a/vendor/blaze/src/compiler/postprocess.h b/vendor/blaze/src/compiler/postprocess.h index b5a1e018..58b8cab0 100644 --- a/vendor/blaze/src/compiler/postprocess.h +++ b/vendor/blaze/src/compiler/postprocess.h @@ -207,7 +207,9 @@ inline auto fuse_numeric_bounds(Instructions &instructions, for (std::size_t type_index = 0; type_index < instructions.size(); ++type_index) { const auto &type_instruction{instructions[type_index]}; - if (!is_numeric_integer_type_check(type_instruction)) { + const auto anchors_on_type{is_numeric_integer_type_check(type_instruction)}; + if (!anchors_on_type && + !is_numeric_integral_multiple_check(type_instruction)) { continue; } @@ -220,6 +222,7 @@ inline auto fuse_numeric_bounds(Instructions &instructions, std::vector bound_indices; std::optional minimum; std::optional maximum; + std::optional minimum_index; for (std::size_t index = 0; index < instructions.size(); ++index) { const auto &candidate{instructions[index]}; @@ -232,10 +235,15 @@ inline auto fuse_numeric_bounds(Instructions &instructions, continue; } + const auto previous_minimum{minimum}; if (!merge_integer_bound(candidate, minimum, maximum)) { continue; } + if (minimum != previous_minimum) { + minimum_index = index; + } + bound_indices.push_back(index); } @@ -249,7 +257,16 @@ inline auto fuse_numeric_bounds(Instructions &instructions, InstructionIndex fused_type; Value fused_value; - if (minimum.has_value() && maximum.has_value()) { + if (!anchors_on_type) { + // Without a type check to absorb there is no lower-bound-only form to + // fall back on, as the instruction has to carry both ends of the range + if (!minimum.has_value() || !maximum.has_value()) { + continue; + } + + fused_type = InstructionIndex::AssertionNumberIntegerBounded; + fused_value = ValueIntegerBounds{minimum.value(), maximum.value()}; + } else if (minimum.has_value() && maximum.has_value()) { fused_type = is_strict ? InstructionIndex::AssertionTypeIntegerBoundedStrict : InstructionIndex::AssertionTypeIntegerBounded; @@ -265,11 +282,14 @@ inline auto fuse_numeric_bounds(Instructions &instructions, const auto first_index{ std::min(type_index, *std::ranges::min_element(bound_indices))}; + const auto &minimum_metadata{ + extra[instructions[minimum_index.value()].extra_index]}; const auto extra_index{extra.size()}; - extra.push_back({.relative_schema_location = parent->first, - .keyword_location = parent->second, - .schema_resource = type_metadata.schema_resource, - .vocabulary = type_metadata.vocabulary}); + extra.push_back( + {.relative_schema_location = minimum_metadata.relative_schema_location, + .keyword_location = minimum_metadata.keyword_location, + .schema_resource = type_metadata.schema_resource, + .vocabulary = type_metadata.vocabulary}); Instructions result; result.reserve(instructions.size() - bound_indices.size()); diff --git a/vendor/blaze/src/compiler/schema_helpers.h b/vendor/blaze/src/compiler/schema_helpers.h index ba69c4fb..4b3dff55 100644 --- a/vendor/blaze/src/compiler/schema_helpers.h +++ b/vendor/blaze/src/compiler/schema_helpers.h @@ -28,6 +28,7 @@ inline auto parse_schema_type_string(const sourcemeta::core::JSON::String &type, } else if (type == "number") { result.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); result.set(std::to_underlying(sourcemeta::core::JSON::Type::Real)); + result.set(std::to_underlying(sourcemeta::core::JSON::Type::Decimal)); } else if (type == "integer") { result.set(std::to_underlying(sourcemeta::core::JSON::Type::Integer)); } else if (type == "string") { diff --git a/vendor/blaze/src/compiler/unevaluated.cc b/vendor/blaze/src/compiler/unevaluated.cc deleted file mode 100644 index 77c418d1..00000000 --- a/vendor/blaze/src/compiler/unevaluated.cc +++ /dev/null @@ -1,311 +0,0 @@ -#include -#include - -#include "compile_helpers.h" - -#include // std::set -#include // std::pair - -namespace { -using namespace sourcemeta::core; -using namespace sourcemeta::blaze; -using Known = SchemaVocabularies::Known; - -// NOLINTBEGIN(cert-err58-cpp,bugprone-throwing-static-initialization) -const std::string UNEVALUATED_PROPERTIES{"unevaluatedProperties"}; -const std::string UNEVALUATED_ITEMS{"unevaluatedItems"}; -// NOLINTEND(cert-err58-cpp,bugprone-throwing-static-initialization) - -// Whether a location is already being visited has to survive the nested calls -// that walk through it, and stop applying the moment they unwind -class PathGuard { -public: - PathGuard(std::set> &visited, - const WeakPointer &pointer, const bool is_static) - : visited_{visited}, entry_{pointer, is_static}, - first_visit_{visited.insert(this->entry_).second} {} - - ~PathGuard() { - if (this->first_visit_) { - this->visited_.erase(this->entry_); - } - } - - [[nodiscard]] auto first_visit() const -> bool { return this->first_visit_; } - - PathGuard(const PathGuard &) = delete; - auto operator=(const PathGuard &) -> PathGuard & = delete; - PathGuard(PathGuard &&) = delete; - auto operator=(PathGuard &&) -> PathGuard & = delete; - -private: - std::set> &visited_; - const std::pair entry_; - const bool first_visit_; -}; - -auto find_adjacent_dependencies( - const JSON::String ¤t, const JSON &schema, const SchemaFrame &frame, - const SchemaWalker &walker, const SchemaResolver &resolver, - const std::set &keywords, const SchemaFrame::Location &root, - const SchemaFrame::Location &entry, const bool is_static, - std::set> &visited, - sourcemeta::blaze::SchemaUnevaluatedEntry &result) -> void { - // A schema may reference itself, directly or through a chain of in-place - // applicators. Following such a cycle forever exhausts the stack, so we cut - // it as soon as we meet a location already on the path we came in through. - // The mark only lives as long as that path, as two references to a common - // target are not a cycle, and each of them still has to be accounted for - const PathGuard guard{visited, entry.pointer, is_static}; - if (!guard.first_visit()) { - return; - } - - const auto &subschema{get(schema, entry.pointer)}; - if (!subschema.is_object()) { - return; - } - - const auto &subschema_vocabularies{frame.vocabularies(entry, resolver)}; - - for (const auto &property : subschema.as_object()) { - if (property.first == current && entry.pointer == root.pointer) { - continue; - } - if (keywords.contains(property.first)) { - // In 2019-09, `additionalItems` takes no effect without `items` - if (subschema_vocabularies.contains( - Known::JSON_SCHEMA_2019_09_APPLICATOR) && - property.first == "additionalItems" && !subschema.defines("items")) { - continue; - } - - auto pointer{entry.pointer.concat(make_weak_pointer(property.first))}; - if (is_static) { - result.static_dependencies.emplace(std::move(pointer)); - } else { - result.dynamic_dependencies.emplace(std::move(pointer)); - } - - continue; - } - - switch (walker(property.first, subschema_vocabularies).type) { - // References - case SchemaKeywordType::Reference: { - const auto reference{ - frame.dereference(entry, make_weak_pointer(property.first))}; - if (reference.first == SchemaReferenceType::Static && - reference.second.has_value()) { - // Recurse into a dedicated entry so that whether this reference's - // target contributes any dynamic dependency can be read directly, - // rather than inferred from whether it grew the shared deduplicated - // set, which misses every reference after the first to a common - // target - SchemaUnevaluatedEntry nested; - find_adjacent_dependencies( - current, schema, frame, walker, resolver, keywords, root, - reference.second.value().get(), is_static, visited, nested); - - // Whatever the target contributes gets recorded at the location of - // the target itself, which tells the applicators this reference sits - // under nothing about it. Record the reference as a dependency of - // its own too, so that they can still tell that reaching through it - // leads to one, and therefore that they cannot short-circuit. Only - // the dynamic dependencies are consulted that way, whereas the - // static ones name the keyword locations that evaluate, which a - // reference is not one of - if (!is_static && !nested.dynamic_dependencies.empty()) { - result.dynamic_dependencies.emplace( - entry.pointer.concat(make_weak_pointer(property.first))); - } - - result.unresolved = result.unresolved || nested.unresolved; - result.static_dependencies.merge(nested.static_dependencies); - result.dynamic_dependencies.merge(nested.dynamic_dependencies); - } else if (reference.first == SchemaReferenceType::Dynamic) { - result.unresolved = true; - } - - break; - } - - // Static - case SchemaKeywordType::ApplicatorElementsInPlace: - // TODO(C++23): Use std::views::enumerate when available in libc++ - for (std::size_t index = 0; index < property.second.size(); index++) { - find_adjacent_dependencies( - current, schema, frame, walker, resolver, keywords, root, - frame.traverse(entry, make_weak_pointer(property.first, index)), - is_static, visited, result); - } - - break; - - // Dynamic - case SchemaKeywordType::ApplicatorElementsInPlaceSome: - if (property.second.is_array()) { - for (std::size_t index = 0; index < property.second.size(); index++) { - find_adjacent_dependencies( - current, schema, frame, walker, resolver, keywords, root, - frame.traverse(entry, make_weak_pointer(property.first, index)), - false, visited, result); - } - } - - break; - case SchemaKeywordType::ApplicatorValueTraverseAnyItem: - [[fallthrough]]; - case SchemaKeywordType::ApplicatorValueTraverseParent: - [[fallthrough]]; - case SchemaKeywordType::ApplicatorValueInPlaceMaybe: - if ((property.second.is_object() || property.second.is_boolean())) { - find_adjacent_dependencies( - current, schema, frame, walker, resolver, keywords, root, - frame.traverse(entry, make_weak_pointer(property.first)), false, - visited, result); - } - - break; - case SchemaKeywordType::ApplicatorValueOrElementsInPlace: - if (property.second.is_array()) { - for (std::size_t index = 0; index < property.second.size(); index++) { - find_adjacent_dependencies( - current, schema, frame, walker, resolver, keywords, root, - frame.traverse(entry, make_weak_pointer(property.first, index)), - false, visited, result); - } - } else if ((property.second.is_object() || - property.second.is_boolean())) { - find_adjacent_dependencies( - current, schema, frame, walker, resolver, keywords, root, - frame.traverse(entry, make_weak_pointer(property.first)), false, - visited, result); - } - - break; - case SchemaKeywordType::ApplicatorMembersInPlaceSome: - if (property.second.is_object()) { - for (const auto &pair : property.second.as_object()) { - find_adjacent_dependencies( - current, schema, frame, walker, resolver, keywords, root, - frame.traverse(entry, - make_weak_pointer(property.first, pair.first)), - false, visited, result); - } - } - - break; - - // Anything else does not contribute to the dependency list - default: - break; - } - } -} - -auto register_under_all_bases(SchemaUnevaluatedEntries &result, - const SchemaFrame &frame, - const SchemaFrame::Location &location, - const JSON::String &keyword, - const SchemaUnevaluatedEntry &value) -> void { - result.emplace(frame.uri(location, make_weak_pointer(keyword)), value); - frame.for_each_location([&](const SchemaReferenceType, const std::string_view, - const SchemaFrame::Location &alternate) -> void { - if (alternate.pointer != location.pointer || - alternate.base == location.base) { - return; - } - - if (alternate.type != SchemaFrame::LocationType::Subschema && - alternate.type != SchemaFrame::LocationType::Resource && - alternate.type != SchemaFrame::LocationType::Anchor) { - return; - } - - result.emplace(frame.uri(alternate, make_weak_pointer(keyword)), value); - }); -} - -} // namespace - -namespace sourcemeta::blaze { - -// TODO: Refactor this entire function using `SchemaFrame`'s new `Instances` -// mode. We can loop over every subschema that defines `unevaluatedProperties` -// or `unevaluatedItems`, find all other subschemas with the same unresolved -// instance location (static dependency) or conditional equivalent unresolved -// instance location (dynamic dependency) and see if those ones define any of -// the dependent keywords. -auto unevaluated(const JSON &schema, const SchemaFrame &frame, - const SchemaWalker &walker, const SchemaResolver &resolver) - -> SchemaUnevaluatedEntries { - SchemaUnevaluatedEntries result; - - frame.for_each_subschema([&](const SchemaFrame::Location &location) -> void { - const auto &subschema{get(schema, location.pointer)}; - assert((subschema.is_object() || subschema.is_boolean())); - if (!subschema.is_object()) { - return; - } - - const bool has_unevaluated_properties{ - subschema.defines("unevaluatedProperties")}; - const bool has_unevaluated_items{subschema.defines("unevaluatedItems")}; - if (!has_unevaluated_properties && !has_unevaluated_items) { - return; - } - - const auto &subschema_vocabularies{frame.vocabularies(location, resolver)}; - - // The same pointer may be reachable through alternate identifiers whose - // dynamic anchors carry a different base, so we register the entry under - // each of them - if (has_unevaluated_properties) { - if ((subschema_vocabularies.contains( - Known::JSON_SCHEMA_2020_12_UNEVALUATED) && - subschema_vocabularies.contains( - Known::JSON_SCHEMA_2020_12_APPLICATOR)) || - subschema_vocabularies.contains( - Known::JSON_SCHEMA_2019_09_APPLICATOR)) { - std::set> visited; - SchemaUnevaluatedEntry unevaluated; - find_adjacent_dependencies( - "unevaluatedProperties", schema, frame, walker, resolver, - {"properties", "patternProperties", "additionalProperties", - "unevaluatedProperties"}, - location, location, true, visited, unevaluated); - register_under_all_bases(result, frame, location, - UNEVALUATED_PROPERTIES, unevaluated); - } - } - - if (has_unevaluated_items) { - std::set> visited; - SchemaUnevaluatedEntry unevaluated; - if (subschema_vocabularies.contains( - Known::JSON_SCHEMA_2020_12_UNEVALUATED) && - subschema_vocabularies.contains( - Known::JSON_SCHEMA_2020_12_APPLICATOR)) { - find_adjacent_dependencies( - "unevaluatedItems", schema, frame, walker, resolver, - {"prefixItems", "items", "contains", "unevaluatedItems"}, location, - location, true, visited, unevaluated); - register_under_all_bases(result, frame, location, UNEVALUATED_ITEMS, - unevaluated); - } else if (subschema_vocabularies.contains( - Known::JSON_SCHEMA_2019_09_APPLICATOR)) { - find_adjacent_dependencies( - "unevaluatedItems", schema, frame, walker, resolver, - {"items", "additionalItems", "unevaluatedItems"}, location, - location, true, visited, unevaluated); - register_under_all_bases(result, frame, location, UNEVALUATED_ITEMS, - unevaluated); - } - } - }); - - return result; -} - -} // namespace sourcemeta::blaze diff --git a/vendor/blaze/src/compiler/unevaluated.h b/vendor/blaze/src/compiler/unevaluated.h new file mode 100644 index 00000000..2eecf332 --- /dev/null +++ b/vendor/blaze/src/compiler/unevaluated.h @@ -0,0 +1,351 @@ +#ifndef SOURCEMETA_BLAZE_COMPILER_UNEVALUATED_H_ +#define SOURCEMETA_BLAZE_COMPILER_UNEVALUATED_H_ + +#include + +#include +#include + +#include // assert +#include // std::size_t +#include // std::set +#include // std::string_view +#include // std::move, std::pair + +#include "compile_helpers.h" + +namespace sourcemeta::blaze { + +// NOLINTBEGIN(cert-err58-cpp,bugprone-throwing-static-initialization) +static const sourcemeta::core::JSON::String UNEVALUATED_PROPERTIES{ + "unevaluatedProperties"}; +static const sourcemeta::core::JSON::String UNEVALUATED_ITEMS{ + "unevaluatedItems"}; +// NOLINTEND(cert-err58-cpp,bugprone-throwing-static-initialization) + +// Whether a location is already being visited has to survive the nested calls +// that walk through it, and stop applying the moment they unwind +class PathGuard { +public: + PathGuard(std::set> &visited, + const sourcemeta::core::WeakPointer &pointer, const bool is_static) + : visited_{visited}, entry_{pointer, is_static}, + first_visit_{visited.insert(this->entry_).second} {} + + ~PathGuard() { + if (this->first_visit_) { + this->visited_.erase(this->entry_); + } + } + + [[nodiscard]] auto first_visit() const -> bool { return this->first_visit_; } + + PathGuard(const PathGuard &) = delete; + auto operator=(const PathGuard &) -> PathGuard & = delete; + PathGuard(PathGuard &&) = delete; + auto operator=(PathGuard &&) -> PathGuard & = delete; + +private: + std::set> &visited_; + const std::pair entry_; + const bool first_visit_; +}; + +inline auto find_adjacent_dependencies( + const sourcemeta::core::JSON::String ¤t, + const sourcemeta::core::JSON &schema, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaWalker &walker, + const sourcemeta::core::SchemaResolver &resolver, + const std::set &keywords, + const sourcemeta::core::SchemaFrame::Location &root, + const sourcemeta::core::SchemaFrame::Location &entry, const bool is_static, + std::set> &visited, + SchemaUnevaluatedEntry &result) -> void { + // A schema may reference itself, directly or through a chain of in-place + // applicators. Following such a cycle forever exhausts the stack, so we cut + // it as soon as we meet a location already on the path we came in through. + // The mark only lives as long as that path, as two references to a common + // target are not a cycle, and each of them still has to be accounted for + const PathGuard guard{visited, entry.pointer, is_static}; + if (!guard.first_visit()) { + return; + } + + const auto &subschema{sourcemeta::core::get(schema, entry.pointer)}; + if (!subschema.is_object()) { + return; + } + + const auto &subschema_vocabularies{frame.vocabularies(entry, resolver)}; + + for (const auto &property : subschema.as_object()) { + if (property.first == current && entry.pointer == root.pointer) { + continue; + } + if (keywords.contains(property.first)) { + // In 2019-09, `additionalItems` takes no effect without `items` + if (subschema_vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known:: + JSON_SCHEMA_2019_09_APPLICATOR) && + property.first == "additionalItems" && !subschema.defines("items")) { + continue; + } + + auto pointer{entry.pointer.concat(make_weak_pointer(property.first))}; + if (is_static) { + result.static_dependencies.emplace(std::move(pointer)); + } else { + result.dynamic_dependencies.emplace(std::move(pointer)); + } + + continue; + } + + switch (walker(property.first, subschema_vocabularies).type) { + // References + case sourcemeta::core::SchemaKeywordType::Reference: { + const auto reference{ + frame.dereference(entry, make_weak_pointer(property.first))}; + if (reference.first == sourcemeta::core::SchemaReferenceType::Static && + reference.second.has_value()) { + // Recurse into a dedicated entry so that whether this reference's + // target contributes any dynamic dependency can be read directly, + // rather than inferred from whether it grew the shared deduplicated + // set, which misses every reference after the first to a common + // target + SchemaUnevaluatedEntry nested; + find_adjacent_dependencies( + current, schema, frame, walker, resolver, keywords, root, + reference.second.value().get(), is_static, visited, nested); + + // Whatever the target contributes gets recorded at the location of + // the target itself, which tells the applicators this reference sits + // under nothing about it. Record the reference as a dependency of + // its own too, so that they can still tell that reaching through it + // leads to one, and therefore that they cannot short-circuit. Only + // the dynamic dependencies are consulted that way, whereas the + // static ones name the keyword locations that evaluate, which a + // reference is not one of + if (!is_static && !nested.dynamic_dependencies.empty()) { + result.dynamic_dependencies.emplace( + entry.pointer.concat(make_weak_pointer(property.first))); + } + + result.unresolved = result.unresolved || nested.unresolved; + result.static_dependencies.merge(nested.static_dependencies); + result.dynamic_dependencies.merge(nested.dynamic_dependencies); + } else if (reference.first == + sourcemeta::core::SchemaReferenceType::Dynamic) { + result.unresolved = true; + } + + break; + } + + // Static + case sourcemeta::core::SchemaKeywordType::ApplicatorElementsInPlace: + // TODO(C++23): Use std::views::enumerate when available in libc++ + for (std::size_t index = 0; index < property.second.size(); index++) { + find_adjacent_dependencies( + current, schema, frame, walker, resolver, keywords, root, + frame.traverse(entry, make_weak_pointer(property.first, index)) + .value() + .get(), + is_static, visited, result); + } + + break; + + // Dynamic + case sourcemeta::core::SchemaKeywordType::ApplicatorElementsInPlaceSome: + if (property.second.is_array()) { + for (std::size_t index = 0; index < property.second.size(); index++) { + find_adjacent_dependencies( + current, schema, frame, walker, resolver, keywords, root, + frame.traverse(entry, make_weak_pointer(property.first, index)) + .value() + .get(), + false, visited, result); + } + } + + break; + case sourcemeta::core::SchemaKeywordType::ApplicatorValueTraverseAnyItem: + [[fallthrough]]; + case sourcemeta::core::SchemaKeywordType::ApplicatorValueTraverseParent: + [[fallthrough]]; + case sourcemeta::core::SchemaKeywordType::ApplicatorValueInPlaceMaybe: + if ((property.second.is_object() || property.second.is_boolean())) { + find_adjacent_dependencies( + current, schema, frame, walker, resolver, keywords, root, + frame.traverse(entry, make_weak_pointer(property.first)) + .value() + .get(), + false, visited, result); + } + + break; + case sourcemeta::core::SchemaKeywordType:: + ApplicatorValueOrElementsInPlace: + if (property.second.is_array()) { + for (std::size_t index = 0; index < property.second.size(); index++) { + find_adjacent_dependencies( + current, schema, frame, walker, resolver, keywords, root, + frame.traverse(entry, make_weak_pointer(property.first, index)) + .value() + .get(), + false, visited, result); + } + } else if ((property.second.is_object() || + property.second.is_boolean())) { + find_adjacent_dependencies( + current, schema, frame, walker, resolver, keywords, root, + frame.traverse(entry, make_weak_pointer(property.first)) + .value() + .get(), + false, visited, result); + } + + break; + case sourcemeta::core::SchemaKeywordType::ApplicatorMembersInPlaceSome: + if (property.second.is_object()) { + for (const auto &pair : property.second.as_object()) { + find_adjacent_dependencies( + current, schema, frame, walker, resolver, keywords, root, + frame + .traverse(entry, + make_weak_pointer(property.first, pair.first)) + .value() + .get(), + false, visited, result); + } + } + + break; + + // Anything else does not contribute to the dependency list + default: + break; + } + } +} + +inline auto register_under_all_bases( + SchemaUnevaluatedEntries &result, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaFrame::Location &location, + const sourcemeta::core::JSON::String &keyword, + const SchemaUnevaluatedEntry &value) -> void { + result.emplace(frame.uri(location, make_weak_pointer(keyword)), value); + frame.for_each_location( + [&](const sourcemeta::core::SchemaReferenceType, const std::string_view, + const sourcemeta::core::SchemaFrame::Location &alternate) -> void { + if (alternate.pointer != location.pointer || + alternate.base == location.base) { + return; + } + + if (alternate.type != + sourcemeta::core::SchemaFrame::LocationType::Subschema && + alternate.type != + sourcemeta::core::SchemaFrame::LocationType::Resource && + alternate.type != + sourcemeta::core::SchemaFrame::LocationType::Anchor) { + return; + } + + result.emplace(frame.uri(alternate, make_weak_pointer(keyword)), value); + }); +} + +// TODO: Refactor this entire function using `SchemaFrame`'s new `Instances` +// mode. We can loop over every subschema that defines `unevaluatedProperties` +// or `unevaluatedItems`, find all other subschemas with the same unresolved +// instance location (static dependency) or conditional equivalent unresolved +// instance location (dynamic dependency) and see if those ones define any of +// the dependent keywords. +inline auto unevaluated(const sourcemeta::core::JSON &schema, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaWalker &walker, + const sourcemeta::core::SchemaResolver &resolver) + -> SchemaUnevaluatedEntries { + SchemaUnevaluatedEntries result; + + frame.for_each_subschema( + [&](const sourcemeta::core::SchemaFrame::Location &location) -> void { + const auto &subschema{sourcemeta::core::get(schema, location.pointer)}; + assert((subschema.is_object() || subschema.is_boolean())); + if (!subschema.is_object()) { + return; + } + + const bool has_unevaluated_properties{ + subschema.defines("unevaluatedProperties")}; + const bool has_unevaluated_items{subschema.defines("unevaluatedItems")}; + if (!has_unevaluated_properties && !has_unevaluated_items) { + return; + } + + const auto &subschema_vocabularies{ + frame.vocabularies(location, resolver)}; + + // The same pointer may be reachable through alternate identifiers whose + // dynamic anchors carry a different base, so we register the entry + // under each of them + if (has_unevaluated_properties) { + if ((subschema_vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known:: + JSON_SCHEMA_2020_12_UNEVALUATED) && + subschema_vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known:: + JSON_SCHEMA_2020_12_APPLICATOR)) || + subschema_vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known:: + JSON_SCHEMA_2019_09_APPLICATOR)) { + std::set> visited; + SchemaUnevaluatedEntry unevaluated; + find_adjacent_dependencies( + "unevaluatedProperties", schema, frame, walker, resolver, + {"properties", "patternProperties", "additionalProperties", + "unevaluatedProperties"}, + location, location, true, visited, unevaluated); + register_under_all_bases(result, frame, location, + UNEVALUATED_PROPERTIES, unevaluated); + } + } + + if (has_unevaluated_items) { + std::set> visited; + SchemaUnevaluatedEntry unevaluated; + if (subschema_vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known:: + JSON_SCHEMA_2020_12_UNEVALUATED) && + subschema_vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known:: + JSON_SCHEMA_2020_12_APPLICATOR)) { + find_adjacent_dependencies( + "unevaluatedItems", schema, frame, walker, resolver, + {"prefixItems", "items", "contains", "unevaluatedItems"}, + location, location, true, visited, unevaluated); + register_under_all_bases(result, frame, location, UNEVALUATED_ITEMS, + unevaluated); + } else if (subschema_vocabularies.contains( + sourcemeta::core::SchemaVocabularies::Known:: + JSON_SCHEMA_2019_09_APPLICATOR)) { + find_adjacent_dependencies( + "unevaluatedItems", schema, frame, walker, resolver, + {"items", "additionalItems", "unevaluatedItems"}, location, + location, true, visited, unevaluated); + register_under_all_bases(result, frame, location, UNEVALUATED_ITEMS, + unevaluated); + } + } + }); + + return result; +} + +} // namespace sourcemeta::blaze + +#endif diff --git a/vendor/blaze/src/convert/CMakeLists.txt b/vendor/blaze/src/convert/CMakeLists.txt index dd2b242c..ffff4ef9 100644 --- a/vendor/blaze/src/convert/CMakeLists.txt +++ b/vendor/blaze/src/convert/CMakeLists.txt @@ -8,22 +8,29 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT blaze NAME convert # Rules rules/definitions_to_defs.h rules/dependencies_to_dependent.h + rules/dialect_override_becomes_dollar_schema.h rules/draft_official_dialect_with_https.h rules/draft_official_dialect_without_empty_fragment.h rules/empty_object_as_true.h rules/enum_to_const.h rules/modern_official_dialect_with_empty_fragment.h + rules/modern_official_dialect_with_http.h + rules/openapi_official_dialect_with_date.h + rules/openapi_official_dialect_with_empty_fragment.h rules/prefix_promoted_2020_12_keywords.h rules/prefix_promoted_draft_2019_09_keywords.h rules/prefix_promoted_draft_4_keywords.h rules/prefix_promoted_draft_6_keywords.h rules/prefix_promoted_draft_7_keywords.h + rules/prefix_promoted_openapi_3_1_keywords.h + rules/shadow_stray_dialect_declaration.h rules/upgrade_2019_09_to_2020_12.h - rules/upgrade_dialect_override_cleanup.h + rules/upgrade_2020_12_to_openapi_3_1.h rules/upgrade_draft_3_to_draft_4.h rules/upgrade_draft_4_to_draft_6.h rules/upgrade_draft_6_to_draft_7.h - rules/upgrade_draft_7_to_draft_2019_09.h) + rules/upgrade_draft_7_to_draft_2019_09.h + rules/upgrade_openapi_3_1_to_openapi_3_2.h) if(BLAZE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT blaze NAME convert) diff --git a/vendor/blaze/src/convert/convert.cc b/vendor/blaze/src/convert/convert.cc index 97232aac..7d4c353b 100644 --- a/vendor/blaze/src/convert/convert.cc +++ b/vendor/blaze/src/convert/convert.cc @@ -5,12 +5,12 @@ #include #include -#include // std::ranges::any_of +#include // std::ranges::any_of, std::ranges::find #include // std::array -#include // assert -#include // std::derived_from -#include // std::size_t -#include // std::uint64_t +#include +#include // std::derived_from +#include // std::size_t +#include // std::uint64_t #include // std::cref, std::function, std::hash, std::reference_wrapper #include // std::map #include // std::make_unique, std::unique_ptr @@ -37,10 +37,10 @@ namespace { using Rule = std::tuple, bool>; /// Construct a rule entry for the given rule type -template T> -[[nodiscard]] auto make_rule() -> Rule { - return {std::make_unique(), - std::is_same_v}; +template T, typename... Args> +[[nodiscard]] auto make_rule(Args &&...arguments) -> Rule { + return {std::make_unique(std::forward(arguments)...), + std::is_same_v}; } /// A reference that lands on something other than a schema is not a reference @@ -62,8 +62,8 @@ auto assert_schema_references(const core::SchemaFrame &frame) -> void { /// Conversion renames keywords, while a meta-schema names those same keywords /// as ordinary data that nothing renames alongside them. Until the two can be /// told apart, a document that describes itself or that carries the -/// meta-schema something in it declares is refused. A dialect the ladder does -/// not name is refused too, as there are no rules for moving a schema off it +/// meta-schema something in it declares is refused, and so is any resource +/// read as a dialect the ladder does not name auto assert_convertible_dialects(const core::JSON &schema, const core::SchemaFrame &frame, const std::string_view default_id) -> void { @@ -75,249 +75,449 @@ auto assert_convertible_dialects(const core::JSON &schema, core::EMPTY_POINTER}; } + // A bundled meta-schema is what the resources naming it are refused for, so + // it is looked for across the whole document before any of them is reported. + // Otherwise whichever the frame happened to reach first would decide, and a + // document carrying its own meta-schema would be reported against the + // resource using it rather than against the meta-schema it cannot move frame.for_each_subschema( [&schema, &frame](const core::SchemaFrame::Location &location) -> void { auto pointer{core::to_pointer(location.pointer)}; - if (is_metaschema_target(core::get(schema, pointer), frame, - location.pointer)) { - throw ConvertUnsupportedMetaschemaError{location.dialect, - std::move(pointer)}; + const auto &subschema{core::get(schema, pointer)}; + if (!is_metaschema_target(subschema, frame, location.pointer)) { + return; + } + + // The meta-schema that cannot be moved is what the error names. The + // dialect that meta-schema is itself written in is an official one + // the conversion supports perfectly well, so naming that instead + // would report the evidence rather than the reason + const auto *identifier{subschema.try_at( + core::schema_identifier_keyword(location.base_dialect))}; + throw ConvertUnsupportedMetaschemaError{ + identifier != nullptr && identifier->is_string() + ? std::string_view{identifier->to_string()} + : location.dialect, + std::move(pointer)}; + }); + + // A dialect the ladder does not name has no rules for moving a schema off + // it, and this conversion does not go near one. Carrying it along to the + // targets it happens to outrank would convert the document around it and + // leave the author to work out which parts moved, so a custom dialect is + // refused outright whatever the target is + frame.for_each_subschema( + [&schema](const core::SchemaFrame::Location &location) -> void { + auto pointer{core::to_pointer(location.pointer)}; + + // What a subschema says about itself counts even where framing does + // not read it that way. The ladder's own marker is read ahead of + // `$schema`, so a document naming a custom meta-schema and carrying a + // marker beside it frames as whatever the marker says, and asking + // framing alone would let it through to be rewritten and have the + // marker cleaned away underneath it. + // + // Only where a resource begins, though. A `$schema` deeper inside one + // declares nothing, which is what both Draft 7 core 7 and 2019-09 + // core 8.1.1 say, so refusing over it would turn a string the author + // left behind into an unconvertible document + const auto &subschema{core::get(schema, pointer)}; + if (subschema.is_object() && + location.pointer.size() == location.relative_pointer) { + const auto *declared{subschema.try_at("$schema")}; + if (declared != nullptr && declared->is_string() && + !names_ladder_dialect(declared->to_string())) { + throw ConvertUnsupportedDialectError{declared->to_string(), + std::move(pointer)}; + } } - if (!names_ladder_dialect(location.dialect)) { - throw ConvertUnsupportedDialectError{location.dialect, - std::move(pointer)}; + if (names_ladder_dialect(location.dialect)) { + return; } + + throw ConvertUnsupportedDialectError{location.dialect, + std::move(pointer)}; }); } -/// Apply the given rules top-down to every subschema until none of them applies +/// A schema that declares `$vocabulary` is a meta-schema, which is what this +/// conversion asks of one. Extending an official meta-schema means referencing +/// a document that recurses with the keyword of the dialect it was written for, +/// and nothing this conversion renames in the extending schema can carry that +/// recursion to another dialect, so such a meta-schema is refused rather than +/// quietly stripped of the constraints it places on what it describes +auto assert_convertible_metaschema(const core::JSON &schema, + const core::SchemaFrame &frame) -> void { + if (!schema.is_object()) { + return; + } + + // Declaring `$vocabulary` is what makes a schema a meta-schema here, and a + // meta-schema that names an official one anywhere other than in its own + // `$schema` is refused wherever that reference sits. Where the reference + // appears does not soften it: the conversion cannot know whether the author + // meant the schemas it describes to move dialect alongside it, and silently + // bumping the meta-schema while leaving the reference on the old dialect + // would decide that for them + const auto extends_official{frame.any_reference( + [](const core::SchemaReferenceType, const core::WeakPointer &origin, + const core::SchemaFrame::Reference &reference) -> bool { + if (!origin.empty() && origin.back().is_property() && + origin.back().to_property() == "$schema") { + return false; + } + + return names_official_metaschema(reference.destination); + })}; + if (!extends_official) { + return; + } + + // The meta-schema is what cannot be converted, so it is what the error names, + // and it may sit inside the document rather than be the whole of it + frame.for_each_subschema( + [&schema](const core::SchemaFrame::Location &location) -> void { + auto pointer{core::to_pointer(location.pointer)}; + const auto &subschema{core::get(schema, pointer)}; + if (subschema.is_object() && subschema.defines("$vocabulary")) { + throw ConvertUnsupportedMetaschemaError{location.dialect, + std::move(pointer)}; + } + }); +} + +/// Follows every reference whose destination stopped resolving to wherever the +/// pass moved it. A reference breaks only when its destination stops +/// resolving: a target sitting at a different position is not enough, since a +/// resource that moved as a whole keeps resolving the fragments its own +/// identifier is the base of. +template +auto repair_references(sourcemeta::core::JSON &schema, + const std::vector &snapshots, + const std::vector &journal, + const sourcemeta::core::SchemaWalker &walker, + const sourcemeta::core::SchemaResolver &resolver, + const std::string_view default_dialect, + const std::string_view default_id) -> void { + if (snapshots.empty() || journal.empty() || schema.is_boolean()) { + return; + } + + const core::SchemaFrame frame{ + core::SchemaFrame::Mode::References, + schema, + walker, + resolver, + default_dialect, + default_id, + sourcemeta::core::SchemaFrame::IdentifierMode::Fallback}; + + for (const auto &snapshot : snapshots) { + if (frame.traverse(snapshot.destination).has_value()) { + continue; + } + + // The resource the fragment is written relative to can move in the same + // pass as the target does, so where that resource now begins is followed + // too. Slicing the moved target by where it used to begin would count + // tokens of the new position as part of the old prefix + auto target{snapshot.target}; + auto origin{snapshot.origin}; + auto base{snapshot.target.slice(0, snapshot.target_offset)}; + for (const auto &[before, after] : journal) { + target = target.rebase(before, after); + origin = origin.rebase(before, after); + base = base.rebase(before, after); + } + + // A pass is free to drop the subschema a reference was written in, and a + // reference that is no longer in the document has nothing left to point + // anywhere, so there is nothing to repair rather than anything broken + if (core::try_get(schema, origin.initial()) == nullptr) { + continue; + } + + // Nothing the pass recorded accounts for where the target went, so the + // reference cannot be followed + if (target == snapshot.target) { + throw ConvertBrokenReferenceError{snapshot.destination, snapshot.origin}; + } + + const auto relative{target.slice(base.size())}; + const auto fragment{snapshot.fragment == core::to_string(snapshot.target) + ? base.concat(relative) + : relative}; + + core::URI original{snapshot.original}; + // The stringified pointer is literal text, so a token that already reads + // as an escape must be encoded rather than taken as one + original.unescaped_fragment(core::to_string(fragment)); + core::set(schema, origin, core::JSON{original.recompose()}); + } +} + +/// Each pass frames the document once, decides every edit the pass will make +/// from that one frame, applies them deepest position first, and only then +/// repairs the references that the edits moved. Nothing re-frames in the +/// middle of a pass. +/// +/// Applying the deepest position first is what lets a rule stop asking whether +/// the subschemas below it are still waiting to be rewritten: by the time a +/// position is reached, everything under it has already been handled in this +/// same pass. auto apply(const std::vector &rules, sourcemeta::core::JSON &schema, const sourcemeta::core::SchemaWalker &walker, const sourcemeta::core::SchemaResolver &resolver, const std::string_view default_dialect, - const std::string_view default_id) -> void { + const std::string_view default_id, const bool assert_convertible) + -> void { assert(!rules.empty()); - struct ProcessedRuleHasher { - auto operator()(const std::tuple &value) const noexcept - -> std::size_t { - return core::Pointer::Hasher{}(std::get<0>(value)) ^ - (std::hash{}(std::get<1>(value)) << 1) ^ - (std::hash{}(std::get<2>(value).fast_hash()) << 2); - } + struct Scheduled { + Site site; + core::SchemaVocabularies vocabularies; + std::size_t rule; + std::size_t depth; + bool writes_outside; }; - std::unordered_set, - ProcessedRuleHasher> - processed_rules; - - std::optional frame; - - struct PotentiallyBrokenReference { + struct Snapshot { core::Pointer origin; core::JSON::String original; core::JSON::String destination; core::JSON::String fragment; - core::Pointer target_pointer; - std::size_t target_relative_pointer; + core::Pointer target; + std::size_t target_offset; }; - std::vector potentially_broken_references; + // Where a rule moved a position, in absolute terms, so that a reference + // naming anything under it can be followed without asking which rule did it + std::vector journal; + bool asserted{false}; + // A pass that leaves the document as it found it has nothing left to + // contribute, and running another would schedule the very same work and + // decline it again. Ending the loop on that rather than on a count is what + // makes a rule whose condition claims work its transform then declines + // impossible to spin on + core::JSON previous{sourcemeta::core::JSON::make_object()}; + bool first_pass{true}; + + // One pass per rung, plus a pass for what the ladder leaves behind, is the + // most a correct set of rules can need. Reaching this means two passes are + // undoing each other, which the check above cannot see, so it is worth + // saying out loud where the asserts are compiled in + constexpr auto MAXIMUM_PASSES{LADDER_DIALECTS.size() + 2}; + std::size_t passes{0}; + while (true) { - if (!frame.has_value()) { - if (schema.is_boolean()) { - break; - } + if (schema.is_boolean()) { + break; + } - frame.emplace(core::SchemaFrame::Mode::References, schema, walker, - resolver, default_dialect, default_id, - sourcemeta::core::SchemaFrame::IdentifierMode::Fallback); + if (!first_pass && schema == previous) { + break; + } - if (!asserted) { - assert_convertible_dialects(schema, frame.value(), default_id); - assert_schema_references(frame.value()); - asserted = true; - } + previous = schema; + first_pass = false; + + passes += 1; + assert(passes <= MAXIMUM_PASSES); + if (passes > MAXIMUM_PASSES) { + break; } - std::unordered_set visited; - bool applied{false}; - - // Stopping the traversal stands in for the restart that the - // rules request once they mutate the schema - [[maybe_unused]] const auto restarted{frame->any_subschema( - [&](const core::SchemaFrame::Location &location) -> bool { - const auto [visited_iterator, inserted] = - visited.insert(core::to_pointer(location.pointer)); - if (!inserted) { - return false; - } - const auto &entry_pointer{*visited_iterator}; - auto ¤t{core::get(schema, entry_pointer)}; - const auto current_vocabularies{ - frame->vocabularies(location, resolver)}; - - for (const auto &[rule, reframe_after_transform] : rules) { - const auto outcome{rule->condition(current, schema, - current_vocabularies, *frame, - location, walker, resolver)}; - - if (!outcome) { - continue; - } + std::vector scheduled; + std::vector snapshots; + + // Everything the frame has to say is taken down here, while it still + // describes the document in front of it. A `SchemaFrame::Location` hands + // out views into the frame and the document, so reading one after an edit + // is not a stale answer but a dangling one, and the same goes for asking + // the frame anything else. Past this block nothing consults it + { + const core::SchemaFrame frame{ + core::SchemaFrame::Mode::References, + schema, + walker, + resolver, + default_dialect, + default_id, + sourcemeta::core::SchemaFrame::IdentifierMode::Fallback}; + + if (assert_convertible && !asserted) { + assert_convertible_dialects(schema, frame, default_id); + assert_convertible_metaschema(schema, frame); + assert_schema_references(frame); + asserted = true; + } - potentially_broken_references.clear(); - frame->for_each_reference([&](const core::SchemaReferenceType, - const core::WeakPointer &origin, - const core::SchemaFrame::Reference - &reference) -> void { - const auto destination{frame->traverse(reference.destination)}; - if (!destination.has_value() || !reference.fragment.has_value() || - !reference.fragment.value().starts_with('/')) { - return; - } + for (const auto &[rule, writes_outside] : rules) { + rule->begin_pass(); + } - const auto &target{destination.value().get()}; - potentially_broken_references.push_back( - {.origin = core::to_pointer(origin), - .original = core::JSON::String{reference.original}, - .destination = reference.destination, - .fragment = core::JSON::String{reference.fragment.value()}, - .target_pointer = core::to_pointer(target.pointer), - .target_relative_pointer = target.relative_pointer}); - }); - - rule->transform(current); - - applied = true; - - if (reframe_after_transform) { - frame.emplace( - core::SchemaFrame::Mode::References, schema, walker, resolver, - default_dialect, default_id, - sourcemeta::core::SchemaFrame::IdentifierMode::Fallback); - } else if (current.is_boolean()) { - std::tuple mark{ - entry_pointer, rule->name(), current}; - assert(!processed_rules.contains(mark)); - processed_rules.emplace(std::move(mark)); - frame.reset(); - return true; + std::unordered_set visited; + frame.for_each_subschema( + [&](const core::SchemaFrame::Location &location) -> void { + auto pointer{core::to_pointer(location.pointer)}; + if (!visited.insert(pointer).second) { + return; } - const auto new_location{ - frame->traverse(core::to_weak_pointer(entry_pointer))}; - assert(new_location.has_value()); - - // Fix broken references before re-checking the condition, - // as the re-check may mutate rule state that rereference needs - bool references_fixed{false}; - const auto resource_offset{ - new_location.value().get().relative_pointer}; - const auto current_slice{entry_pointer.slice(resource_offset)}; - for (const auto &saved_reference : potentially_broken_references) { - // A reference only breaks when its destination stops resolving. - // The target sitting at a different pointer than before is not - // enough, as a resource that moved as a whole keeps resolving - // the fragments that its own identifier is the base of - if (frame->traverse(saved_reference.destination).has_value()) { + const auto ¤t{core::get(schema, pointer)}; + const auto &vocabularies{frame.vocabularies(location, resolver)}; + Site site{.pointer = std::move(pointer), + .dialect = core::JSON::String{location.dialect}, + .base_dialect = location.base_dialect, + .type = location.type, + .relative_pointer = location.relative_pointer}; + + for (std::size_t index = 0; index < rules.size(); index += 1) { + const auto &[rule, writes_outside] = rules.at(index); + + // A dialect the ladder does not name has no rules for moving a + // schema off it, so nothing may rewrite a subschema that is read + // as one. Leaving this to each rule's own vocabulary gate does + // not hold: core derives a pre-2019-09 dialect's vocabularies + // from its base dialect, so an off-ladder resource does carry + // the rung's vocabulary and does match those gates + if (!writes_outside && !site.dialect.empty() && + !names_ladder_dialect(site.dialect)) { continue; } - // If the origin was also relocated, resolve its new location - auto effective_origin{saved_reference.origin}; - if (!core::try_get(schema, saved_reference.origin.initial())) { - const auto new_origin{rule->rereference( - saved_reference.destination, saved_reference.origin, - saved_reference.origin.slice(resource_offset), - current_slice)}; - if (!new_origin.has_value()) { - continue; - } - effective_origin = - saved_reference.origin.slice(0, resource_offset) - .concat(new_origin.value()); - if (!core::try_get(schema, effective_origin.initial())) { - continue; - } - } - - const auto new_relative{rule->rereference( - saved_reference.destination, saved_reference.origin, - saved_reference.target_pointer.slice( - saved_reference.target_relative_pointer), - current_slice)}; - if (!new_relative.has_value()) { - throw ConvertBrokenReferenceError{saved_reference.destination, - saved_reference.origin}; + // Whatever this rule needs the frame for, it takes down now + rule->plan(current, schema, vocabularies, frame, location, site, + walker, resolver); + + if (rule->condition(current, schema, vocabularies, site, walker, + resolver)) { + scheduled.push_back({.site = site, + .vocabularies = vocabularies, + .rule = index, + .depth = site.pointer.size(), + .writes_outside = writes_outside}); } - const auto new_fragment{ - saved_reference.fragment == - core::to_string(saved_reference.target_pointer) - ? saved_reference.target_pointer - .slice(0, saved_reference.target_relative_pointer) - .concat(new_relative.value()) - : new_relative.value()}; - - core::URI original{saved_reference.original}; - original.fragment(core::to_string(new_fragment)); - core::set(schema, effective_origin, - core::JSON{original.recompose()}); - references_fixed = true; + } + }); + + frame.for_each_reference( + [&](const core::SchemaReferenceType, const core::WeakPointer &origin, + const core::SchemaFrame::Reference &reference) -> void { + const auto destination{frame.traverse(reference.destination)}; + if (!destination.has_value() || !reference.fragment.has_value() || + !reference.fragment.value().starts_with('/')) { + return; } - const auto new_vocabularies{ - frame->vocabularies(new_location.value().get(), resolver)}; + const auto &landing{destination.value().get()}; - assert(!rule->condition(current, schema, new_vocabularies, *frame, - new_location.value().get(), walker, - resolver)); + // A fragment shaped like a pointer is not necessarily one. Draft 4 + // placed no restriction on the fragment an identifier carries, so + // an anchor may be named something like `/definitions/x`, and + // framing hands that name the URI the pointer would otherwise have + // had. Such a reference follows the anchor when it moves rather + // than keeping the location it looks like it names + if (landing.type == core::SchemaFrame::LocationType::Anchor) { + return; + } - std::tuple mark{ - entry_pointer, rule->name(), current}; - assert(!processed_rules.contains(mark)); - processed_rules.emplace(std::move(mark)); + snapshots.push_back( + {.origin = core::to_pointer(origin), + .original = core::JSON::String{reference.original}, + .destination = reference.destination, + .fragment = core::JSON::String{reference.fragment.value()}, + .target = core::to_pointer(landing.pointer), + .target_offset = landing.relative_pointer}); + }); + } - if (references_fixed) { - frame.reset(); - } + if (scheduled.empty()) { + break; + } - if (references_fixed || reframe_after_transform) { - return true; - } + // A rule that writes across the document goes first, because what it + // planned names positions as they are now and any other edit would move + // them. After that, deepest first, and within one position in the order the + // rules were registered. Applying what is deepest first is what lets a rule + // stop asking whether the subschemas below it are still waiting: by the + // time a position is reached, everything under it has been handled already + std::ranges::stable_sort( + scheduled, [](const auto &left, const auto &right) -> bool { + if (left.writes_outside != right.writes_outside) { + return left.writes_outside; } - return false; - })}; + if (left.depth != right.depth) { + return left.depth > right.depth; + } - if (!applied) { - break; + return left.rule < right.rule; + }); + + journal.clear(); + for (const auto &entry : scheduled) { + const auto &[rule, writes_outside] = rules.at(entry.rule); + + // An edit applied earlier in this pass may have moved or removed the + // position a later one was scheduled on + if (core::try_get(schema, entry.site.pointer) == nullptr) { + continue; + } + + auto ¤t{core::get(schema, entry.site.pointer)}; + + // Asked again right before the edit, so that an edit applied earlier in + // this pass can settle what this one was going to do. The question is + // answered from what was taken down above, never from the frame + if (!rule->condition(current, schema, entry.vocabularies, entry.site, + walker, resolver)) { + continue; + } + + rule->transform(current, entry.site); + + for (const auto &[before, after] : rule->relocations()) { + journal.emplace_back(entry.site.pointer.concat(before), + entry.site.pointer.concat(after)); + } } + + repair_references(schema, snapshots, journal, walker, resolver, + default_dialect, default_id); } } #include "rules/definitions_to_defs.h" #include "rules/dependencies_to_dependent.h" +#include "rules/dialect_override_becomes_dollar_schema.h" #include "rules/draft_official_dialect_with_https.h" #include "rules/draft_official_dialect_without_empty_fragment.h" #include "rules/empty_object_as_true.h" #include "rules/enum_to_const.h" #include "rules/modern_official_dialect_with_empty_fragment.h" +#include "rules/modern_official_dialect_with_http.h" +#include "rules/openapi_official_dialect_with_date.h" +#include "rules/openapi_official_dialect_with_empty_fragment.h" #include "rules/prefix_promoted_2020_12_keywords.h" #include "rules/prefix_promoted_draft_2019_09_keywords.h" #include "rules/prefix_promoted_draft_4_keywords.h" #include "rules/prefix_promoted_draft_6_keywords.h" #include "rules/prefix_promoted_draft_7_keywords.h" +#include "rules/prefix_promoted_openapi_3_1_keywords.h" +#include "rules/sanitize_draft_4_anchors.h" +#include "rules/shadow_stray_dialect_declaration.h" #include "rules/upgrade_2019_09_to_2020_12.h" -#include "rules/upgrade_dialect_override_cleanup.h" +#include "rules/upgrade_2020_12_to_openapi_3_1.h" #include "rules/upgrade_draft_3_to_draft_4.h" #include "rules/upgrade_draft_4_to_draft_6.h" #include "rules/upgrade_draft_6_to_draft_7.h" #include "rules/upgrade_draft_7_to_draft_2019_09.h" +#include "rules/upgrade_openapi_3_0_to_draft_4.h" +#include "rules/upgrade_openapi_3_1_to_openapi_3_2.h" #undef ONLY_CONTINUE_IF @@ -328,45 +528,118 @@ auto convert(sourcemeta::core::JSON &schema, const sourcemeta::core::SchemaResolver &resolver, const ConvertTarget target, const std::string_view default_dialect, const std::string_view default_id) -> void { + // How a dialect is spelled is settled before any rung is asked about one. A + // rung rule reads the dialect of the position it is looking at, and a + // spelling the ladder does not name is one it cannot answer for, so these + // run to completion first rather than alongside + std::vector spellings; + spellings.reserve(8); + spellings.push_back(make_rule()); + spellings.push_back(make_rule()); + spellings.push_back(make_rule()); + spellings.push_back(make_rule()); + spellings.push_back(make_rule()); + spellings.push_back(make_rule()); + spellings.push_back(make_rule()); + spellings.push_back(make_rule()); + std::vector rules; rules.reserve(20); - rules.push_back(make_rule()); - rules.push_back(make_rule()); - rules.push_back(make_rule()); + + // Again here, and not only among the spellings above: a marker that said + // something when the pass began can stop saying anything once a rung has + // moved the resource holding it to the dialect the marker names, and a + // conversion that left it behind would not be done with the document + rules.push_back(make_rule()); + + // A document on the OpenAPI 3.0 dialect forks here rather than standing on a + // rung. Every target below reaches it through Draft 4, which is the base + // dialect its Schema Object extends, so this runs ahead of the ladder and + // the rungs carry on from there + rules.push_back(make_rule(target)); + + // Whether this conversion starts from OpenAPI 3.0. Usually only the caller + // can say, because the Schema Object admits no `$schema` member of its own, + // so a document written in that dialect is named from the outside. The + // exception is the override marker, which names a dialect for a document that + // cannot declare one, and which `declared_dialect` reads ahead of `$schema` + // exactly as core frames it. + // + // So the question is what the document says it is, and only where it says + // nothing does the default the caller passed decide. Asking merely whether a + // `$schema` member is present answers both halves wrongly: a document naming + // this dialect through the marker is read as not being on it, and one naming + // another dialect that way is read as being on it. + // + // What this buys is the keywords the fork carries across as themselves. Each + // is author data to the rung that promotes it, and shadowing it there would + // undo the fork, so the rules that would are told to leave it be + const auto declared{declared_dialect(schema)}; + const auto from_openapi_3_0{ + names_openapi_3_0_dialect(declared) || + (declared.empty() && names_openapi_3_0_dialect(default_dialect))}; + rules.push_back(make_rule()); rules.push_back(make_rule()); if (target == ConvertTarget::Draft6 || target == ConvertTarget::Draft7 || target == ConvertTarget::Draft201909 || - target == ConvertTarget::Draft202012) { + target == ConvertTarget::Draft202012 || + target == ConvertTarget::OpenAPI31 || + target == ConvertTarget::OpenAPI32) { rules.push_back(make_rule()); + rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); } if (target == ConvertTarget::Draft7 || target == ConvertTarget::Draft201909 || - target == ConvertTarget::Draft202012) { - rules.push_back(make_rule()); + target == ConvertTarget::Draft202012 || + target == ConvertTarget::OpenAPI31 || + target == ConvertTarget::OpenAPI32) { + rules.push_back(make_rule( + from_openapi_3_0 + ? std::vector{"readOnly", "writeOnly"} + : std::vector{})); rules.push_back(make_rule()); } if (target == ConvertTarget::Draft201909 || - target == ConvertTarget::Draft202012) { - rules.push_back(make_rule()); + target == ConvertTarget::Draft202012 || + target == ConvertTarget::OpenAPI31 || + target == ConvertTarget::OpenAPI32) { + rules.push_back(make_rule( + from_openapi_3_0 ? std::vector{"deprecated"} + : std::vector{})); rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); } - if (target == ConvertTarget::Draft202012) { + if (target == ConvertTarget::Draft202012 || + target == ConvertTarget::OpenAPI31 || + target == ConvertTarget::OpenAPI32) { rules.push_back(make_rule()); rules.push_back(make_rule()); } - rules.push_back(make_rule()); - apply(rules, schema, walker, resolver, default_dialect, default_id); - erase_dialect_overrides(schema); + if (target == ConvertTarget::OpenAPI31 || + target == ConvertTarget::OpenAPI32) { + rules.push_back(make_rule( + from_openapi_3_0 + ? std::vector{"example", "discriminator", + "externalDocs", "xml"} + : std::vector{})); + rules.push_back(make_rule()); + } + + if (target == ConvertTarget::OpenAPI32) { + rules.push_back(make_rule()); + } + + apply(spellings, schema, walker, resolver, default_dialect, default_id, true); + apply(rules, schema, walker, resolver, default_dialect, default_id, false); } } // namespace sourcemeta::blaze diff --git a/vendor/blaze/src/convert/helpers.h b/vendor/blaze/src/convert/helpers.h index 91341123..994839db 100644 --- a/vendor/blaze/src/convert/helpers.h +++ b/vendor/blaze/src/convert/helpers.h @@ -13,8 +13,11 @@ inline auto declared_dialect(const sourcemeta::core::JSON &schema) return {}; } + // Core reads an empty override as no override at all, so shadowing a real + // `$schema` with one would disagree with how the document frames const auto *override_value{schema.try_at(DIALECT_OVERRIDE_KEYWORD)}; - if (override_value != nullptr && override_value->is_string()) { + if (override_value != nullptr && override_value->is_string() && + !override_value->to_string().empty()) { return override_value->to_string(); } @@ -26,9 +29,49 @@ inline auto declared_dialect(const sourcemeta::core::JSON &schema) return {}; } -inline auto mark_dialect_override(sourcemeta::core::JSON &schema, - const std::string_view dialect) -> void { - schema.assign(DIALECT_OVERRIDE_KEYWORD, sourcemeta::core::JSON{dialect}); +// Where the dialect a rung moves off is written down, and so the only place a +// rung has anything to write. Framing reads a `$schema` only at the root of a +// schema resource, which is where the position and its resource-relative +// offset meet, and a resource that does not spell one out takes the dialect of +// the resource holding it, which the same pass has already bumped. The +// document root is the exception: there is nothing above it to inherit from, so +// a dialect it took from the caller is materialised here +/// Where a rule fires, owned rather than viewed, so that it stays readable +/// after the document has been edited +struct Site { + sourcemeta::core::Pointer pointer; + sourcemeta::core::JSON::String dialect; + sourcemeta::core::SchemaBaseDialect base_dialect; + sourcemeta::core::SchemaFrame::LocationType type; + std::size_t relative_pointer; +}; + +inline auto at_dialect_declaration(const sourcemeta::core::JSON &schema, + const Site &site) -> bool { + // A rule scheduled against an object may find a boolean here, because an + // edit applied earlier in the same pass can have replaced it with one + if (!schema.is_object() || site.pointer.size() != site.relative_pointer) { + return false; + } + + return site.pointer.empty() || schema.defines("$schema"); +} + +// A dialect the document never spelled out goes in front of what the author +// did write, which is where a `$schema` belongs and where one that was already +// there would have stayed. The first key is read before anything is written, +// because inserting into the object invalidates what iterating it handed out +inline auto assign_before_first_key(sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON::String &key, + sourcemeta::core::JSON &&value) -> void { + if (schema.empty()) { + schema.assign(key, std::move(value)); + return; + } + + const sourcemeta::core::JSON::String first{ + schema.as_object().cbegin()->first}; + schema.try_assign_before(key, value, first); } inline auto current_dialect_or_override(const sourcemeta::core::JSON &schema) @@ -82,33 +125,222 @@ inline auto is_metaschema_target(const sourcemeta::core::JSON &schema, }); } -inline auto -subschema_at_dialect(const sourcemeta::core::JSON &schema, - const sourcemeta::core::SchemaFrame::Location &location, - const std::string_view dialect) -> bool { - const auto current{current_dialect_or_override(schema)}; - if (!current.empty()) { - return current == dialect; - } - return schema.is_object() && location.pointer.empty(); +// The spelling every OpenAPI 3.1 dialect URI settles onto. OpenAPI 3.1 +// publishes the same dialect at `dialect/2024-10-25` and `dialect/2024-11-10` +// as well, naming documents that differ only in formatting, and +// `normalized_official_dialect` below folds those onto this one +constexpr std::string_view OPENAPI_3_1_DIALECT{ + "https://spec.openapis.org/oas/3.1/dialect/base"}; + +// The OpenAPI 3.0 Schema Object dialect, which the conversion reads as a +// source and never writes. It is not a rung: its Schema Object extends the +// Draft 4 base dialect while OpenAPI 3.1's extends 2020-12, so it sits below +// Draft 4 by base dialect and below 3.1 by product version, and those two +// orderings disagree. A document on it forks instead, reaching an OpenAPI +// target through 3.1 and a JSON Schema target through Draft 4. +// +// Both dated spellings are listed and neither is canonical. Unlike OpenAPI +// 3.1's three URIs, these name two documents that genuinely differ, so there +// is nothing here for `normalized_official_dialect` to fold. They differ only +// in the parts of an OpenAPI document that a Schema Object never reaches, +// which is why one set of rules serves both +// NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) +constexpr std::array OPENAPI_3_0_DIALECTS{ + {"tag:spec.openapis.org,2024-10-18:oas/3.0/dialect", + "tag:spec.openapis.org,2021-09-28:oas/3.0/dialect"}}; + +// Whether the dialect is one of those, asked of the spelling as written. A tag +// URI carries no fragment and no scheme the spelling rules fold, so what the +// document says is already the only spelling there is +inline auto names_openapi_3_0_dialect(const std::string_view dialect) -> bool { + return std::ranges::find(OPENAPI_3_0_DIALECTS, dialect) != + OPENAPI_3_0_DIALECTS.cend(); +} + +// Fold an OpenAPI 3.0 `nullable` into the `type` beside it, which is the one +// member of that dialect carrying an assertion rather than an annotation. Both +// targets of the fork accept a type array, so the same rewrite serves each. +// +// The keyword takes effect only where `type` is explicitly defined within the +// same Schema Object, which the specification states outright. One standing +// alone constrained nothing, and is left exactly where it is so that the +// shadowing carries it across as the author data it was. Erasing it would drop +// something the author wrote, which is not what becomes of any other member +// this dialect has, and turning it into `{"type": ["null"]}` would invent a +// constraint it never applied. +// +// A `nullable` that a `$ref` suppressed is left untouched too, whatever it +// says. Such a keyword never applied, and the caller shadows it along with +// every other sibling instead +inline auto fold_nullable_into_type(sourcemeta::core::JSON &schema) -> void { + const auto *nullable{schema.try_at("nullable")}; + if (nullable == nullptr || !nullable->is_boolean() || + !nullable->to_boolean() || schema.defines("$ref") || + !schema.defines("type")) { + return; + } + + static const sourcemeta::core::JSON NULL_TYPE{"null"}; + auto &type{schema.at("type")}; + if (type.is_string()) { + auto names{sourcemeta::core::JSON::make_array()}; + names.push_back(type); + names.push_back_if_unique(NULL_TYPE); + type = std::move(names); + } else if (type.is_array()) { + // An array here is not a shape OpenAPI 3.0 permits, as its `type` takes a + // single name. Carrying one anyway costs a line and keeps the rewrite from + // depending on a check that runs elsewhere + type.push_back_if_unique(NULL_TYPE); + } else { + return; + } + + schema.erase("nullable"); +} + +// Draft 4 reads `enum` as a set and requires its members to be unique, while +// the OpenAPI 3.0 Schema Object states `uniqueItems: false` for it outright. A +// repeated option says nothing a single one does not, so a document carrying +// one would otherwise reach a dialect that cannot read it back +inline auto deduplicate_enum(sourcemeta::core::JSON &schema) -> void { + auto *options{schema.try_at("enum")}; + if (options == nullptr || !options->is_array() || options->unique()) { + return; + } + + auto distinct{sourcemeta::core::JSON::make_array()}; + for (const auto &option : options->as_array()) { + distinct.push_back_if_unique(option); + } + + *options = std::move(distinct); } -// The official dialects the upgrade walks through, oldest first, so that a -// marker recording a newer one can be told apart from a stale one +// An empty `anyOf` or `oneOf` is satisfied by no instance whatever, which +// Draft 4 has no boolean schema to say and spells as a `not` of the schema +// every instance satisfies. That goes into `allOf`, whose meaning is already +// the conjunction this needs, so a `not` the author wrote is left exactly as it +// stands. Draft 4 requires at least one branch in each of the three, while the +// OpenAPI 3.0 Schema Object places no bound on them at all +inline auto fold_empty_applicators(sourcemeta::core::JSON &schema) -> void { + static const std::array UNSATISFIABLE_WHEN_EMPTY{ + {"anyOf", "oneOf"}}; + + for (const auto &keyword : UNSATISFIABLE_WHEN_EMPTY) { + const sourcemeta::core::JSON::String name{keyword}; + const auto *branches{schema.try_at(name)}; + if (branches == nullptr || !branches->is_array() || !branches->empty()) { + continue; + } + + schema.erase(name); + if (!schema.defines("allOf") || !schema.at("allOf").is_array()) { + schema.assign("allOf", sourcemeta::core::JSON::make_array()); + } + + auto nothing{sourcemeta::core::JSON::make_object()}; + nothing.assign("not", sourcemeta::core::JSON::make_object()); + schema.at("allOf").push_back(std::move(nothing)); + } +} + +// The OpenAPI 3.2 dialect, which declares the same four keywords as 3.1 with +// strictly more permissive constraints, so the rung above only has a dialect +// to move. OpenAPI 3.2 publishes one dated URI with no undated alias, so there +// is no spelling here for `normalized_official_dialect` to fold +constexpr std::string_view OPENAPI_3_2_DIALECT{ + "https://spec.openapis.org/oas/3.2/dialect/2025-09-17"}; + +// The dialects the upgrade walks through, oldest first, so that a marker +// recording a newer one can be told apart from a stale one. The last two rungs +// are not JSON Schema dialects but the OpenAPI ones, each of which requires +// every 2020-12 vocabulary and adds one of its own // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) -constexpr std::array LADDER_DIALECTS{ +constexpr std::array LADDER_DIALECTS{ {"http://json-schema.org/draft-03/schema#", "http://json-schema.org/draft-04/schema#", "http://json-schema.org/draft-06/schema#", "http://json-schema.org/draft-07/schema#", "https://json-schema.org/draft/2019-09/schema", - "https://json-schema.org/draft/2020-12/schema"}}; + "https://json-schema.org/draft/2020-12/schema", OPENAPI_3_1_DIALECT, + OPENAPI_3_2_DIALECT}}; + +// The spellings the normalising rules settle on all name the same dialect, so +// whether the ladder names one has to be asked of the spelling those rules +// would produce rather than of what the document happens to say +// The numbered drafts are spelled `http` and the dated ones `https`, but +// either scheme names the same dialect and framing accepts both, reporting the +// canonical spelling as the base dialect while the one that was written stays +// in the dialect. Both have to collapse to one answer here, or a document is +// read as off the ladder purely for how its `$schema` was typed +inline auto normalized_official_dialect(const std::string_view dialect) + -> std::string { + std::string result{without_empty_fragment(dialect)}; + if (result.starts_with("https://json-schema.org/draft-")) { + result.erase(4, 1); + } else if (result.starts_with("http://json-schema.org/draft/")) { + result.insert(4, "s"); + } else if (result == "https://spec.openapis.org/oas/3.1/dialect/2024-10-25" || + result == "https://spec.openapis.org/oas/3.1/dialect/2024-11-10") { + // A spelling rule rewrites the document, but it cannot be the only place + // this lives. The dialect asserts run in the first pass of the spellings + // phase, before any rule is planned, so a dated URI would be refused + // before that rule could reach it + result = OPENAPI_3_1_DIALECT; + } + + return result; +} + +// Where a base dialect sits on the ladder, so that a dialect the ladder does +// not name can still be placed by the official one it derives from. Hyper +// variants sit alongside their plain counterparts, and the drafts below the +// ladder answer zero just as an unrecognised dialect does. +// +// This stops at 2020-12 where `dialect_position` goes one rung further. The +// OpenAPI 3.1 dialect declares 2020-12 as its own `$schema`, so core frames a +// document on it with a 2020-12 base dialect and there is no base dialect that +// names the last rung. Anything needing that rung has to ask about the +// declared dialect instead +inline auto +base_dialect_position(const sourcemeta::core::SchemaBaseDialect base_dialect) + -> std::size_t { + using Base = sourcemeta::core::SchemaBaseDialect; + switch (base_dialect) { + case Base::JSON_SCHEMA_DRAFT_3: + case Base::JSON_SCHEMA_DRAFT_3_HYPER: + return 1; + case Base::JSON_SCHEMA_DRAFT_4: + case Base::JSON_SCHEMA_DRAFT_4_HYPER: + return 2; + case Base::JSON_SCHEMA_DRAFT_6: + case Base::JSON_SCHEMA_DRAFT_6_HYPER: + return 3; + case Base::JSON_SCHEMA_DRAFT_7: + case Base::JSON_SCHEMA_DRAFT_7_HYPER: + return 4; + case Base::JSON_SCHEMA_2019_09: + case Base::JSON_SCHEMA_2019_09_HYPER: + return 5; + case Base::JSON_SCHEMA_2020_12: + case Base::JSON_SCHEMA_2020_12_HYPER: + return 6; + default: + return 0; + } +} // How far along the ladder a dialect sits, counting from one so that anything -// the ladder does not name sits before all of them +// the ladder does not name sits before all of them. The spelling is normalised +// first, so that every form naming the same dialect ranks the same. Whether a +// dialect is the ladder's and how far along it sits have to be one question, +// or the ladder would accept a marker in one place and refuse to rank it in +// another inline auto dialect_position(const std::string_view dialect) -> std::size_t { + const auto candidate{normalized_official_dialect(dialect)}; for (std::size_t index = 0; index < LADDER_DIALECTS.size(); index += 1) { - if (LADDER_DIALECTS[index] == dialect) { + if (without_empty_fragment(LADDER_DIALECTS[index]) == candidate) { return index + 1; } } @@ -116,6 +348,79 @@ inline auto dialect_position(const std::string_view dialect) -> std::size_t { return 0; } +// The official meta-schema documents, by the URI a schema references them at. +// Each recurses with the keyword of the dialect it was written for, so a +// meta-schema that extends one cannot be carried to another dialect by renaming +// anything in the extending document alone. The OpenAPI documents are here for +// that same reason, as they recurse through `$dynamicAnchor`. All three +// spellings are listed, because `normalized_metaschema_uri` folds only +// `json-schema.org` prefixes and a `$ref` is not something a spelling rule +// rewrites +// NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) +constexpr std::array OFFICIAL_METASCHEMAS{ + {"http://json-schema.org/draft-03/schema", + "http://json-schema.org/draft-04/schema", + "http://json-schema.org/draft-06/schema", + "http://json-schema.org/draft-07/schema", + "https://json-schema.org/draft/2019-09/schema", + "https://json-schema.org/draft/2019-09/meta/core", + "https://json-schema.org/draft/2019-09/meta/applicator", + "https://json-schema.org/draft/2019-09/meta/validation", + "https://json-schema.org/draft/2019-09/meta/meta-data", + "https://json-schema.org/draft/2019-09/meta/format", + "https://json-schema.org/draft/2019-09/meta/content", + "https://json-schema.org/draft/2019-09/meta/hyper-schema", + "https://json-schema.org/draft/2020-12/schema", + "https://json-schema.org/draft/2020-12/meta/core", + "https://json-schema.org/draft/2020-12/meta/applicator", + "https://json-schema.org/draft/2020-12/meta/unevaluated", + "https://json-schema.org/draft/2020-12/meta/validation", + "https://json-schema.org/draft/2020-12/meta/meta-data", + "https://json-schema.org/draft/2020-12/meta/format-annotation", + "https://json-schema.org/draft/2020-12/meta/format-assertion", + "https://json-schema.org/draft/2020-12/meta/content", + "https://json-schema.org/draft/2020-12/meta/hyper-schema", + "https://spec.openapis.org/oas/3.1/dialect/base", + "https://spec.openapis.org/oas/3.1/dialect/2024-10-25", + "https://spec.openapis.org/oas/3.1/dialect/2024-11-10", + "https://spec.openapis.org/oas/3.1/meta/base", + "https://spec.openapis.org/oas/3.1/meta/2024-10-25", + "https://spec.openapis.org/oas/3.1/meta/2024-11-10", + "https://spec.openapis.org/oas/3.2/dialect/2025-09-17", + "https://spec.openapis.org/oas/3.2/meta/2025-09-17"}}; + +// The two families settled on opposite schemes, `http` for the numbered drafts +// and `https` for the dated ones, and both spellings are seen in the wild. +// Which one a document wrote must not decide whether it is recognised +inline auto normalized_metaschema_uri(const std::string_view uri) + -> std::string { + std::string result{without_empty_fragment(uri)}; + if (result.starts_with("https://json-schema.org/draft-")) { + result.erase(4, 1); + } else if (result.starts_with("http://json-schema.org/draft/")) { + result.insert(4, "s"); + } + + return result; +} + +inline auto names_official_metaschema(const std::string_view uri) -> bool { + const auto candidate{normalized_metaschema_uri(uri)}; + return std::ranges::any_of( + OFFICIAL_METASCHEMAS, [&candidate](const auto &entry) -> bool { + return normalized_metaschema_uri(entry) == candidate; + }); +} + +// Whether the conversion has rules for moving a schema off this dialect. A +// dialect it does not name is one it has no rules for, whether that is a draft +// older than the ladder starts at or a meta-schema of the caller's own. Every +// rung qualifies, and so does OpenAPI 3.0, which forks off the ladder rather +// than standing on it +inline auto names_ladder_dialect(const std::string_view dialect) -> bool { + return dialect_position(dialect) > 0 || names_openapi_3_0_dialect(dialect); +} + // Core reads this keyword as a dialect too, so it may well be a keyword the // caller wrote. The ladder only ever records one of the dialects it walks // through, so anything else is not ours to clear @@ -124,28 +429,38 @@ inline auto is_own_dialect_override(const sourcemeta::core::JSON &value) return value.is_string() && dialect_position(value.to_string()) > 0; } -// The spellings the normalising rules settle on all name the same dialect, so -// whether the ladder names one has to be asked of the spelling those rules -// would produce rather than of what the document happens to say -inline auto normalized_official_dialect(const std::string_view dialect) - -> std::string { - std::string result{without_empty_fragment(dialect)}; - if (result.starts_with("https://json-schema.org/draft-")) { - result.erase(4, 1); +inline auto bump_dialect(sourcemeta::core::JSON &schema, const Site &site, + const std::string_view dialect) -> void { + // Core reads the override marker as the dialect of the position carrying it, + // ahead of any `$schema` beside it and wherever that position is + if (schema.is_object()) { + auto *override_value{schema.try_at(DIALECT_OVERRIDE_KEYWORD)}; + if (override_value != nullptr && override_value->is_string() && + names_ladder_dialect(override_value->to_string())) { + // Where nothing else is read, the dialect moves by writing the marker. A + // `$schema` written here instead would not be the thing read, and the + // position would keep the dialect it had while its keywords moved on + if (!at_dialect_declaration(schema, site)) { + *override_value = sourcemeta::core::JSON{dialect}; + return; + } + + // Here a `$schema` is read, and the marker would go on overriding it, so + // the marker gives way to the declaration that now says the same thing + schema.erase(DIALECT_OVERRIDE_KEYWORD); + } } - return result; -} + if (!at_dialect_declaration(schema, site)) { + return; + } -// A dialect the ladder does not name is one the conversion has no rules for, -// whether it belongs to a draft older than the ladder starts at or to a -// meta-schema of the caller's own -inline auto names_ladder_dialect(const std::string_view dialect) -> bool { - const auto candidate{normalized_official_dialect(dialect)}; - return std::ranges::any_of( - LADDER_DIALECTS, [&candidate](const auto &entry) -> bool { - return without_empty_fragment(entry) == candidate; - }); + if (schema.defines("$schema")) { + schema.assign("$schema", sourcemeta::core::JSON{dialect}); + return; + } + + assign_before_first_key(schema, "$schema", sourcemeta::core::JSON{dialect}); } // Whether an identifier and a dialect name the same thing once both are @@ -154,13 +469,22 @@ inline auto names_the_same_uri(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON::StringView keyword, const std::string_view dialect, const std::string_view default_id) -> bool { + // A document that declares no usable identifier of its own is named by + // whatever the caller said it is called, which is the answer identification + // gives too, so that is the name this question has to ask about. Reading only + // the keyword would let a meta-schema identified solely by the caller + // describe itself unnoticed. A keyword holding something other than a string + // never reaches here, as framing rejects it first const auto *identifier{schema.try_at(keyword)}; - if (identifier == nullptr || !identifier->is_string()) { + const std::string_view candidate{ + identifier != nullptr && identifier->is_string() + ? std::string_view{identifier->to_string()} + : default_id}; + if (candidate.empty()) { return false; } - if (without_empty_fragment(identifier->to_string()) == - without_empty_fragment(dialect)) { + if (without_empty_fragment(candidate) == without_empty_fragment(dialect)) { return true; } @@ -169,7 +493,7 @@ inline auto names_the_same_uri(const sourcemeta::core::JSON &schema, // A value that does not parse is not for this question to complain about, // as framing says so in better words a moment later try { - sourcemeta::core::URI left{identifier->to_string()}; + sourcemeta::core::URI left{std::string{candidate}}; sourcemeta::core::URI right{std::string{dialect}}; if (!default_id.empty()) { const sourcemeta::core::URI base{std::string{default_id}}; @@ -221,81 +545,152 @@ inline auto moved_past(const sourcemeta::core::JSON &schema, dialect_position(dialect); } -// The marker is state of the ladder rather than of the schema. A resource that -// declares a dialect the conversion does not own can never materialise it into -// a `$schema`, so whatever survives the ladder has to come off before the -// caller ever sees it -inline auto erase_dialect_overrides(sourcemeta::core::JSON &schema) -> void { - if (schema.is_array()) { - for (auto &item : schema.as_array()) { - erase_dialect_overrides(item); - } +// A subschema that declares a dialect newer than the one a step moves schemas +// off has nothing pending for that step, as the keywords it holds belong to the +// dialect it declares rather than being strangers there. A subschema that +// declares nothing inherits the dialect of the schema being converted, which is +// why only a declared position counts +// Whether a keyword that the given dialect promoted is still the author's data +// here. It is while the subschema is read below that rung, where the keyword +// asserts nothing and would start to once the dialect moves, so it has to be +// shadowed first. Several of those keywords are also what the upgrade writes +// as it climbs, so one in a subschema already declaring the promoting dialect +// is the keyword doing its job and must be left alone. This asks what a rule +// found, where `declares_dialect_out_of_reach` asks whether it has work at all +inline auto +promoted_keyword_is_author_data(const sourcemeta::core::JSON &subschema, + const std::string_view dialect) -> bool { + return dialect_position(declared_dialect(subschema)) < + dialect_position(dialect); +} - return; +// Whether a rule for the given rung has no work in this subschema and never +// will. A subschema declaring a newer dialect has already moved past the rung, +// and one declaring a dialect the ladder does not name is never rewritten at +// all, so neither is ever going to change. Reading the second as merely +// "older, still pending" is what holds an ancestor's upgrade back for good: +// the ancestor waits for a descendant that nothing is allowed to touch +inline auto +declares_dialect_out_of_reach(const sourcemeta::core::JSON &subschema, + const std::string_view dialect) -> bool { + const auto declared_uri{declared_dialect(subschema)}; + if (declared_uri.empty()) { + return false; } - if (!schema.is_object()) { - return; - } + const auto declared{dialect_position(declared_uri)}; + return declared == 0 || declared > dialect_position(dialect); +} - const auto *marker{schema.try_at(DIALECT_OVERRIDE_KEYWORD)}; - if (marker != nullptr && is_own_dialect_override(*marker)) { - schema.erase(DIALECT_OVERRIDE_KEYWORD); - } +struct AnchorCharPolicy { + std::function is_valid_first; + std::function is_valid_body; +}; - std::vector keys; - keys.reserve(schema.size()); - for (const auto &entry : schema.as_object()) { - keys.push_back(entry.first); - } - for (const auto &key : keys) { - erase_dialect_overrides(schema.at(key)); - } +// The dialect whose identifiers carry a free-form fragment +constexpr std::string_view DRAFT_4_DIALECT{ + "http://json-schema.org/draft-04/schema#"}; + +// Draft 6 core 9.2 restricts a plain-name fragment to a letter followed by +// letters, digits, hyphens, underscores, colons or periods. Draft 4 placed no +// such restriction on the fragment its identifier carries, so an anchor that +// was legal there may not be legal once the dialect moves +inline auto is_draft_6_anchor_first_char(const char character) -> bool { + return (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z'); } -inline auto drop_dialect_overrides(sourcemeta::core::JSON &schema, - const bool is_root, - const std::string_view dialect) -> void { - if (schema.is_array()) { - for (auto &item : schema.as_array()) { - drop_dialect_overrides(item, false, dialect); - } - return; - } +inline auto is_draft_6_anchor_body_char(const char character) -> bool { + return is_draft_6_anchor_first_char(character) || + (character >= '0' && character <= '9') || character == '_' || + character == ':' || character == '.' || character == '-'; +} - if (!schema.is_object()) { - return; +inline auto is_draft_6_plain_name(const std::string_view fragment) -> bool { + if (fragment.empty() || !is_draft_6_anchor_first_char(fragment.front())) { + return false; } - if (!is_root && schema.defines("$schema") && - schema.at("$schema").is_string()) { - return; + for (std::size_t index{1}; index < fragment.size(); index += 1) { + if (!is_draft_6_anchor_body_char(fragment[index])) { + return false; + } } - // A subschema that already moved past the dialect being established keeps - // its marker. Dropping it would leave the keywords that move brought in - // looking like keywords of the dialect it has left behind, and the rules - // that reserve those names would prefix them away - const auto *marker{schema.try_at(DIALECT_OVERRIDE_KEYWORD)}; - if (marker != nullptr && is_own_dialect_override(*marker) && - (is_root || !moved_past(schema, dialect))) { - schema.erase(DIALECT_OVERRIDE_KEYWORD); + return true; +} + +inline auto draft_6_anchor_policy() -> const AnchorCharPolicy & { + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) + static const AnchorCharPolicy POLICY{ + .is_valid_first = &is_draft_6_anchor_first_char, + .is_valid_body = &is_draft_6_anchor_body_char}; + return POLICY; +} + +// The fragment an identifier carries, if it carries one +inline auto identifier_fragment(const sourcemeta::core::JSON &value) + -> std::optional { + if (!value.is_string()) { + return std::nullopt; } - std::vector keys; - keys.reserve(schema.size()); - for (const auto &entry : schema.as_object()) { - keys.push_back(entry.first); + const sourcemeta::core::URI uri{value.to_string()}; + const auto fragment{uri.fragment()}; + if (!fragment.has_value()) { + return std::nullopt; } - for (const auto &key : keys) { - drop_dialect_overrides(schema.at(key), false, dialect); + + return std::string{fragment.value()}; +} + +// Whether any Draft 4 resource in this document still holds an anchor that +// Draft 6 would reject. The dialect must not move while one does, or the +// identifier would arrive in Draft 6 carrying a fragment that dialect rejects +inline auto +has_unsanitized_draft_4_anchor(const sourcemeta::core::JSON &schema, + const sourcemeta::core::SchemaFrame &frame) + -> bool { + if (!schema.is_object()) { + return false; } + + bool pending{false}; + frame.for_each_subschema( + [&schema, + &pending](const sourcemeta::core::SchemaFrame::Location &entry) -> void { + if (pending || base_dialect_position(entry.base_dialect) != + dialect_position(DRAFT_4_DIALECT)) { + return; + } + + const auto &subschema{sourcemeta::core::get( + schema, sourcemeta::core::to_pointer(entry.pointer))}; + if (!subschema.is_object()) { + return; + } + + const auto *identifier{subschema.try_at("id")}; + if (identifier == nullptr) { + return; + } + + const auto fragment{identifier_fragment(*identifier)}; + pending = fragment.has_value() && !fragment.value().empty() && + !is_draft_6_plain_name(fragment.value()); + }); + + return pending; } -struct AnchorCharPolicy { - std::function is_valid_first; - std::function is_valid_body; -}; +// Whatever an identifier is spelled relative to, kept as written, so that only +// the fragment moves when an anchor is renamed +inline auto without_fragment_as_written(const std::string_view identifier) + -> std::string_view { + const auto hash{identifier.find('#')}; + return hash == std::string_view::npos ? identifier + : identifier.substr(0, hash); +} inline auto sanitize_anchor_with_policy(const std::string_view original, const std::set &in_use, @@ -306,10 +701,14 @@ inline auto sanitize_anchor_with_policy(const std::string_view original, for (const char character : original) { sanitized.push_back(policy.is_valid_body(character) ? character : '-'); } + // Every policy takes a letter as an opening character and a hyphen in the + // body, so prepending this both settles an opening character the policy + // rejects and walks away from a name already in use while (sanitized.empty() || !policy.is_valid_first(sanitized.front()) || in_use.contains(sanitized)) { sanitized.insert(0, "x-"); } + return sanitized; } diff --git a/vendor/blaze/src/convert/include/sourcemeta/blaze/convert.h b/vendor/blaze/src/convert/include/sourcemeta/blaze/convert.h index 0ac219da..104b37a1 100644 --- a/vendor/blaze/src/convert/include/sourcemeta/blaze/convert.h +++ b/vendor/blaze/src/convert/include/sourcemeta/blaze/convert.h @@ -41,15 +41,33 @@ enum class ConvertTarget : std::uint8_t { /// JSON Schema 2020-12 Draft202012, + + /// The OpenAPI 3.1 Schema Object dialect + OpenAPI31, + + /// The OpenAPI 3.2 Schema Object dialect + OpenAPI32, }; /// @ingroup convert /// Convert the given schema, in place, to the given dialect. Only upgrades are /// supported, so a schema already on that dialect or a newer one is left as /// is. A document that describes itself or that carries the meta-schema -/// something in it declares raises `ConvertUnsupportedMetaschemaError`, and one -/// that sits on a dialect outside the official ladder raises -/// `ConvertUnsupportedDialectError`. For example: +/// something in it declares raises `ConvertUnsupportedMetaschemaError`. The +/// dialects this conversion walks through are the official JSON Schema ones up +/// to 2020-12, and then the OpenAPI 3.1 and OpenAPI 3.2 Schema Object +/// dialects, each of which is 2020-12 plus a vocabulary of its own. A document +/// that sits on any other +/// dialect raises `ConvertUnsupportedDialectError` whatever the requested +/// target is, as this +/// conversion has no rules for moving a schema off such a dialect and will not +/// convert the document around one either. Note +/// that a document whose dialect cannot be resolved or framed at all fails +/// before any of those apply, propagating the error that resolution or framing +/// raised rather than one of this module's, so a caller that must not fault on +/// hostile input catches more than the `Convert*Error` family. Conversion +/// mutates in place and does not roll back, so a document that raises part way +/// through is left half converted. For example: /// /// ```cpp /// #include diff --git a/vendor/blaze/src/convert/rule.h b/vendor/blaze/src/convert/rule.h index f9a1f075..eb919c8c 100644 --- a/vendor/blaze/src/convert/rule.h +++ b/vendor/blaze/src/convert/rule.h @@ -12,30 +12,53 @@ class SchemaTransformRule { auto operator=(const SchemaTransformRule &) -> SchemaTransformRule & = delete; auto operator=(SchemaTransformRule &&) -> SchemaTransformRule & = delete; + /// Whether this rule writes outside the subschema it fires on. Such a rule + /// is offered a subschema the ladder does not name, because what it is + /// allowed to rewrite is its own business rather than the driver's + using writes_outside_itself = std::false_type; + [[nodiscard]] auto name() const noexcept -> std::string_view { return this->name_; } - /// A method to optionally fix any reference location that was affected by the - /// transformation - [[nodiscard]] virtual auto - rereference(const std::string_view, const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &) const - -> std::optional { - return std::nullopt; + /// Where this rule moved a subschema, as pairs of positions relative to the + /// subschema it fired on. The driver collects these so that a reference + /// naming a moved position follows it, which is why a rule that relocates + /// anything has to say so here rather than rewrite references itself + using Relocation = + std::pair; + + [[nodiscard]] virtual auto relocations() const -> std::vector { + return {}; } + /// Called once at the start of every pass, before anything is planned, so + /// that a rule holding what it planned does not read back an answer about a + /// document that has since changed + virtual auto begin_pass() const -> void {} + + /// Whatever a rule needs the frame for, taken down while the frame still + /// describes the document in front of it. A `SchemaFrame::Location` hands out + /// views into the frame and the document, so reading one after an edit is a + /// dangling read rather than a stale one, which is why anything frame-derived + /// is captured here and nowhere else + virtual auto plan(const sourcemeta::core::JSON &, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const sourcemeta::core::SchemaFrame &, + const sourcemeta::core::SchemaFrame::Location &, + const Site &, const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> void {} + [[nodiscard]] virtual auto condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &root, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &frame, - const sourcemeta::core::SchemaFrame::Location &location, - const sourcemeta::core::SchemaWalker &walker, + const Site &site, const sourcemeta::core::SchemaWalker &walker, const sourcemeta::core::SchemaResolver &resolver) const -> bool = 0; - virtual auto transform(sourcemeta::core::JSON &schema) const -> void = 0; + virtual auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void = 0; private: const std::string name_{}; diff --git a/vendor/blaze/src/convert/rules/definitions_to_defs.h b/vendor/blaze/src/convert/rules/definitions_to_defs.h index e7e7f576..ab22dae3 100644 --- a/vendor/blaze/src/convert/rules/definitions_to_defs.h +++ b/vendor/blaze/src/convert/rules/definitions_to_defs.h @@ -1,15 +1,12 @@ class DefinitionsToDefs final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; DefinitionsToDefs() : SchemaTransformRule{"definitions_to_defs"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, - const sourcemeta::core::SchemaWalker &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( vocabularies.contains_any( @@ -20,16 +17,13 @@ class DefinitionsToDefs final : public SchemaTransformRule { return true; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { schema.rename("definitions", "$defs"); } - [[nodiscard]] auto rereference(const std::string_view, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &target, - const sourcemeta::core::Pointer ¤t) const - -> std::optional override { - return target.rebase(current.concat("definitions"), - current.concat("$defs")); + [[nodiscard]] auto relocations() const -> std::vector override { + return {{sourcemeta::core::Pointer{"definitions"}, + sourcemeta::core::Pointer{"$defs"}}}; } }; diff --git a/vendor/blaze/src/convert/rules/dependencies_to_dependent.h b/vendor/blaze/src/convert/rules/dependencies_to_dependent.h index 23f411a9..6ea1a40a 100644 --- a/vendor/blaze/src/convert/rules/dependencies_to_dependent.h +++ b/vendor/blaze/src/convert/rules/dependencies_to_dependent.h @@ -1,6 +1,5 @@ class DependenciesToDependent final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; DependenciesToDependent() : SchemaTransformRule{"dependencies_to_dependent"} {}; @@ -8,9 +7,7 @@ class DependenciesToDependent final : public SchemaTransformRule { condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, - const sourcemeta::core::SchemaWalker &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( vocabularies.contains_any( @@ -32,7 +29,8 @@ class DependenciesToDependent final : public SchemaTransformRule { return true; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { this->renames_.clear(); auto dependent_required{sourcemeta::core::JSON::make_object()}; auto dependent_schemas{sourcemeta::core::JSON::make_object()}; @@ -87,20 +85,8 @@ class DependenciesToDependent final : public SchemaTransformRule { schema.at("dependentRequired").into(std::move(dependent_required)); } - [[nodiscard]] auto rereference(const std::string_view, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &target, - const sourcemeta::core::Pointer ¤t) const - -> std::optional override { - for (const auto &[old_pointer, new_pointer] : this->renames_) { - const auto result{target.rebase(current.concat(old_pointer), - current.concat(new_pointer))}; - if (result != target) { - return result; - } - } - - return target; + [[nodiscard]] auto relocations() const -> std::vector override { + return {this->renames_.cbegin(), this->renames_.cend()}; } private: diff --git a/vendor/blaze/src/convert/rules/dialect_override_becomes_dollar_schema.h b/vendor/blaze/src/convert/rules/dialect_override_becomes_dollar_schema.h new file mode 100644 index 00000000..2766c425 --- /dev/null +++ b/vendor/blaze/src/convert/rules/dialect_override_becomes_dollar_schema.h @@ -0,0 +1,92 @@ +/// Core reads `x-sourcemeta-dialect-override-subschema` as a dialect, ahead of +/// any `$schema` beside it, so a document carrying one has whatever dialect it +/// names whether or not the author meant that. Settling it into a real +/// `$schema` is what makes the rest of the conversion, and the output, depend +/// on nothing but what JSON Schema itself defines. +/// +/// That is only possible where a `$schema` is the thing framing reads. A marker +/// deeper than that names a dialect for one position, which JSON Schema has no +/// way to say, so it can only be settled when it says nothing: when it names +/// the dialect that position would have had anyway. One that names another +/// dialect stays exactly where it is, and `bump_dialect` moves it along with +/// the schema it governs, because dropping it would silently reinterpret every +/// keyword underneath it +class DialectOverrideBecomesDollarSchema final : public SchemaTransformRule { +public: + DialectOverrideBecomesDollarSchema() + : SchemaTransformRule{"dialect_override_becomes_dollar_schema"} {}; + + [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const Site &site, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const + -> bool override { + if (!schema.is_object()) { + return false; + } + + // Only a value naming a dialect the ladder walks through is one framing + // would read as a dialect here. Any other value is the author's own data + // that happens to share the name, and is left exactly as written + const auto *value{schema.try_at(DIALECT_OVERRIDE_KEYWORD)}; + return value != nullptr && is_own_dialect_override(*value) && + (at_dialect_declaration(schema, site) || this->says_nothing(site)); + } + + auto begin_pass() const -> void override { this->redundant_.clear(); } + + auto plan(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaFrame::Location &location, + const Site &site, const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> void override { + this->redundant_.erase(site.pointer); + if (!schema.is_object() || !location.parent.has_value()) { + return; + } + + const auto *value{schema.try_at(DIALECT_OVERRIDE_KEYWORD)}; + if (value == nullptr || !value->is_string()) { + return; + } + + // What the position would read as without the marker is what the resource + // holding it reads as, which only the frame can say + const auto enclosing{frame.traverse(location.parent.value())}; + if (enclosing.has_value() && + without_empty_fragment(enclosing.value().get().dialect) == + without_empty_fragment(value->to_string())) { + this->redundant_.insert(site.pointer); + } + } + + auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void override { + auto dialect{schema.at(DIALECT_OVERRIDE_KEYWORD)}; + schema.erase(DIALECT_OVERRIDE_KEYWORD); + + // Only where framing would read a `$schema` does one have to be written to + // keep the dialect the document had. Anywhere else the marker said nothing + // a `$schema` could say, and said nothing at all, so the subschema takes + // the dialect of the resource holding it either way + if (!dialect.is_string() || schema.defines("$schema") || + !at_dialect_declaration(schema, site)) { + return; + } + + assign_before_first_key(schema, "$schema", std::move(dialect)); + } + +private: + // Whether the marker at this position names the dialect the position would + // read as without it + [[nodiscard]] auto says_nothing(const Site &site) const -> bool { + return this->redundant_.contains(site.pointer); + } + + mutable std::set redundant_; +}; diff --git a/vendor/blaze/src/convert/rules/draft_official_dialect_with_https.h b/vendor/blaze/src/convert/rules/draft_official_dialect_with_https.h index f33fbf10..ccab3957 100644 --- a/vendor/blaze/src/convert/rules/draft_official_dialect_with_https.h +++ b/vendor/blaze/src/convert/rules/draft_official_dialect_with_https.h @@ -1,17 +1,15 @@ class DraftOfficialDialectWithHttps final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; DraftOfficialDialectWithHttps() : SchemaTransformRule{"draft_official_dialect_with_https"} {}; - [[nodiscard]] auto - condition(const sourcemeta::core::JSON &schema, - const sourcemeta::core::JSON &, - const sourcemeta::core::SchemaVocabularies &, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &location, - const sourcemeta::core::SchemaWalker &, - const sourcemeta::core::SchemaResolver &) const -> bool override { + [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const Site &location, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const + -> bool override { using sourcemeta::core::SchemaBaseDialect; ONLY_CONTINUE_IF( location.base_dialect == SchemaBaseDialect::JSON_SCHEMA_DRAFT_7 || @@ -62,7 +60,8 @@ class DraftOfficialDialectWithHttps final : public SchemaTransformRule { return true; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { const auto &old_dialect{schema.at("$schema").to_string()}; std::string new_dialect{"http://"}; new_dialect += old_dialect.substr(8); diff --git a/vendor/blaze/src/convert/rules/draft_official_dialect_without_empty_fragment.h b/vendor/blaze/src/convert/rules/draft_official_dialect_without_empty_fragment.h index 43abf757..9ba6d873 100644 --- a/vendor/blaze/src/convert/rules/draft_official_dialect_without_empty_fragment.h +++ b/vendor/blaze/src/convert/rules/draft_official_dialect_without_empty_fragment.h @@ -1,15 +1,13 @@ class DraftOfficialDialectWithoutEmptyFragment final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; DraftOfficialDialectWithoutEmptyFragment() : SchemaTransformRule{"draft_official_dialect_without_empty_fragment"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { @@ -35,7 +33,8 @@ class DraftOfficialDialectWithoutEmptyFragment final return true; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { auto dialect{std::move(schema.at("$schema")).to_string()}; dialect += "#"; schema.at("$schema").into(sourcemeta::core::JSON{dialect}); diff --git a/vendor/blaze/src/convert/rules/empty_object_as_true.h b/vendor/blaze/src/convert/rules/empty_object_as_true.h index 34caffa5..935b0485 100644 --- a/vendor/blaze/src/convert/rules/empty_object_as_true.h +++ b/vendor/blaze/src/convert/rules/empty_object_as_true.h @@ -1,15 +1,12 @@ class EmptyObjectAsTrue final : public SchemaTransformRule { public: - using reframe_after_transform = std::false_type; EmptyObjectAsTrue() : SchemaTransformRule{"empty_object_as_true"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, - const sourcemeta::core::SchemaWalker &, + const Site &site, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF(vocabularies.contains_any( {SchemaVocabularies::Known::JSON_SCHEMA_2020_12_CORE, @@ -18,10 +15,14 @@ class EmptyObjectAsTrue final : public SchemaTransformRule { SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6, SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4}) && schema.is_object() && schema.empty()); - return true; + + // The document root is where the dialect gets written, and a boolean + // cannot carry one, so an empty object stays an object there + return !site.pointer.empty(); } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { schema.into(sourcemeta::core::JSON{true}); } }; diff --git a/vendor/blaze/src/convert/rules/enum_to_const.h b/vendor/blaze/src/convert/rules/enum_to_const.h index 111025a9..94d5b8f3 100644 --- a/vendor/blaze/src/convert/rules/enum_to_const.h +++ b/vendor/blaze/src/convert/rules/enum_to_const.h @@ -1,15 +1,12 @@ class EnumToConst final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; EnumToConst() : SchemaTransformRule{"enum_to_const"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, - const sourcemeta::core::SchemaWalker &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( vocabularies.contains_any( @@ -25,7 +22,8 @@ class EnumToConst final : public SchemaTransformRule { return true; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { auto front{schema.at("enum").front()}; schema.at("enum").into(front); schema.rename("enum", "const"); diff --git a/vendor/blaze/src/convert/rules/modern_official_dialect_with_empty_fragment.h b/vendor/blaze/src/convert/rules/modern_official_dialect_with_empty_fragment.h index e3158b1f..3ff9e187 100644 --- a/vendor/blaze/src/convert/rules/modern_official_dialect_with_empty_fragment.h +++ b/vendor/blaze/src/convert/rules/modern_official_dialect_with_empty_fragment.h @@ -1,15 +1,13 @@ class ModernOfficialDialectWithEmptyFragment final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; ModernOfficialDialectWithEmptyFragment() : SchemaTransformRule{"modern_official_dialect_with_empty_fragment"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { @@ -25,7 +23,8 @@ class ModernOfficialDialectWithEmptyFragment final return true; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { auto dialect{std::move(schema.at("$schema")).to_string()}; dialect.pop_back(); schema.at("$schema").into(sourcemeta::core::JSON{dialect}); diff --git a/vendor/blaze/src/convert/rules/modern_official_dialect_with_http.h b/vendor/blaze/src/convert/rules/modern_official_dialect_with_http.h new file mode 100644 index 00000000..971443c9 --- /dev/null +++ b/vendor/blaze/src/convert/rules/modern_official_dialect_with_http.h @@ -0,0 +1,36 @@ +class ModernOfficialDialectWithHttp final : public SchemaTransformRule { +public: + ModernOfficialDialectWithHttp() + : SchemaTransformRule{"modern_official_dialect_with_http"} {}; + + [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const Site &, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const + -> bool override { + ONLY_CONTINUE_IF(schema.is_object()); + const auto *schema_keyword{schema.try_at("$schema")}; + ONLY_CONTINUE_IF(schema_keyword && schema_keyword->is_string()); + const auto &dialect{schema_keyword->to_string()}; + ONLY_CONTINUE_IF( + dialect == "http://json-schema.org/draft/2019-09/schema" || + dialect == "http://json-schema.org/draft/2019-09/schema#" || + dialect == "http://json-schema.org/draft/2019-09/hyper-schema" || + dialect == "http://json-schema.org/draft/2019-09/hyper-schema#" || + dialect == "http://json-schema.org/draft/2020-12/schema" || + dialect == "http://json-schema.org/draft/2020-12/schema#" || + dialect == "http://json-schema.org/draft/2020-12/hyper-schema" || + dialect == "http://json-schema.org/draft/2020-12/hyper-schema#"); + return true; + } + + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { + const auto &old_dialect{schema.at("$schema").to_string()}; + std::string new_dialect{"https://"}; + new_dialect += old_dialect.substr(7); + schema.at("$schema").into(sourcemeta::core::JSON{new_dialect}); + } +}; diff --git a/vendor/blaze/src/convert/rules/openapi_official_dialect_with_date.h b/vendor/blaze/src/convert/rules/openapi_official_dialect_with_date.h new file mode 100644 index 00000000..4ea5d2ac --- /dev/null +++ b/vendor/blaze/src/convert/rules/openapi_official_dialect_with_date.h @@ -0,0 +1,32 @@ +class OpenAPIOfficialDialectWithDate final : public SchemaTransformRule { +public: + OpenAPIOfficialDialectWithDate() + : SchemaTransformRule{"openapi_official_dialect_with_date"} {}; + + [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const Site &, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const + -> bool override { + ONLY_CONTINUE_IF(schema.is_object()); + const auto *schema_keyword{schema.try_at("$schema")}; + ONLY_CONTINUE_IF(schema_keyword && schema_keyword->is_string()); + // The empty fragment does not change which dialect a URI names, and + // `normalized_official_dialect` strips one before placing a dialect on the + // ladder. Comparing the spelling as written would accept such a document as + // this dialect and then decline to settle it onto the canonical URI + const auto dialect{without_empty_fragment(schema_keyword->to_string())}; + ONLY_CONTINUE_IF( + dialect == "https://spec.openapis.org/oas/3.1/dialect/2024-10-25" || + dialect == "https://spec.openapis.org/oas/3.1/dialect/2024-11-10"); + return true; + } + + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { + schema.at("$schema").into( + sourcemeta::core::JSON{std::string{OPENAPI_3_1_DIALECT}}); + } +}; diff --git a/vendor/blaze/src/convert/rules/openapi_official_dialect_with_empty_fragment.h b/vendor/blaze/src/convert/rules/openapi_official_dialect_with_empty_fragment.h new file mode 100644 index 00000000..d3d22ace --- /dev/null +++ b/vendor/blaze/src/convert/rules/openapi_official_dialect_with_empty_fragment.h @@ -0,0 +1,36 @@ +class OpenAPIOfficialDialectWithEmptyFragment final + : public SchemaTransformRule { +public: + OpenAPIOfficialDialectWithEmptyFragment() + : SchemaTransformRule{"openapi_official_dialect_with_empty_fragment"} {}; + + [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const Site &, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const + -> bool override { + ONLY_CONTINUE_IF(schema.is_object()); + const auto *schema_keyword{schema.try_at("$schema")}; + ONLY_CONTINUE_IF(schema_keyword && schema_keyword->is_string()); + const auto &dialect{schema_keyword->to_string()}; + // Only the spellings this rule owns. The dated 3.1 URIs are absent on + // purpose: `OpenAPIOfficialDialectWithDate` is registered ahead of this + // rule and strips the empty fragment before comparing, so it settles one + // of those onto the undated URI whether or not a fragment was written, + // and this rule's condition is asked again right before its transform. + // Listing them here would be a branch nothing can reach + ONLY_CONTINUE_IF( + dialect == "https://spec.openapis.org/oas/3.1/dialect/base#" || + dialect == "https://spec.openapis.org/oas/3.2/dialect/2025-09-17#"); + return true; + } + + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { + auto dialect{std::move(schema.at("$schema")).to_string()}; + dialect.pop_back(); + schema.at("$schema").into(sourcemeta::core::JSON{dialect}); + } +}; diff --git a/vendor/blaze/src/convert/rules/prefix_promoted_2020_12_keywords.h b/vendor/blaze/src/convert/rules/prefix_promoted_2020_12_keywords.h index 30705e35..6f249985 100644 --- a/vendor/blaze/src/convert/rules/prefix_promoted_2020_12_keywords.h +++ b/vendor/blaze/src/convert/rules/prefix_promoted_2020_12_keywords.h @@ -1,6 +1,5 @@ class PrefixPromoted202012Keywords final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; PrefixPromoted202012Keywords() : SchemaTransformRule{"prefix_promoted_2020_12_keywords"} {}; @@ -8,18 +7,20 @@ class PrefixPromoted202012Keywords final : public SchemaTransformRule { condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, - const sourcemeta::core::SchemaWalker &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF(vocabularies.contains( SchemaVocabularies::Known::JSON_SCHEMA_2019_09_CORE) && schema.is_object()); + ONLY_CONTINUE_IF( + promoted_keyword_is_author_data(schema, PROMOTING_DIALECT)); + return schema.defines_any({"prefixItems", "$dynamicAnchor", "$dynamicRef"}); } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { this->renames_.clear(); for (const auto &keyword : KEYWORDS) { const std::string keyword_name{keyword}; @@ -37,24 +38,21 @@ class PrefixPromoted202012Keywords final : public SchemaTransformRule { } } - [[nodiscard]] auto rereference(const std::string_view, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &target, - const sourcemeta::core::Pointer ¤t) const - -> std::optional override { + [[nodiscard]] auto relocations() const -> std::vector override { + std::vector result; + result.reserve(this->renames_.size()); for (const auto &[old_name, new_name] : this->renames_) { - const auto result{ - target.rebase(current.concat(sourcemeta::core::Pointer{old_name}), - current.concat(sourcemeta::core::Pointer{new_name}))}; - if (result != target) { - return result; - } + result.emplace_back(sourcemeta::core::Pointer{old_name}, + sourcemeta::core::Pointer{new_name}); } - return target; + return result; } private: + static constexpr std::string_view PROMOTING_DIALECT{ + "https://json-schema.org/draft/2020-12/schema"}; + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) static inline const std::array KEYWORDS{ {"prefixItems", "$dynamicAnchor", "$dynamicRef"}}; diff --git a/vendor/blaze/src/convert/rules/prefix_promoted_draft_2019_09_keywords.h b/vendor/blaze/src/convert/rules/prefix_promoted_draft_2019_09_keywords.h index 047a43da..12253f6e 100644 --- a/vendor/blaze/src/convert/rules/prefix_promoted_draft_2019_09_keywords.h +++ b/vendor/blaze/src/convert/rules/prefix_promoted_draft_2019_09_keywords.h @@ -1,23 +1,25 @@ class PrefixPromoted201909Keywords final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; - PrefixPromoted201909Keywords() - : SchemaTransformRule{"prefix_promoted_2019_09_keywords"} {}; + PrefixPromoted201909Keywords( + std::vector &&keywords_left_alone = {}) + : SchemaTransformRule{"prefix_promoted_2019_09_keywords"}, + keywords_left_alone_{std::move(keywords_left_alone)} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, - const sourcemeta::core::SchemaWalker &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7) && schema.is_object()); + ONLY_CONTINUE_IF( + promoted_keyword_is_author_data(schema, PROMOTING_DIALECT)); + for (const auto &keyword : KEYWORDS) { - if (schema.defines(keyword)) { + if (schema.defines(keyword) && !this->is_left_alone(schema, keyword)) { return true; } } @@ -25,11 +27,13 @@ class PrefixPromoted201909Keywords final : public SchemaTransformRule { return false; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { this->renames_.clear(); for (const auto &keyword : KEYWORDS) { const std::string keyword_name{keyword}; - if (!schema.defines(keyword_name)) { + if (!schema.defines(keyword_name) || + this->is_left_alone(schema, keyword)) { continue; } @@ -43,24 +47,21 @@ class PrefixPromoted201909Keywords final : public SchemaTransformRule { } } - [[nodiscard]] auto rereference(const std::string_view, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &target, - const sourcemeta::core::Pointer ¤t) const - -> std::optional override { + [[nodiscard]] auto relocations() const -> std::vector override { + std::vector result; + result.reserve(this->renames_.size()); for (const auto &[old_name, new_name] : this->renames_) { - const auto result{ - target.rebase(current.concat(sourcemeta::core::Pointer{old_name}), - current.concat(sourcemeta::core::Pointer{new_name}))}; - if (result != target) { - return result; - } + result.emplace_back(sourcemeta::core::Pointer{old_name}, + sourcemeta::core::Pointer{new_name}); } - return target; + return result; } private: + static constexpr std::string_view PROMOTING_DIALECT{ + "https://json-schema.org/draft/2019-09/schema"}; + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) static inline const std::array KEYWORDS{ {"$anchor", "$recursiveAnchor", "$recursiveRef", "$vocabulary", "$defs", @@ -68,5 +69,28 @@ class PrefixPromoted201909Keywords final : public SchemaTransformRule { "unevaluatedProperties", "maxContains", "minContains", "contentSchema", "deprecated"}}; + // A keyword the caller asked to carry across as the keyword it already was, + // rather than as the author data it would otherwise be read as. + // + // The caller names the dialect of a document, not of each position in it, and + // a document may embed a resource written in another dialect whose members + // are exactly the author data this rule exists to protect. What tells the two + // apart is the value: the source dialect constrained the keyword it named, so + // one carrying a shape that dialect never allowed was never that keyword + [[nodiscard]] auto is_left_alone(const sourcemeta::core::JSON &schema, + const std::string_view keyword) const + -> bool { + if (std::ranges::find(this->keywords_left_alone_, keyword) == + this->keywords_left_alone_.cend()) { + return false; + } + + // Only a boolean is the annotation this names. A value of any other shape + // was never that keyword, whatever it is called, and carrying it across as + // one would leave an output the target's own meta-schema rejects + return schema.at(sourcemeta::core::JSON::String{keyword}).is_boolean(); + } + + const std::vector keywords_left_alone_; mutable std::unordered_map renames_; }; diff --git a/vendor/blaze/src/convert/rules/prefix_promoted_draft_4_keywords.h b/vendor/blaze/src/convert/rules/prefix_promoted_draft_4_keywords.h index 737da6f1..8b4a31ac 100644 --- a/vendor/blaze/src/convert/rules/prefix_promoted_draft_4_keywords.h +++ b/vendor/blaze/src/convert/rules/prefix_promoted_draft_4_keywords.h @@ -1,6 +1,5 @@ class PrefixPromotedDraft4Keywords final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; PrefixPromotedDraft4Keywords() : SchemaTransformRule{"prefix_promoted_draft_4_keywords"} {}; @@ -8,14 +7,15 @@ class PrefixPromotedDraft4Keywords final : public SchemaTransformRule { condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, - const sourcemeta::core::SchemaWalker &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3) && schema.is_object()); + ONLY_CONTINUE_IF( + promoted_keyword_is_author_data(schema, PROMOTING_DIALECT)); + for (const auto &keyword : KEYWORDS) { if (schema.defines(keyword)) { return true; @@ -25,7 +25,8 @@ class PrefixPromotedDraft4Keywords final : public SchemaTransformRule { return false; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { this->renames_.clear(); for (const auto &keyword : KEYWORDS) { const std::string keyword_name{keyword}; @@ -43,24 +44,21 @@ class PrefixPromotedDraft4Keywords final : public SchemaTransformRule { } } - [[nodiscard]] auto rereference(const std::string_view, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &target, - const sourcemeta::core::Pointer ¤t) const - -> std::optional override { + [[nodiscard]] auto relocations() const -> std::vector override { + std::vector result; + result.reserve(this->renames_.size()); for (const auto &[old_name, new_name] : this->renames_) { - const auto result{ - target.rebase(current.concat(sourcemeta::core::Pointer{old_name}), - current.concat(sourcemeta::core::Pointer{new_name}))}; - if (result != target) { - return result; - } + result.emplace_back(sourcemeta::core::Pointer{old_name}, + sourcemeta::core::Pointer{new_name}); } - return target; + return result; } private: + static constexpr std::string_view PROMOTING_DIALECT{ + "http://json-schema.org/draft-04/schema#"}; + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) static inline const std::array KEYWORDS{ {"multipleOf", "maxProperties", "minProperties", "allOf", "anyOf", diff --git a/vendor/blaze/src/convert/rules/prefix_promoted_draft_6_keywords.h b/vendor/blaze/src/convert/rules/prefix_promoted_draft_6_keywords.h index 7b5576f0..24ade6f8 100644 --- a/vendor/blaze/src/convert/rules/prefix_promoted_draft_6_keywords.h +++ b/vendor/blaze/src/convert/rules/prefix_promoted_draft_6_keywords.h @@ -1,6 +1,5 @@ class PrefixPromotedDraft6Keywords final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; PrefixPromotedDraft6Keywords() : SchemaTransformRule{"prefix_promoted_draft_6_keywords"} {}; @@ -8,14 +7,15 @@ class PrefixPromotedDraft6Keywords final : public SchemaTransformRule { condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, - const sourcemeta::core::SchemaWalker &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4) && schema.is_object()); + ONLY_CONTINUE_IF( + promoted_keyword_is_author_data(schema, PROMOTING_DIALECT)); + for (const auto &keyword : KEYWORDS) { if (schema.defines(keyword)) { return true; @@ -25,7 +25,8 @@ class PrefixPromotedDraft6Keywords final : public SchemaTransformRule { return false; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { this->renames_.clear(); for (const auto &keyword : KEYWORDS) { const std::string keyword_name{keyword}; @@ -43,24 +44,21 @@ class PrefixPromotedDraft6Keywords final : public SchemaTransformRule { } } - [[nodiscard]] auto rereference(const std::string_view, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &target, - const sourcemeta::core::Pointer ¤t) const - -> std::optional override { + [[nodiscard]] auto relocations() const -> std::vector override { + std::vector result; + result.reserve(this->renames_.size()); for (const auto &[old_name, new_name] : this->renames_) { - const auto result{ - target.rebase(current.concat(sourcemeta::core::Pointer{old_name}), - current.concat(sourcemeta::core::Pointer{new_name}))}; - if (result != target) { - return result; - } + result.emplace_back(sourcemeta::core::Pointer{old_name}, + sourcemeta::core::Pointer{new_name}); } - return target; + return result; } private: + static constexpr std::string_view PROMOTING_DIALECT{ + "http://json-schema.org/draft-06/schema#"}; + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) static inline const std::array KEYWORDS{ {"$id", "const", "contains", "propertyNames", "examples"}}; diff --git a/vendor/blaze/src/convert/rules/prefix_promoted_draft_7_keywords.h b/vendor/blaze/src/convert/rules/prefix_promoted_draft_7_keywords.h index 89b9ab41..9fccf545 100644 --- a/vendor/blaze/src/convert/rules/prefix_promoted_draft_7_keywords.h +++ b/vendor/blaze/src/convert/rules/prefix_promoted_draft_7_keywords.h @@ -1,23 +1,25 @@ class PrefixPromotedDraft7Keywords final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; - PrefixPromotedDraft7Keywords() - : SchemaTransformRule{"prefix_promoted_draft_7_keywords"} {}; + PrefixPromotedDraft7Keywords( + std::vector &&keywords_left_alone = {}) + : SchemaTransformRule{"prefix_promoted_draft_7_keywords"}, + keywords_left_alone_{std::move(keywords_left_alone)} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &, - const sourcemeta::core::SchemaFrame::Location &, - const sourcemeta::core::SchemaWalker &, + const Site &, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6) && schema.is_object()); + ONLY_CONTINUE_IF( + promoted_keyword_is_author_data(schema, PROMOTING_DIALECT)); + for (const auto &keyword : KEYWORDS) { - if (schema.defines(keyword)) { + if (schema.defines(keyword) && !this->is_left_alone(schema, keyword)) { return true; } } @@ -25,11 +27,13 @@ class PrefixPromotedDraft7Keywords final : public SchemaTransformRule { return false; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { this->renames_.clear(); for (const auto &keyword : KEYWORDS) { const std::string keyword_name{keyword}; - if (!schema.defines(keyword_name)) { + if (!schema.defines(keyword_name) || + this->is_left_alone(schema, keyword)) { continue; } @@ -43,28 +47,48 @@ class PrefixPromotedDraft7Keywords final : public SchemaTransformRule { } } - [[nodiscard]] auto rereference(const std::string_view, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &target, - const sourcemeta::core::Pointer ¤t) const - -> std::optional override { + [[nodiscard]] auto relocations() const -> std::vector override { + std::vector result; + result.reserve(this->renames_.size()); for (const auto &[old_name, new_name] : this->renames_) { - const auto result{ - target.rebase(current.concat(sourcemeta::core::Pointer{old_name}), - current.concat(sourcemeta::core::Pointer{new_name}))}; - if (result != target) { - return result; - } + result.emplace_back(sourcemeta::core::Pointer{old_name}, + sourcemeta::core::Pointer{new_name}); } - return target; + return result; } private: + static constexpr std::string_view PROMOTING_DIALECT{ + "http://json-schema.org/draft-07/schema#"}; + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) static inline const std::array KEYWORDS{ {"$comment", "if", "then", "else", "readOnly", "writeOnly", "contentMediaType", "contentEncoding"}}; + // A keyword the caller asked to carry across as the keyword it already was, + // rather than as the author data it would otherwise be read as. + // + // The caller names the dialect of a document, not of each position in it, and + // a document may embed a resource written in another dialect whose members + // are exactly the author data this rule exists to protect. What tells the two + // apart is the value: the source dialect constrained the keyword it named, so + // one carrying a shape that dialect never allowed was never that keyword + [[nodiscard]] auto is_left_alone(const sourcemeta::core::JSON &schema, + const std::string_view keyword) const + -> bool { + if (std::ranges::find(this->keywords_left_alone_, keyword) == + this->keywords_left_alone_.cend()) { + return false; + } + + // Only a boolean is the annotation these two name. A value of any other + // shape was never that keyword, whatever it is called, and carrying it + // across as one would leave an output the target's own meta-schema rejects + return schema.at(sourcemeta::core::JSON::String{keyword}).is_boolean(); + } + + const std::vector keywords_left_alone_; mutable std::unordered_map renames_; }; diff --git a/vendor/blaze/src/convert/rules/prefix_promoted_openapi_3_1_keywords.h b/vendor/blaze/src/convert/rules/prefix_promoted_openapi_3_1_keywords.h new file mode 100644 index 00000000..d39b66e1 --- /dev/null +++ b/vendor/blaze/src/convert/rules/prefix_promoted_openapi_3_1_keywords.h @@ -0,0 +1,101 @@ +class PrefixPromotedOpenAPI31Keywords final : public SchemaTransformRule { +public: + PrefixPromotedOpenAPI31Keywords( + std::vector &&keywords_left_alone = {}) + : SchemaTransformRule{"prefix_promoted_openapi_3_1_keywords"}, + keywords_left_alone_{std::move(keywords_left_alone)} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &vocabularies, + const Site &, const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> bool override { + // The OpenAPI dialect requires every 2020-12 vocabulary, so the core + // vocabulary alone cannot tell the two rungs apart. Without the second + // half, a subschema of a document already on the OpenAPI dialect declares + // no dialect of its own, reads as sitting below the rung, and has the + // keyword that is doing its job renamed out from under it + ONLY_CONTINUE_IF(vocabularies.contains( + SchemaVocabularies::Known::JSON_SCHEMA_2020_12_CORE) && + !vocabularies.contains_any( + {SchemaVocabularies::Known::OPENAPI_3_1_BASE, + SchemaVocabularies::Known::OPENAPI_3_2_BASE}) && + schema.is_object()); + + ONLY_CONTINUE_IF( + promoted_keyword_is_author_data(schema, OPENAPI_3_1_DIALECT)); + + for (const auto &keyword : KEYWORDS) { + if (schema.defines(sourcemeta::core::JSON::String{keyword}) && + !this->is_left_alone(schema, keyword)) { + return true; + } + } + + return false; + } + + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { + this->renames_.clear(); + for (const auto &keyword : KEYWORDS) { + const std::string keyword_name{keyword}; + if (!schema.defines(keyword_name) || + this->is_left_alone(schema, keyword)) { + continue; + } + + std::string prefixed_name{"x-" + keyword_name}; + while (schema.defines(prefixed_name)) { + prefixed_name.insert(0, "x-"); + } + + this->renames_.emplace(keyword_name, prefixed_name); + schema.rename(keyword_name, std::move(prefixed_name)); + } + } + + [[nodiscard]] auto relocations() const -> std::vector override { + std::vector result; + result.reserve(this->renames_.size()); + for (const auto &[old_name, new_name] : this->renames_) { + result.emplace_back(sourcemeta::core::Pointer{old_name}, + sourcemeta::core::Pointer{new_name}); + } + + return result; + } + +private: + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) + static inline const std::array KEYWORDS{ + {"example", "discriminator", "externalDocs", "xml"}}; + + // A keyword the caller asked to carry across as the keyword it already was, + // rather than as the author data it would otherwise be read as. + // + // The caller names the dialect of a document, not of each position in it, and + // a document may embed a resource written in another dialect whose members + // are exactly the author data this rule exists to protect. What tells the two + // apart is the value: the source dialect constrained the keyword it named, so + // one carrying a shape that dialect never allowed was never that keyword + [[nodiscard]] auto is_left_alone(const sourcemeta::core::JSON &schema, + const std::string_view keyword) const + -> bool { + if (std::ranges::find(this->keywords_left_alone_, keyword) == + this->keywords_left_alone_.cend()) { + return false; + } + + // Three of these name an object and `example` names anything at all. A + // value of another shape was never the keyword it is named after, and + // carrying it across as one would leave an output the target's own + // meta-schema rejects + return keyword == "example" || + schema.at(sourcemeta::core::JSON::String{keyword}).is_object(); + } + + const std::vector keywords_left_alone_; + mutable std::unordered_map renames_; +}; diff --git a/vendor/blaze/src/convert/rules/sanitize_draft_4_anchors.h b/vendor/blaze/src/convert/rules/sanitize_draft_4_anchors.h new file mode 100644 index 00000000..2b162524 --- /dev/null +++ b/vendor/blaze/src/convert/rules/sanitize_draft_4_anchors.h @@ -0,0 +1,195 @@ +/// Draft 4 lets an identifier carry any fragment, while Draft 6 restricts a +/// plain-name fragment to a narrow grammar. Renaming such an anchor means +/// moving every reference that names it, and a reference may name an anchor in +/// a resource other than the one it sits in, so this runs once over the whole +/// document rather than per resource. No single resource owns the problem: +/// whichever one is chosen to own it, the references that live outside it are +/// left pointing at a name that moved +class SanitizeDraft4Anchors final : public SchemaTransformRule { +public: + // References to an anchor may sit anywhere, so this fires at the document + // root and writes across it. Only Draft 4 subschemas are ever touched, which + // is what the driver's own guard is there to protect + using writes_outside_itself = std::true_type; + + SanitizeDraft4Anchors() : SchemaTransformRule{"sanitize_draft_4_anchors"} {}; + + auto plan(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaFrame::Location &, const Site &site, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> void override { + if (!site.pointer.empty() || !schema.is_object()) { + return; + } + + this->moves_.clear(); + + // Which anchors move, and what each becomes, settled before anything is + // written so that a reference is matched against the name it still has + std::map renamed; + plan_anchor_renames(schema, frame, renamed, this->moves_); + if (renamed.empty()) { + this->moves_.clear(); + return; + } + + plan_reference_rewrites(frame, renamed, this->moves_); + } + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &, const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, const Site &site, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> bool override { + // What was planned names positions across the whole document, so it is + // written from the root and nowhere else + return site.pointer.empty() && !this->moves_.empty(); + } + + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { + for (const auto &[pointer, keyword, value] : this->moves_) { + sourcemeta::core::get(schema, pointer).assign(keyword, value); + } + } + +private: + // The subschema to write into, the keyword to write, and the value. Holding + // the parent rather than the keyword's own pointer keeps the write a plain + // assignment + using Move = std::tuple; + + // The name a reference has to spell out to be following this anchor, and + // the one it becomes + struct Rename { + std::string from; + std::string to; + }; + + mutable std::vector moves_; + + static auto + plan_anchor_renames(const sourcemeta::core::JSON &schema, + const sourcemeta::core::SchemaFrame &frame, + std::map &renamed, + std::vector &moves) -> void { + // Every anchor each resource already holds, so that a sanitised name never + // lands on one that is taken + std::map> taken; + + // Framing reports one subschema once per location it holds, and an anchor + // adds a second location at the same pointer. Keying by pointer is what + // stops the repeat visit from seeing the name the first one reserved and + // prefixing around it + std::map> + pending; + frame.for_each_subschema( + [&schema, &taken, &pending]( + const sourcemeta::core::SchemaFrame::Location &entry) -> void { + // What dialect a subschema is read as is what decides whether its + // identifier carries a free-form fragment, and asking the subschema + // directly covers a root that never named itself + if (base_dialect_position(entry.base_dialect) != + dialect_position(DRAFT_4_DIALECT)) { + return; + } + + std::string base{entry.base}; + + auto pointer{sourcemeta::core::to_pointer(entry.pointer)}; + const auto &subschema{sourcemeta::core::get(schema, pointer)}; + if (!subschema.is_object()) { + return; + } + + const auto *identifier{subschema.try_at("id")}; + if (identifier == nullptr) { + return; + } + + const auto fragment{identifier_fragment(*identifier)}; + if (!fragment.has_value() || fragment.value().empty()) { + return; + } + + taken[base].insert(fragment.value()); + if (!is_draft_6_plain_name(fragment.value())) { + pending.insert_or_assign( + std::move(pointer), + std::make_pair(std::move(base), fragment.value())); + } + }); + + for (const auto &[pointer, entry] : pending) { + const auto &[base, original] = entry; + auto in_use{taken.at(base)}; + in_use.erase(original); + auto sanitized{sanitize_anchor_with_policy(original, in_use, + draft_6_anchor_policy())}; + taken.at(base).insert(sanitized); + + const auto &written{ + sourcemeta::core::get(schema, pointer).at("id").to_string()}; + auto replacement{sourcemeta::core::JSON{ + std::string{without_fragment_as_written(written)} + "#" + sanitized}}; + moves.emplace_back(pointer, "id", std::move(replacement)); + renamed.emplace(pointer, + Rename{.from = original, .to = std::move(sanitized)}); + } + } + + static auto plan_reference_rewrites( + const sourcemeta::core::SchemaFrame &frame, + const std::map &renamed, + std::vector &moves) -> void { + frame.for_each_reference( + [&frame, &renamed, &moves]( + const sourcemeta::core::SchemaReferenceType, + const sourcemeta::core::WeakPointer &origin, + const sourcemeta::core::SchemaFrame::Reference &reference) -> void { + // A reference that names no fragment points at the resource itself + // rather than at anything inside it + if (!reference.fragment.has_value() || + reference.fragment.value().empty()) { + return; + } + + // Letting framing resolve the reference is what keeps the two sides + // comparable. Matching the destination against a name built here + // would miss a fragment that differs only in normalisation, such as + // the case of a percent-encoded octet + const auto target{frame.traverse(reference.destination)}; + if (!target.has_value()) { + return; + } + + const auto match{renamed.find( + sourcemeta::core::to_pointer(target.value().get().pointer))}; + if (match == renamed.cend()) { + return; + } + + // Landing on a renamed subschema is not enough. A JSON Pointer + // fragment reaches it by location and keeps working whatever the + // anchor is called, while a Draft 4 anchor name may itself be shaped + // like a pointer, so what decides is whether the reference spells + // out the name that is moving + if (reference.fragment.value() != match->second.from) { + return; + } + + auto replacement{sourcemeta::core::JSON{ + std::string{without_fragment_as_written(reference.original)} + + "#" + match->second.to}}; + auto holder{sourcemeta::core::to_pointer(origin)}; + const auto keyword{holder.back().to_property()}; + holder.pop_back(); + moves.emplace_back(std::move(holder), keyword, + std::move(replacement)); + }); + } +}; diff --git a/vendor/blaze/src/convert/rules/shadow_stray_dialect_declaration.h b/vendor/blaze/src/convert/rules/shadow_stray_dialect_declaration.h new file mode 100644 index 00000000..1468c8e9 --- /dev/null +++ b/vendor/blaze/src/convert/rules/shadow_stray_dialect_declaration.h @@ -0,0 +1,48 @@ +class ShadowStrayDialectDeclaration final : public SchemaTransformRule { +public: + ShadowStrayDialectDeclaration() + : SchemaTransformRule{"shadow_stray_dialect_declaration"} {}; + + [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const Site &location, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const + -> bool override { + ONLY_CONTINUE_IF(schema.is_object() && schema.defines("$schema") && + schema.at("$schema").is_string()); + + // A dialect is declared where a resource begins and nowhere else, which + // is what both Draft 7 core 7 and 2019-09 core 8.1.1 say and what framing + // implements. A `$schema` anywhere else is the author's data, and reading + // it as a dialect is what leaves this module and framing disagreeing about + // what dialect a subschema is on: the rules wait for it to move while + // framing never put it there + // A dialect may be declared where a resource begins, which is where the + // subschema's own pointer and the offset of its resource meet. Asking the + // location's type instead does not work here: the driver walks subschema + // locations, and a resource root has one of those too + ONLY_CONTINUE_IF(!location.pointer.empty() && + location.pointer.size() != location.relative_pointer); + + // The ladder's own marker may sit beside this `$schema`, and it is left + // alone: it records the dialect reached so far, which renaming a + // declaration that declares nothing does not disturb. Standing down while + // one is present would mean shadowing the same `$schema` only once the + // marker had gone, so converting a document twice would not agree with + // converting it once + + return true; + } + + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { + std::string shadowed{"x-$schema"}; + while (schema.defines(shadowed)) { + shadowed.insert(0, "x-"); + } + + schema.rename("$schema", std::move(shadowed)); + } +}; diff --git a/vendor/blaze/src/convert/rules/upgrade_2019_09_to_2020_12.h b/vendor/blaze/src/convert/rules/upgrade_2019_09_to_2020_12.h index def871b5..2c90f88b 100644 --- a/vendor/blaze/src/convert/rules/upgrade_2019_09_to_2020_12.h +++ b/vendor/blaze/src/convert/rules/upgrade_2019_09_to_2020_12.h @@ -1,21 +1,38 @@ class Upgrade201909To202012 final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; Upgrade201909To202012() : SchemaTransformRule{"upgrade_2019_09_to_2020_12"} {}; - [[nodiscard]] auto condition( - const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &root, - const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &frame, - const sourcemeta::core::SchemaFrame::Location &location, - const sourcemeta::core::SchemaWalker &walker, - const sourcemeta::core::SchemaResolver &resolver) const -> bool override { - this->sanitize_pending_ = false; + auto begin_pass() const -> void override { + this->plans_.clear(); + this->unevaluated_items_.reset(); + this->recursive_anchors_.clear(); + this->pending_sanitizations_.clear(); + } - ONLY_CONTINUE_IF(vocabularies.contains( - SchemaVocabularies::Known::JSON_SCHEMA_2019_09_CORE) && - schema.is_object()); + auto plan(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &root, + const sourcemeta::core::SchemaVocabularies &vocabularies, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaFrame::Location &location, + const Site &site, const sourcemeta::core::SchemaWalker &walker, + const sourcemeta::core::SchemaResolver &resolver) const + -> void override { + this->reset(); + + if (!vocabularies.contains( + SchemaVocabularies::Known::JSON_SCHEMA_2019_09_CORE) || + !schema.is_object()) { + return; + } + + // Answered here rather than beside its use, because more than one branch + // below returns without reaching that point, and a stale answer from the + // subschema visited before this one would decide whether a referenced + // schema is kept + this->additional_items_is_referenced_ = + schema.defines("additionalItems") && + compute_additional_items_is_referenced(frame, location); const bool is_resource_scope{ location.type == @@ -26,57 +43,105 @@ class Upgrade201909To202012 final : public SchemaTransformRule { compute_anchor_sanitization(root, frame, location); if (!this->anchor_renames_.empty() || !this->anchor_ref_rewrites_.empty()) { - this->descendant_has_pending_pattern_ = - any_descendant_has_pending_pattern(root, frame, location); this->resource_has_recursive_anchor_ = - compute_resource_has_recursive_anchor(root, frame, location); + this->resource_has_recursive_anchor(root, frame, location, site); this->anchor_at_resource_root_ = is_resource_root(frame, location); if (needs_dynamic_anchor_name(schema)) { this->dynamic_anchor_name_ = compute_dynamic_anchor_name(root); } this->document_has_unevaluated_items_ = - compute_document_has_unevaluated_items(root, frame, walker, - resolver); + this->document_has_unevaluated_items(root, frame, walker, resolver); this->is_inside_contains_wrapper_ = false; this->sanitize_pending_ = true; - return true; + this->record(site); + return; } } - if (enclosing_resource_has_pending_sanitization(root, frame, location)) { - return false; + if (this->resource_has_pending_sanitization(root, frame, location, site)) { + return; } this->is_inside_contains_wrapper_ = - location_inside_contains_wrapper(location); - - if (any_descendant_has_pending_pattern(root, frame, location)) { - return false; - } + location_inside_contains_wrapper(schema, location); this->resource_has_recursive_anchor_ = - compute_resource_has_recursive_anchor(root, frame, location); + this->resource_has_recursive_anchor(root, frame, location, site); this->anchor_at_resource_root_ = is_resource_root(frame, location); if (needs_dynamic_anchor_name(schema)) { this->dynamic_anchor_name_ = compute_dynamic_anchor_name(root); } this->document_has_unevaluated_items_ = - compute_document_has_unevaluated_items(root, frame, walker, resolver); - return true; + this->document_has_unevaluated_items(root, frame, walker, resolver); + + if (this->has_work_here(schema, site)) { + this->record(site); + } + } + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &, const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, const Site &site, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> bool override { + return this->load(site); } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + // Mirrors what `transform` below will actually do. A condition that claims + // work the transform then declines to do leaves the position scheduled for + // every pass, so the two have to agree keyword for keyword + [[nodiscard]] auto has_work_here(const sourcemeta::core::JSON &schema, + const Site &site) const -> bool { if (this->sanitize_pending_) { - apply_anchor_sanitization(schema); - if (this->descendant_has_pending_pattern_) { - return; - } + return true; + } + + if (schema.defines("$recursiveAnchor") && + schema.at("$recursiveAnchor").is_boolean()) { + return true; } + if (schema.defines_any({"$recursiveRef", "additionalItems"})) { + return true; + } + + if (schema.defines("items") && schema.at("items").is_array()) { + return true; + } + + if (schema.defines("contains") && !this->is_inside_contains_wrapper_ && + this->document_has_unevaluated_items_) { + return true; + } + + if (vocabulary_has_mappable_uri(schema)) { + return true; + } + + return at_dialect_declaration(schema, site); + } + + auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void override { + [[maybe_unused]] const auto planned{this->load(site)}; + assert(planned); + + // Cleared before anything may return, because the driver reads what this + // records straight after and would otherwise journal the moves of whatever + // position was rewritten before this one, as moves of this one this->renames_.clear(); + // Renaming this resource's anchors is what the pass does here, and it + // held the subschemas under it back while doing so. Moving the dialect as + // well would leave them reading as the target while their own rung work is + // still outstanding, and nothing would come back for it + if (this->sanitize_pending_) { + apply_anchor_sanitization(schema); + return; + } + if (schema.defines("$recursiveAnchor") && schema.at("$recursiveAnchor").is_boolean()) { // Only the root of a schema resource can carry a recursive anchor. @@ -115,7 +180,11 @@ class Upgrade201909To202012 final : public SchemaTransformRule { schema.rename("additionalItems", "items"); } } else if (schema.defines("additionalItems")) { - schema.erase("additionalItems"); + if (this->additional_items_is_referenced_) { + move_to_defs(schema, "additionalItems", this->renames_); + } else { + schema.erase("additionalItems"); + } } if (schema.defines("contains") && !this->is_inside_contains_wrapper_ && @@ -170,29 +239,11 @@ class Upgrade201909To202012 final : public SchemaTransformRule { rewrite_vocabulary(schema); - if (schema.defines("$schema") && schema.at("$schema").is_string() && - schema.at("$schema").to_string() == DRAFT_2019_09_URL) { - schema.assign("$schema", sourcemeta::core::JSON{DRAFT_2020_12_URL}); - drop_dialect_overrides(schema, true, DRAFT_2020_12_URL); - } else { - mark_dialect_override(schema, DRAFT_2020_12_URL); - } - } - - [[nodiscard]] auto rereference(const std::string_view, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &target, - const sourcemeta::core::Pointer ¤t) const - -> std::optional override { - for (const auto &[old_pointer, new_pointer] : this->renames_) { - const auto result{target.rebase(current.concat(old_pointer), - current.concat(new_pointer))}; - if (result != target) { - return result; - } - } + bump_dialect(schema, site, DRAFT_2020_12_URL); + } - return target; + [[nodiscard]] auto relocations() const -> std::vector override { + return {this->renames_.cbegin(), this->renames_.cend()}; } private: @@ -297,6 +348,120 @@ class Upgrade201909To202012 final : public SchemaTransformRule { std::string new_value; }; + // Beside an `items` that is not an array this keyword asserts nothing, and + // 2020-12 does not define it at all, so dropping it loses nothing unless a + // reference reaches the schema it holds. Such a reference uses it as a + // definition, and `$defs` is where 2020-12 keeps a schema it does not apply. + // A reference naming something nested inside it counts too, since erasing + // the schema takes everything under it along + static auto compute_additional_items_is_referenced( + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaFrame::Location &location) -> bool { + const auto wanted{ + sourcemeta::core::to_pointer(location.pointer) + .concat(sourcemeta::core::Pointer{"additionalItems"})}; + return frame.any_reference( + [&frame, &wanted]( + const sourcemeta::core::SchemaReferenceType, + const sourcemeta::core::WeakPointer &, + const sourcemeta::core::SchemaFrame::Reference &reference) -> bool { + const auto destination{frame.traverse(reference.destination)}; + return destination.has_value() && + sourcemeta::core::to_pointer(destination.value().get().pointer) + .starts_with(wanted); + }); + } + + static auto + move_to_defs(sourcemeta::core::JSON &schema, const std::string &keyword, + std::vector> &renames) + -> void { + if (!schema.defines("$defs")) { + schema.assign("$defs", sourcemeta::core::JSON::make_object()); + } else if (!schema.at("$defs").is_object()) { + // Nothing can be put inside a `$defs` that is not an object, and a + // document carrying one is not a valid schema of its own dialect + // either, so the keyword stays where its author wrote it + return; + } + + std::string name{keyword}; + while (schema.at("$defs").defines(name)) { + name.insert(0, "x-"); + } + + renames.emplace_back(sourcemeta::core::Pointer{keyword}, + sourcemeta::core::Pointer{"$defs", name}); + schema.at("$defs").assign(name, schema.at(keyword)); + schema.erase(keyword); + } + + // What the frame had to say about one position, kept as values rather than + // as views, so that it still reads correctly once the document has been + // edited. The working members below are loaded from this just before the + // position is answered for or rewritten + struct Plan { + bool sanitize; + bool additional_items_is_referenced; + bool resource_has_recursive_anchor; + bool anchor_at_resource_root; + bool is_inside_contains_wrapper; + bool document_has_unevaluated_items; + std::string dynamic_anchor_name; + std::vector anchor_renames; + std::vector anchor_ref_rewrites; + }; + + auto reset() const -> void { + this->sanitize_pending_ = false; + this->additional_items_is_referenced_ = false; + this->resource_has_recursive_anchor_ = false; + this->anchor_at_resource_root_ = false; + this->is_inside_contains_wrapper_ = false; + this->document_has_unevaluated_items_ = false; + this->anchor_renames_.clear(); + this->anchor_ref_rewrites_.clear(); + } + + auto record(const Site &site) const -> void { + this->plans_.insert_or_assign( + site.pointer, + Plan{.sanitize = this->sanitize_pending_, + .additional_items_is_referenced = + this->additional_items_is_referenced_, + .resource_has_recursive_anchor = + this->resource_has_recursive_anchor_, + .anchor_at_resource_root = this->anchor_at_resource_root_, + .is_inside_contains_wrapper = this->is_inside_contains_wrapper_, + .document_has_unevaluated_items = + this->document_has_unevaluated_items_, + .dynamic_anchor_name = this->dynamic_anchor_name_, + .anchor_renames = this->anchor_renames_, + .anchor_ref_rewrites = this->anchor_ref_rewrites_}); + } + + [[nodiscard]] auto load(const Site &site) const -> bool { + const auto found{this->plans_.find(site.pointer)}; + if (found == this->plans_.cend()) { + return false; + } + + const auto &plan{found->second}; + this->sanitize_pending_ = plan.sanitize; + this->additional_items_is_referenced_ = plan.additional_items_is_referenced; + this->resource_has_recursive_anchor_ = plan.resource_has_recursive_anchor; + this->anchor_at_resource_root_ = plan.anchor_at_resource_root; + this->is_inside_contains_wrapper_ = plan.is_inside_contains_wrapper; + this->document_has_unevaluated_items_ = plan.document_has_unevaluated_items; + this->dynamic_anchor_name_ = plan.dynamic_anchor_name; + this->anchor_renames_ = plan.anchor_renames; + this->anchor_ref_rewrites_ = plan.anchor_ref_rewrites; + return true; + } + + mutable std::map plans_; + mutable bool additional_items_is_referenced_{false}; mutable std::vector< std::pair> renames_; @@ -304,13 +469,31 @@ class Upgrade201909To202012 final : public SchemaTransformRule { mutable bool resource_has_recursive_anchor_{false}; mutable bool anchor_at_resource_root_{false}; mutable std::string dynamic_anchor_name_{"meta"}; + mutable bool dynamic_anchor_name_chosen_{false}; mutable bool is_inside_contains_wrapper_{false}; - mutable bool descendant_has_pending_pattern_{false}; mutable bool document_has_unevaluated_items_{false}; mutable bool sanitize_pending_{false}; mutable std::vector anchor_renames_; mutable std::vector anchor_ref_rewrites_; + // Whether the document has an `unevaluatedItems` anywhere does not change + // while a pass runs, and asking it per position is asking a question about + // the whole document once for every position in it + [[nodiscard]] auto document_has_unevaluated_items( + const sourcemeta::core::JSON &root, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaWalker &walker, + const sourcemeta::core::SchemaResolver &resolver) const -> bool { + if (!this->unevaluated_items_.has_value()) { + this->unevaluated_items_ = + compute_document_has_unevaluated_items(root, frame, walker, resolver); + } + + return this->unevaluated_items_.value(); + } + + mutable std::optional unevaluated_items_; + static auto compute_document_has_unevaluated_items( const sourcemeta::core::JSON &root, const sourcemeta::core::SchemaFrame &frame, @@ -340,26 +523,6 @@ class Upgrade201909To202012 final : public SchemaTransformRule { return false; } - static auto any_descendant_has_pending_pattern( - const sourcemeta::core::JSON &root, - const sourcemeta::core::SchemaFrame &frame, - const sourcemeta::core::SchemaFrame::Location &location) -> bool { - if (frame.any_subschema_under( - location.pointer, - [&](const sourcemeta::core::SchemaFrame::Location &entry) -> bool { - const auto absolute{sourcemeta::core::to_pointer(entry.pointer)}; - const auto &descendant{sourcemeta::core::get(root, absolute)}; - if (has_pending_pattern(descendant, entry)) { - return true; - } - - return false; - })) { - return true; - } - return false; - } - static auto is_2020_12_anchor_first_char(const char character) -> bool { return (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || character == '_'; @@ -387,7 +550,30 @@ class Upgrade201909To202012 final : public SchemaTransformRule { return true; } + // The wrapper this rule builds holds nothing but the `contains` it moved and + // the two bounds that go with it, so a double negation the document wrote + // itself can be told apart by what sits beside them. Reading the position + // alone would mistake an authored `not` of a `not` for the rule's own output + // and leave the `contains` inside it bare + static auto + subschema_is_contains_wrapper(const sourcemeta::core::JSON &subschema) + -> bool { + if (!subschema.is_object() || !subschema.defines("contains")) { + return false; + } + + for (const auto &entry : subschema.as_object()) { + if (entry.first != "contains" && entry.first != "minContains" && + entry.first != "maxContains") { + return false; + } + } + + return true; + } + static auto location_inside_contains_wrapper( + const sourcemeta::core::JSON &subschema, const sourcemeta::core::SchemaFrame::Location &location) -> bool { if (location.pointer.size() < 2) { return false; @@ -400,7 +586,7 @@ class Upgrade201909To202012 final : public SchemaTransformRule { if (!second_last.is_property() || second_last.to_property() != "not") { return false; } - return true; + return subschema_is_contains_wrapper(subschema); } static auto @@ -410,6 +596,15 @@ class Upgrade201909To202012 final : public SchemaTransformRule { if (!subschema.is_object()) { return false; } + + // What framing reads is what decides whether this rung still has work + // here. A `$schema` framing does not read declares nothing, and taking it + // at its word would put the subschema out of reach while it is still + // waiting to be converted + if (dialect_position(location.dialect) > + dialect_position(DRAFT_2019_09_URL)) { + return false; + } if (!subschema.defines_any({"$schema", "$recursiveAnchor", "$recursiveRef", "items", "additionalItems", "contains", "$vocabulary"})) { @@ -427,7 +622,7 @@ class Upgrade201909To202012 final : public SchemaTransformRule { return true; } if (subschema.defines("contains") && - !location_inside_contains_wrapper(location)) { + !location_inside_contains_wrapper(subschema, location)) { return true; } if (vocabulary_has_mappable_uri(subschema)) { @@ -575,6 +770,17 @@ class Upgrade201909To202012 final : public SchemaTransformRule { return; } + // Spelling the same fragment is not enough. Another document may + // name an anchor identically, and following the renamed one would + // point this reference at a schema it never named. What decides is + // where framing says the reference actually lands + const auto destination{frame.traverse(reference.destination)}; + if (!destination.has_value() || + !pointer_within_resource(destination.value().get().pointer, + resource_pointer)) { + return; + } + sourcemeta::core::URI ref_uri{reference.original}; ref_uri.fragment(rename_iter->second); const auto new_value{ref_uri.recompose()}; @@ -673,6 +879,16 @@ class Upgrade201909To202012 final : public SchemaTransformRule { names.emplace(anchor->to_string()); } + // An author's own dynamic anchor has to be stepped around too, or the + // name chosen here lands on it and, being declared further out, takes + // over every reference that named it. This is read once, before this rule + // has written any dynamic anchor of its own, so what it finds is the + // author's and nothing else + const auto *dynamic{node.try_at("$dynamicAnchor")}; + if (dynamic != nullptr && dynamic->is_string()) { + names.emplace(dynamic->to_string()); + } + for (const auto &entry : node.as_object()) { collect_static_anchors(entry.second, names); } @@ -686,8 +902,15 @@ class Upgrade201909To202012 final : public SchemaTransformRule { schema.defines_any({"$recursiveAnchor", "$recursiveRef"}); } - static auto compute_dynamic_anchor_name(const sourcemeta::core::JSON &root) + // Answered once per document. The driver runs this rule to a fixed point, so + // a later pass would read back the dynamic anchor this one wrote and keep + // stepping around its own name + auto compute_dynamic_anchor_name(const sourcemeta::core::JSON &root) const -> std::string { + if (this->dynamic_anchor_name_chosen_) { + return this->dynamic_anchor_name_; + } + std::set in_use; collect_static_anchors(root, in_use); @@ -696,9 +919,56 @@ class Upgrade201909To202012 final : public SchemaTransformRule { name.insert(0, "x-"); } + this->dynamic_anchor_name_chosen_ = true; return name; } + // Both of these ask about the resource holding a position rather than about + // the position, so they are asked once per resource instead of once per + // position in it. `relative_pointer` is how deep the resource begins, so the + // resource is the pointer cut to that depth + [[nodiscard]] static auto enclosing_resource(const Site &site) + -> sourcemeta::core::Pointer { + return site.pointer.slice(0, site.relative_pointer); + } + + [[nodiscard]] auto resource_has_recursive_anchor( + const sourcemeta::core::JSON &root, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaFrame::Location &location, + const Site &site) const -> bool { + const auto resource{enclosing_resource(site)}; + const auto cached{this->recursive_anchors_.find(resource)}; + if (cached != this->recursive_anchors_.cend()) { + return cached->second; + } + + const auto answer{ + compute_resource_has_recursive_anchor(root, frame, location)}; + this->recursive_anchors_.emplace(resource, answer); + return answer; + } + + [[nodiscard]] auto resource_has_pending_sanitization( + const sourcemeta::core::JSON &root, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaFrame::Location &location, + const Site &site) const -> bool { + const auto resource{enclosing_resource(site)}; + const auto cached{this->pending_sanitizations_.find(resource)}; + if (cached != this->pending_sanitizations_.cend()) { + return cached->second; + } + + const auto answer{ + enclosing_resource_has_pending_sanitization(root, frame, location)}; + this->pending_sanitizations_.emplace(resource, answer); + return answer; + } + + mutable std::map recursive_anchors_; + mutable std::map pending_sanitizations_; + static auto compute_resource_has_recursive_anchor( const sourcemeta::core::JSON &root, const sourcemeta::core::SchemaFrame &frame, diff --git a/vendor/blaze/src/convert/rules/upgrade_2020_12_to_openapi_3_1.h b/vendor/blaze/src/convert/rules/upgrade_2020_12_to_openapi_3_1.h new file mode 100644 index 00000000..2fb93605 --- /dev/null +++ b/vendor/blaze/src/convert/rules/upgrade_2020_12_to_openapi_3_1.h @@ -0,0 +1,29 @@ +class Upgrade202012ToOpenAPI31 final : public SchemaTransformRule { +public: + Upgrade202012ToOpenAPI31() + : SchemaTransformRule{"upgrade_2020_12_to_openapi_3_1"} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &vocabularies, + const Site &site, const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> bool override { + // The OpenAPI dialect requires every 2020-12 vocabulary, so the core + // vocabulary alone does not say the document is still below this rung. + // Without the second half the rule would claim work on a document already + // on the dialect and write the same `$schema` straight back + ONLY_CONTINUE_IF(vocabularies.contains( + SchemaVocabularies::Known::JSON_SCHEMA_2020_12_CORE) && + !vocabularies.contains_any( + {SchemaVocabularies::Known::OPENAPI_3_1_BASE, + SchemaVocabularies::Known::OPENAPI_3_2_BASE})); + + return at_dialect_declaration(schema, site); + } + + auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void override { + bump_dialect(schema, site, OPENAPI_3_1_DIALECT); + } +}; diff --git a/vendor/blaze/src/convert/rules/upgrade_dialect_override_cleanup.h b/vendor/blaze/src/convert/rules/upgrade_dialect_override_cleanup.h deleted file mode 100644 index ba10325b..00000000 --- a/vendor/blaze/src/convert/rules/upgrade_dialect_override_cleanup.h +++ /dev/null @@ -1,119 +0,0 @@ -class UpgradeDialectOverrideCleanup final : public SchemaTransformRule { -public: - using reframe_after_transform = std::true_type; - UpgradeDialectOverrideCleanup() - : SchemaTransformRule{"upgrade_dialect_override_cleanup"} {}; - - [[nodiscard]] auto condition( - const sourcemeta::core::JSON &schema, const sourcemeta::core::JSON &root, - const sourcemeta::core::SchemaVocabularies &, - const sourcemeta::core::SchemaFrame &frame, - const sourcemeta::core::SchemaFrame::Location &location, - const sourcemeta::core::SchemaWalker &, - const sourcemeta::core::SchemaResolver &resolver) const -> bool override { - ONLY_CONTINUE_IF(location.pointer.empty() && schema.is_object()); - this->redundant_.clear(); - - const auto *override_value{schema.try_at(DIALECT_OVERRIDE_KEYWORD)}; - if (override_value != nullptr && override_value->is_string()) { - return true; - } - - // A document that names a dialect of its own never bumps a `$schema` the - // ladder recognises, so nothing there will ever clear the markers that - // subschemas below it left behind. Every other document clears them when - // it bumps, and taking them early would strip state the ladder still needs - ONLY_CONTINUE_IF(dialect_position(declared_dialect(schema)) == 0); - - [[maybe_unused]] const auto visited{frame.any_subschema_under( - location.pointer, - [this, &root, &frame, &resolver]( - const sourcemeta::core::SchemaFrame::Location &entry) -> bool { - const auto entry_pointer{sourcemeta::core::to_pointer(entry.pointer)}; - const auto &entry_schema{sourcemeta::core::get(root, entry_pointer)}; - if (!entry_schema.is_object()) { - return false; - } - - const auto *marker{entry_schema.try_at(DIALECT_OVERRIDE_KEYWORD)}; - if (marker == nullptr || !marker->is_string()) { - return false; - } - - // Framing agreeing with what the marker claims is what makes it - // redundant. Until then it is the only record of the move - const auto vocabulary{marker_vocabulary(marker->to_string())}; - if (vocabulary.has_value() && frame.vocabularies(entry, resolver) - .contains(vocabulary.value())) { - this->redundant_.push_back(entry_pointer); - } - - return false; - })}; - - return !this->redundant_.empty(); - } - - auto transform(sourcemeta::core::JSON &schema) const -> void override { - if (!schema.defines(DIALECT_OVERRIDE_KEYWORD)) { - for (const auto &pointer : this->redundant_) { - sourcemeta::core::get(schema, pointer).erase(DIALECT_OVERRIDE_KEYWORD); - } - - return; - } - - const sourcemeta::core::JSON::String dialect{ - schema.at(DIALECT_OVERRIDE_KEYWORD).to_string()}; - if (!schema.defines("$schema")) { - sourcemeta::core::JSON dialect_value{ - schema.at(DIALECT_OVERRIDE_KEYWORD).to_string()}; - - bool placed{false}; - for (const auto &entry : schema.as_object()) { - if (entry.first != DIALECT_OVERRIDE_KEYWORD) { - schema.try_assign_before("$schema", dialect_value, entry.first); - placed = true; - break; - } - } - - if (!placed) { - schema.assign("$schema", std::move(dialect_value)); - } - } - - drop_dialect_overrides(schema, true, dialect); - } - -private: - // The vocabulary that stands for each dialect the ladder walks through, so - // that what framing reports can be told apart from what a marker claims - static auto marker_vocabulary(const std::string_view dialect) - -> std::optional { - using Known = SchemaVocabularies::Known; - static constexpr std::array, 6> ENTRIES{ - {{"http://json-schema.org/draft-03/schema#", - Known::JSON_SCHEMA_DRAFT_3}, - {"http://json-schema.org/draft-04/schema#", - Known::JSON_SCHEMA_DRAFT_4}, - {"http://json-schema.org/draft-06/schema#", - Known::JSON_SCHEMA_DRAFT_6}, - {"http://json-schema.org/draft-07/schema#", - Known::JSON_SCHEMA_DRAFT_7}, - {"https://json-schema.org/draft/2019-09/schema", - Known::JSON_SCHEMA_2019_09_CORE}, - {"https://json-schema.org/draft/2020-12/schema", - Known::JSON_SCHEMA_2020_12_CORE}}}; - - for (const auto &[uri, vocabulary] : ENTRIES) { - if (uri == dialect) { - return vocabulary; - } - } - - return std::nullopt; - } - - mutable std::vector redundant_; -}; diff --git a/vendor/blaze/src/convert/rules/upgrade_draft_3_to_draft_4.h b/vendor/blaze/src/convert/rules/upgrade_draft_3_to_draft_4.h index cdeda88e..10f30dd9 100644 --- a/vendor/blaze/src/convert/rules/upgrade_draft_3_to_draft_4.h +++ b/vendor/blaze/src/convert/rules/upgrade_draft_3_to_draft_4.h @@ -1,67 +1,56 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; UpgradeDraft3ToDraft4() : SchemaTransformRule{"upgrade_draft_3_to_draft_4"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, - const sourcemeta::core::JSON &root, + const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &frame, - const sourcemeta::core::SchemaFrame::Location &location, - const sourcemeta::core::SchemaWalker &, + const Site &site, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_3) && schema.is_object()); - const bool root_via_default_dialect = - location.pointer.empty() && !schema.defines("$schema"); - - ONLY_CONTINUE_IF(has_pending_draft_3_pattern(schema) || - root_via_default_dialect); - - if (frame.any_subschema_under( - location.pointer, - [&root]( - const sourcemeta::core::SchemaFrame::Location &entry) -> bool { - const auto entry_pointer{ - sourcemeta::core::to_pointer(entry.pointer)}; - const auto &entry_schema{ - sourcemeta::core::get(root, entry_pointer)}; - if (entry_schema.is_object() && entry_schema.defines("$ref")) { - return false; - } - - return has_pending_draft_3_pattern(entry_schema); - })) { - return false; - } + this->stray_required_ = has_stray_required_boolean(schema, site.pointer); + + return has_pending_draft_3_pattern(schema, site.dialect) || + this->stray_required_ || at_dialect_declaration(schema, site); + } - return true; + [[nodiscard]] auto relocations() const -> std::vector override { + return {this->renames_.cbegin(), this->renames_.cend()}; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void override { + this->renames_.clear(); rewrite_type_any(schema); - rewrite_type_array_with_subschemas(schema); - rewrite_disallow(schema); - rewrite_extends(schema); + rewrite_type_array_with_subschemas(schema, this->renames_); + rewrite_disallow(schema, this->renames_); + rewrite_extends(schema, this->renames_); + rewrite_empty_items(schema, this->renames_); rewrite_divisible_by(schema); + // Dropping the stray boolean first leaves the lift free to write its array + // under the same name. The other way round the lift's array is what gets + // erased, and the properties it named stop being required + if (this->stray_required_) { + schema.erase("required"); + } + rewrite_required_property_booleans(schema); rewrite_dependencies_string_form(schema); + normalize_dependency_arrays(schema); rewrite_format(schema); - if (schema.defines("$schema") && schema.at("$schema").is_string() && - schema.at("$schema").to_string() == DRAFT_3_URL) { - schema.assign("$schema", sourcemeta::core::JSON{DRAFT_4_URL}); - drop_dialect_overrides(schema, true, DRAFT_4_URL); - } else { - mark_dialect_override(schema, DRAFT_4_URL); - } + bump_dialect(schema, site, DRAFT_4_URL); } private: + using Relocation = + std::pair; + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) static inline const std::string DRAFT_3_URL{ "http://json-schema.org/draft-03/schema#"}; @@ -69,9 +58,26 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { static inline const std::string DRAFT_4_URL{ "http://json-schema.org/draft-04/schema#"}; + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) + static inline const std::array PROMOTED_DRAFT_4_KEYWORDS{ + {"multipleOf", "maxProperties", "minProperties", "allOf", "anyOf", + "oneOf", "not"}}; + + mutable bool stray_required_{false}; + mutable std::vector renames_; + static auto - has_pending_draft_3_pattern(const sourcemeta::core::JSON &subschema) -> bool { - if (!subschema.is_object()) { + has_pending_draft_3_pattern(const sourcemeta::core::JSON &subschema, + const std::string_view dialect) -> bool { + // Either answer putting the subschema past this rung is enough. What it + // declares covers the marker this rule plants on its own output, without + // which the keywords it writes would read as pending for good. What + // framing reads covers a subschema sitting inside a resource that has + // already moved up, which declares nothing of its own and would otherwise + // leave the root waiting on work nothing will do + if (!subschema.is_object() || + declares_dialect_out_of_reach(subschema, DRAFT_3_URL) || + dialect_position(dialect) > dialect_position(DRAFT_3_URL)) { return false; } @@ -82,12 +88,12 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { const auto *type_value{subschema.try_at("type")}; if (type_value != nullptr) { - if (type_value->is_string() && type_value->to_string() == "any") { + if (names_every_instance(*type_value)) { return true; } if (type_value->is_array()) { for (const auto &element : type_value->as_array()) { - if (element.is_string() && element.to_string() == "any") { + if (names_every_instance(element)) { return true; } if (element.is_object()) { @@ -114,6 +120,11 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { return true; } + const auto *items{subschema.try_at("items")}; + if (items != nullptr && items->is_array() && items->empty()) { + return true; + } + const auto *properties{subschema.try_at("properties")}; if (properties != nullptr && properties->is_object()) { for (const auto &entry : properties->as_object()) { @@ -127,7 +138,9 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { const auto *dependencies{subschema.try_at("dependencies")}; if (dependencies != nullptr && dependencies->is_object()) { for (const auto &entry : dependencies->as_object()) { - if (entry.second.is_string()) { + if (entry.second.is_string() || + (entry.second.is_array() && + (entry.second.empty() || has_repeated_name(entry.second)))) { return true; } } @@ -137,7 +150,16 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { return true; } - return false; + // A keyword Draft 4 promotes is inert data here, and the rule that + // shadows it only fires while the subschema is still read as Draft 3. + // Bumping an ancestor first takes that reading away, and the keyword + // starts asserting something the document never said. A descendant this + // rule has already converted carries the marker that puts it out of + // reach above, so waiting cannot outlast the work + return std::ranges::any_of(PROMOTED_DRAFT_4_KEYWORDS, + [&subschema](const auto keyword) -> bool { + return subschema.defines(keyword); + }); } static auto @@ -154,13 +176,31 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { return name == "host-name" || name == "ip-address"; } + // Draft 3 lists the type names it defines and then says that a value outside + // that list accepts any instance, which is what `any` does. So an + // unrecognised name is dropped exactly as `any` is, rather than carried into + // a dialect whose meta-schema accepts only the listed names + static auto names_every_instance(const sourcemeta::core::JSON &element) + -> bool { + if (!element.is_string()) { + return false; + } + + static constexpr std::array DRAFT_3_TYPE_NAMES{ + {"string", "number", "integer", "boolean", "object", "array", "null"}}; + const auto &name{element.to_string()}; + return std::ranges::none_of( + DRAFT_3_TYPE_NAMES, + [&name](const auto candidate) -> bool { return candidate == name; }); + } + static auto rewrite_type_any(sourcemeta::core::JSON &schema) -> void { if (!schema.defines("type")) { return; } auto &type_value{schema.at("type")}; if (type_value.is_string()) { - if (type_value.to_string() == "any") { + if (names_every_instance(type_value)) { schema.erase("type"); } return; @@ -168,20 +208,20 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { if (!type_value.is_array()) { return; } - bool collapses{false}; - for (const auto &element : type_value.as_array()) { - if (element.is_string() && element.to_string() == "any") { - collapses = true; - break; - } + if (std::ranges::any_of( + type_value.as_array(), + [](const auto &element) -> bool { return element.is_object(); })) { + return; } - if (collapses) { + + if (std::ranges::any_of(type_value.as_array(), names_every_instance)) { schema.erase("type"); } } - static auto rewrite_type_array_with_subschemas(sourcemeta::core::JSON &schema) - -> void { + static auto + rewrite_type_array_with_subschemas(sourcemeta::core::JSON &schema, + std::vector &renames) -> void { if (!schema.defines("type")) { return; } @@ -203,25 +243,31 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { auto branches{sourcemeta::core::JSON::make_array()}; for (const auto &element : type_value.as_array()) { if (element.is_string()) { - auto branch{sourcemeta::core::JSON::make_object()}; - branch.assign("type", element); - branches.push_back(std::move(branch)); + branches.push_back(type_name_to_branch(element)); } else if (element.is_object()) { branches.push_back(element); } } schema.erase("type"); schema.assign("anyOf", std::move(branches)); + renames.emplace_back(sourcemeta::core::Pointer{"type"}, + sourcemeta::core::Pointer{"anyOf"}); } - static auto type_string_to_branch(const std::string &type_name) + // A name matching every instance becomes a schema that accepts anything, + // since no later dialect defines that name as a type + static auto type_name_to_branch(const sourcemeta::core::JSON &type_name) -> sourcemeta::core::JSON { auto branch{sourcemeta::core::JSON::make_object()}; - branch.assign("type", sourcemeta::core::JSON{type_name}); + if (!names_every_instance(type_name)) { + branch.assign("type", type_name); + } + return branch; } - static auto rewrite_disallow(sourcemeta::core::JSON &schema) -> void { + static auto rewrite_disallow(sourcemeta::core::JSON &schema, + std::vector &renames) -> void { if (!schema.defines("disallow") || schema.defines("not")) { return; } @@ -232,20 +278,26 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { return; } - if (disallow.is_string() && disallow.to_string() == "any") { + if (disallow.is_string() && names_every_instance(disallow)) { schema.erase("disallow"); schema.assign("not", sourcemeta::core::JSON::make_object()); return; } - if (disallow.is_array()) { - for (const auto &element : disallow.as_array()) { - if (element.is_string() && element.to_string() == "any") { - schema.erase("disallow"); - schema.assign("not", sourcemeta::core::JSON::make_object()); - return; - } - } + // A name matching every instance makes the whole union match everything, + // so the result rejects everything whatever else is listed. Collapsing + // straight to that answer is only safe while the other entries are type + // names: a schema among them is a schema a reference can name, and + // dropping it would leave that reference pointing nowhere. Carrying every + // entry over as a branch says the same thing and keeps them all addressable + if (disallow.is_array() && + std::ranges::any_of(disallow.as_array(), names_every_instance) && + std::ranges::none_of( + disallow.as_array(), + [](const auto &element) -> bool { return element.is_object(); })) { + schema.erase("disallow"); + schema.assign("not", sourcemeta::core::JSON::make_object()); + return; } auto negated{sourcemeta::core::JSON::make_object()}; @@ -265,22 +317,32 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { auto branches{sourcemeta::core::JSON::make_array()}; for (const auto &element : disallow.as_array()) { if (element.is_string()) { - branches.push_back(type_string_to_branch(element.to_string())); + branches.push_back(type_name_to_branch(element)); } else if (element.is_object()) { branches.push_back(element); } } negated.assign("anyOf", std::move(branches)); + + // A reference may name a schema that sat in here, so where these + // land is recorded for `rereference` to follow. Each branch records + // its own move, as an author's `disallow` schema may define `anyOf` + // itself and reading the result back cannot tell the two apart + renames.emplace_back(sourcemeta::core::Pointer{"disallow"}, + sourcemeta::core::Pointer{"not", "anyOf"}); } } else { negated = disallow; + renames.emplace_back(sourcemeta::core::Pointer{"disallow"}, + sourcemeta::core::Pointer{"not"}); } schema.erase("disallow"); schema.assign("not", std::move(negated)); } - static auto rewrite_extends(sourcemeta::core::JSON &schema) -> void { + static auto rewrite_extends(sourcemeta::core::JSON &schema, + std::vector &renames) -> void { if (!schema.defines("extends") || schema.defines("allOf")) { return; } @@ -294,15 +356,68 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { schema.erase("extends"); if (value.is_array()) { + // Draft 3 satisfies an empty `extends` vacuously, while Draft 4 asks + // `allOf` for at least one element, so there is nothing to carry over + if (value.empty()) { + return; + } + + renames.emplace_back(sourcemeta::core::Pointer{"extends"}, + sourcemeta::core::Pointer{"allOf"}); schema.assign("allOf", std::move(value)); return; } auto array{sourcemeta::core::JSON::make_array()}; array.push_back(std::move(value)); + renames.emplace_back(sourcemeta::core::Pointer{"extends"}, + sourcemeta::core::Pointer{"allOf", 0}); schema.assign("allOf", std::move(array)); } + // Draft 3 takes an empty `items` array as naming no position at all, so + // every element falls to `additionalItems`. Draft 4 asks a schema array for + // at least one entry, so the empty one cannot come along. Saying the same + // thing there means letting the `additionalItems` schema apply to every + // element, which is what a single-schema `items` does. Dropping the empty + // array alone would instead leave `additionalItems` with no array beside it, + // where both dialects ignore it, and every element would stop being checked + static auto rewrite_empty_items(sourcemeta::core::JSON &schema, + std::vector &renames) -> void { + if (!schema.defines("items") || !schema.at("items").is_array() || + !schema.at("items").empty()) { + return; + } + + if (!schema.defines("additionalItems")) { + schema.erase("items"); + return; + } + + // Draft 3 lets this keyword be a boolean, which Draft 4 does not accept + // where it is going. `true` allows every element, which is what saying + // nothing does, and `false` allows none, which is an array that has to be + // empty. `maxItems` says that in a keyword both dialects share, so no + // keyword Draft 4 only just promoted is introduced here + if (schema.at("additionalItems").is_boolean()) { + const auto allows{schema.at("additionalItems").to_boolean()}; + schema.erase("additionalItems"); + schema.erase("items"); + if (!allows) { + schema.assign("maxItems", sourcemeta::core::JSON{0}); + } + + return; + } + + // A reference may name the schema being moved, or something inside it, so + // where it lands is recorded for `rereference` to follow + renames.emplace_back(sourcemeta::core::Pointer{"additionalItems"}, + sourcemeta::core::Pointer{"items"}); + schema.assign("items", schema.at("additionalItems")); + schema.erase("additionalItems"); + } + static auto rewrite_divisible_by(sourcemeta::core::JSON &schema) -> void { if (!schema.defines("divisibleBy") || schema.defines("multipleOf")) { return; @@ -310,6 +425,17 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { schema.rename("divisibleBy", "multipleOf"); } + // A boolean `required` is a Draft 3 spelling, so it is only this rule's to + // move when the property is read as Draft 3 as well. One that names a dialect + // of its own answers to that dialect instead, where the member may be nothing + // but author data, and lifting it would destroy it there while inventing an + // assertion here that the document never made + static auto reads_as_draft_3(const sourcemeta::core::JSON &property) -> bool { + const auto declared{declared_dialect(property)}; + return declared.empty() || + dialect_position(declared) == dialect_position(DRAFT_3_URL); + } + static auto rewrite_required_property_booleans(sourcemeta::core::JSON &schema) -> void { if (!schema.defines("properties") || !schema.at("properties").is_object()) { @@ -329,10 +455,37 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { if (!property.is_object() || !property.defines("required")) { continue; } + + if (!reads_as_draft_3(property)) { + continue; + } const auto &required_value{property.at("required")}; if (!required_value.is_boolean()) { continue; } + // Draft 3 replaces a schema with whatever its `$ref` names, so a + // `required` flag beside one never made the property mandatory. Lifting + // it onto the parent, where nothing suppresses it, would invent an + // assertion the document never made, and erasing it would throw away the + // only record that the author asked for something the dialect ignored. + // Shadowing keeps that record without asserting anything, which is how + // every other suppressed sibling is carried over. A `false` flag is the + // default and says nothing, so it goes + if (property.defines("$ref")) { + if (required_value.to_boolean()) { + std::string shadowed{"x-required"}; + while (property.defines(shadowed)) { + shadowed.insert(0, "x-"); + } + + property.rename("required", std::move(shadowed)); + } else { + property.erase("required"); + } + + continue; + } + const bool is_required{required_value.to_boolean()}; property.erase("required"); if (is_required) { @@ -369,6 +522,87 @@ class UpgradeDraft3ToDraft4 final : public SchemaTransformRule { } } + // Draft 3 reads this keyword as whether the instance it sits on has to be + // present, which is a question only the enclosing `properties` can answer. + // One directly under `properties` is that enclosing schema's to lift, so + // only one anywhere else is stray + static auto + has_stray_required_boolean(const sourcemeta::core::JSON &subschema, + const sourcemeta::core::Pointer &pointer) -> bool { + if (!subschema.is_object() || + declares_dialect_out_of_reach(subschema, DRAFT_3_URL)) { + return false; + } + + const auto *required{subschema.try_at("required")}; + if (required == nullptr || !required->is_boolean()) { + return false; + } + + return pointer.size() < 2 || + !pointer.at(pointer.size() - 2).is_property() || + pointer.at(pointer.size() - 2).to_property() != "properties"; + } + + static auto has_repeated_name(const sourcemeta::core::JSON &names) -> bool { + std::set already; + for (const auto &name : names.as_array()) { + if (name.is_string() && !already.insert(name.to_string()).second) { + return true; + } + } + + return false; + } + + // Draft 3 satisfies an empty property dependency vacuously and asks for the + // same property twice when a name repeats, while Draft 4 asks such an array + // for at least one element and for its elements to be unique + static auto normalize_dependency_arrays(sourcemeta::core::JSON &schema) + -> void { + if (!schema.defines("dependencies") || + !schema.at("dependencies").is_object()) { + return; + } + + auto &dependencies{schema.at("dependencies")}; + std::vector vacuous_keys; + std::vector repeating_keys; + for (const auto &entry : dependencies.as_object()) { + if (!entry.second.is_array()) { + continue; + } + + if (entry.second.empty()) { + vacuous_keys.push_back(entry.first); + } else if (has_repeated_name(entry.second)) { + repeating_keys.push_back(entry.first); + } + } + + for (const auto &key : repeating_keys) { + auto fresh{sourcemeta::core::JSON::make_array()}; + std::set already; + for (const auto &name : dependencies.at(key).as_array()) { + if (name.is_string() && !already.insert(name.to_string()).second) { + continue; + } + + fresh.push_back(name); + } + + dependencies.assign(key, std::move(fresh)); + } + + for (const auto &key : vacuous_keys) { + dependencies.erase(key); + } + + if (dependencies.empty()) { + schema.erase("dependencies"); + } + } + static auto rewrite_dependencies_string_form(sourcemeta::core::JSON &schema) -> void { if (!schema.defines("dependencies") || diff --git a/vendor/blaze/src/convert/rules/upgrade_draft_4_to_draft_6.h b/vendor/blaze/src/convert/rules/upgrade_draft_4_to_draft_6.h index ae8edb9e..31ba7320 100644 --- a/vendor/blaze/src/convert/rules/upgrade_draft_4_to_draft_6.h +++ b/vendor/blaze/src/convert/rules/upgrade_draft_4_to_draft_6.h @@ -1,84 +1,38 @@ class UpgradeDraft4ToDraft6 final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; UpgradeDraft4ToDraft6() : SchemaTransformRule{"upgrade_draft_4_to_draft_6"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, - const sourcemeta::core::JSON &root, + const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &frame, - const sourcemeta::core::SchemaFrame::Location &location, - const sourcemeta::core::SchemaWalker &, + const Site &site, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { - this->sanitize_pending_ = false; - ONLY_CONTINUE_IF( vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_4) && schema.is_object()); - const bool is_resource_scope = - location.type == - sourcemeta::core::SchemaFrame::LocationType::Resource || - location.pointer.empty(); - - const bool sanitization_branch = - is_resource_scope && resource_needs_anchor_sanitization(schema); - - const bool other_branch = has_pending_draft_4_pattern(schema); - - const bool root_via_default_dialect = - location.pointer.empty() && !schema.defines("$schema"); - - ONLY_CONTINUE_IF(sanitization_branch || other_branch || - root_via_default_dialect); - - if (!sanitization_branch && other_branch && - enclosing_resource_has_pending_sanitization(location, root, frame)) { - return false; - } - - if (!sanitization_branch) { - if (frame.any_subschema_under( - location.pointer, - [&root](const sourcemeta::core::SchemaFrame::Location &entry) - -> bool { - const auto entry_pointer{ - sourcemeta::core::to_pointer(entry.pointer)}; - const auto &entry_schema{ - sourcemeta::core::get(root, entry_pointer)}; - if (entry_schema.is_object() && entry_schema.defines("$ref")) { - return false; - } + ONLY_CONTINUE_IF(has_pending_draft_4_pattern(schema, site.dialect) || + at_dialect_declaration(schema, site)); - return has_pending_draft_4_pattern(entry_schema); - })) { - return false; - } - } - - this->sanitize_pending_ = sanitization_branch; - return true; + // Anchors are renamed document-wide by a rule of its own, and bumping the + // dialect is what stops `id` from identifying anything, so this waits for + // that to finish rather than deciding which resource ought to do it + return !this->anchors_pending_; } - auto transform(sourcemeta::core::JSON &schema) const -> void override { - if (this->sanitize_pending_) { - const auto renames{build_resource_rename_map(schema)}; - std::optional resource_base; - if (schema.defines("id") && schema.at("id").is_string()) { - const sourcemeta::core::URI uri{schema.at("id").to_string()}; - const auto without_fragment{uri.recompose_without_fragment()}; - if (without_fragment.has_value() && !without_fragment.value().empty()) { - resource_base = without_fragment.value(); - } - } - apply_anchor_renames_in_resource(schema, true, renames, resource_base); - if (resource_has_descendant_with_pending_pattern(schema, true)) { - return; - } - } + auto plan(const sourcemeta::core::JSON &, const sourcemeta::core::JSON &root, + const sourcemeta::core::SchemaVocabularies &, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaFrame::Location &, const Site &, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> void override { + this->anchors_pending_ = has_unsanitized_draft_4_anchor(root, frame); + } + auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void override { if (schema.defines("id") && schema.at("id").is_string()) { schema.rename("id", "$id"); } @@ -103,16 +57,12 @@ class UpgradeDraft4ToDraft6 final : public SchemaTransformRule { } } - if (schema.defines("$schema") && schema.at("$schema").is_string() && - schema.at("$schema").to_string() == DRAFT_4_URL) { - schema.assign("$schema", sourcemeta::core::JSON{DRAFT_6_URL}); - drop_dialect_overrides(schema, true, DRAFT_6_URL); - } else { - mark_dialect_override(schema, DRAFT_6_URL); - } + bump_dialect(schema, site, DRAFT_6_URL); } private: + mutable bool anchors_pending_{false}; + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) static inline const std::string DRAFT_4_URL{ "http://json-schema.org/draft-04/schema#"}; @@ -123,23 +73,31 @@ class UpgradeDraft4ToDraft6 final : public SchemaTransformRule { static inline const std::array PROMOTED_KEYWORDS{ {"const", "contains", "propertyNames", "examples"}}; - mutable bool sanitize_pending_{false}; - static auto - has_pending_draft_4_pattern(const sourcemeta::core::JSON &subschema) -> bool { + has_pending_draft_4_pattern(const sourcemeta::core::JSON &subschema, + const std::string_view dialect) -> bool { if (!subschema.is_object()) { return false; } + // What framing reads is what decides whether this rung still has work + // here. A `$schema` that framing does not read declares nothing, and + // taking it at its word would put the subschema out of reach while it is + // still waiting to be converted, letting an ancestor move the dialect out + // from under it + if (dialect_position(dialect) > dialect_position(DRAFT_4_URL)) { + return false; + } + if (subschema.defines("$schema") && subschema.at("$schema").is_string() && subschema.at("$schema").to_string() == DRAFT_4_URL) { return true; } if (subschema.defines("id") && subschema.at("id").is_string()) { - const auto fragment{extract_id_fragment(subschema.at("id"))}; + const auto fragment{identifier_fragment(subschema.at("id"))}; if (!fragment.has_value() || fragment.value().empty() || - is_strict_plain_name(fragment.value())) { + is_draft_6_plain_name(fragment.value())) { return true; } } @@ -160,444 +118,25 @@ class UpgradeDraft4ToDraft6 final : public SchemaTransformRule { } } - return has_stray_identifier(subschema); + return has_stray_identifier(subschema, dialect); } // Draft 4 does not know `$id`, so one written there is inert data that // Draft 6 would read as the identifier, and it has to be shadowed before // `id` takes that name. It is also the one Draft 6 addition this rule // produces itself, so unlike every other promoted keyword its presence only - // means work is pending while the subschema is still on Draft 4 or older - static auto has_stray_identifier(const sourcemeta::core::JSON &subschema) - -> bool { + // means work is pending while the subschema is still on Draft 4 or older. + // + // How the subschema is read counts as well as what it declares. A resource + // around it may have moved on while leaving it declaring nothing, and the + // `$id` is then the identifier doing its job rather than data awaiting a + // shadow. Asking only what it declares leaves an ancestor waiting on a + // subschema that nothing is going to change again + static auto has_stray_identifier(const sourcemeta::core::JSON &subschema, + const std::string_view dialect) -> bool { return subschema.defines("$id") && + dialect_position(dialect) <= dialect_position(DRAFT_4_URL) && dialect_position(declared_dialect(subschema)) <= dialect_position(DRAFT_4_URL); } - - static auto is_strict_plain_name_first_char(const char character) -> bool { - return (character >= 'A' && character <= 'Z') || - (character >= 'a' && character <= 'z'); - } - - static auto is_strict_plain_name_body_char(const char character) -> bool { - return (character >= 'A' && character <= 'Z') || - (character >= 'a' && character <= 'z') || - (character >= '0' && character <= '9') || character == '_' || - character == ':' || character == '.' || character == '-'; - } - - static auto is_strict_plain_name(const std::string_view fragment) -> bool { - if (fragment.empty()) { - return false; - } - if (!is_strict_plain_name_first_char(fragment.front())) { - return false; - } - for (std::size_t index{1}; index < fragment.size(); ++index) { - if (!is_strict_plain_name_body_char(fragment[index])) { - return false; - } - } - return true; - } - - static auto sanitize_anchor_name(const std::string_view original, - const std::set &existing_names) - -> std::string { - static const AnchorCharPolicy POLICY{ - .is_valid_first = &is_strict_plain_name_first_char, - .is_valid_body = &is_strict_plain_name_body_char}; - return sanitize_anchor_with_policy(original, existing_names, POLICY); - } - - static auto extract_id_fragment(const sourcemeta::core::JSON &id_value) - -> std::optional { - if (!id_value.is_string()) { - return std::nullopt; - } - const sourcemeta::core::URI uri{id_value.to_string()}; - const auto fragment{uri.fragment()}; - if (!fragment.has_value()) { - return std::nullopt; - } - return std::string{fragment.value()}; - } - - static auto - subschema_id_fragment_is_invalid(const sourcemeta::core::JSON &subschema) - -> bool { - if (!subschema.is_object() || !subschema.defines("id") || - !subschema.at("id").is_string()) { - return false; - } - const auto fragment{extract_id_fragment(subschema.at("id"))}; - if (!fragment.has_value() || fragment.value().empty()) { - return false; - } - return !is_strict_plain_name(fragment.value()); - } - - static auto - subschema_starts_sub_resource(const sourcemeta::core::JSON &subschema) - -> bool { - if (!subschema.is_object() || !subschema.defines("id") || - !subschema.at("id").is_string()) { - return false; - } - const sourcemeta::core::URI uri{subschema.at("id").to_string()}; - if (uri.is_fragment_only()) { - return false; - } - const auto without_fragment{uri.recompose_without_fragment()}; - return without_fragment.has_value() && !without_fragment.value().empty(); - } - - static auto collect_resource_anchors(const sourcemeta::core::JSON &subschema, - const bool is_root, - std::set &result) -> void { - if (!subschema.is_object()) { - return; - } - - if (!is_root && subschema_starts_sub_resource(subschema)) { - return; - } - - if (subschema.defines("id") && subschema.at("id").is_string()) { - const auto fragment{extract_id_fragment(subschema.at("id"))}; - if (fragment.has_value() && !fragment.value().empty()) { - result.insert(fragment.value()); - } - } - - for (const std::string_view object_keyword : - {"definitions", "properties", "patternProperties", "dependencies"}) { - if (subschema.defines(object_keyword) && - subschema.at(object_keyword).is_object()) { - for (const auto &entry : subschema.at(object_keyword).as_object()) { - collect_resource_anchors(entry.second, false, result); - } - } - } - - for (const std::string_view array_keyword : {"allOf", "anyOf", "oneOf"}) { - if (subschema.defines(array_keyword) && - subschema.at(array_keyword).is_array()) { - for (const auto &item : subschema.at(array_keyword).as_array()) { - collect_resource_anchors(item, false, result); - } - } - } - - for (const std::string_view single_keyword : - {"additionalProperties", "additionalItems", "not"}) { - if (subschema.defines(single_keyword)) { - collect_resource_anchors(subschema.at(single_keyword), false, result); - } - } - - if (subschema.defines("items")) { - const auto &items{subschema.at("items")}; - if (items.is_array()) { - for (const auto &item : items.as_array()) { - collect_resource_anchors(item, false, result); - } - } else { - collect_resource_anchors(items, false, result); - } - } - } - - static auto collect_invalid_anchors(const sourcemeta::core::JSON &subschema, - const bool is_root, - std::vector &result) - -> void { - if (!subschema.is_object()) { - return; - } - - if (!is_root && subschema_starts_sub_resource(subschema)) { - return; - } - - if (subschema_id_fragment_is_invalid(subschema)) { - const auto fragment{extract_id_fragment(subschema.at("id"))}; - result.push_back(fragment.value()); - } - - for (const std::string_view object_keyword : - {"definitions", "properties", "patternProperties", "dependencies"}) { - if (subschema.defines(object_keyword) && - subschema.at(object_keyword).is_object()) { - for (const auto &entry : subschema.at(object_keyword).as_object()) { - collect_invalid_anchors(entry.second, false, result); - } - } - } - - for (const std::string_view array_keyword : {"allOf", "anyOf", "oneOf"}) { - if (subschema.defines(array_keyword) && - subschema.at(array_keyword).is_array()) { - for (const auto &item : subschema.at(array_keyword).as_array()) { - collect_invalid_anchors(item, false, result); - } - } - } - - for (const std::string_view single_keyword : - {"additionalProperties", "additionalItems", "not"}) { - if (subschema.defines(single_keyword)) { - collect_invalid_anchors(subschema.at(single_keyword), false, result); - } - } - - if (subschema.defines("items")) { - const auto &items{subschema.at("items")}; - if (items.is_array()) { - for (const auto &item : items.as_array()) { - collect_invalid_anchors(item, false, result); - } - } else { - collect_invalid_anchors(items, false, result); - } - } - } - - static auto - build_resource_rename_map(const sourcemeta::core::JSON &resource_root) - -> std::map { - std::set existing; - collect_resource_anchors(resource_root, true, existing); - - std::vector invalid; - collect_invalid_anchors(resource_root, true, invalid); - - std::map renames; - std::set in_use{existing}; - for (const auto &original : invalid) { - if (renames.contains(original)) { - continue; - } - in_use.erase(original); - const auto sanitized{sanitize_anchor_name(original, in_use)}; - renames.emplace(original, sanitized); - in_use.insert(sanitized); - } - return renames; - } - - static auto resource_has_descendant_with_pending_pattern( - const sourcemeta::core::JSON &subschema, const bool is_root) -> bool { - if (!subschema.is_object()) { - return false; - } - if (!is_root && subschema_starts_sub_resource(subschema)) { - return false; - } - if (!is_root && has_pending_draft_4_pattern(subschema)) { - return true; - } - - for (const std::string_view object_keyword : - {"definitions", "properties", "patternProperties", "dependencies"}) { - if (subschema.defines(object_keyword) && - subschema.at(object_keyword).is_object()) { - for (const auto &entry : subschema.at(object_keyword).as_object()) { - if (resource_has_descendant_with_pending_pattern(entry.second, - false)) { - return true; - } - } - } - } - - for (const std::string_view array_keyword : {"allOf", "anyOf", "oneOf"}) { - if (subschema.defines(array_keyword) && - subschema.at(array_keyword).is_array()) { - for (const auto &item : subschema.at(array_keyword).as_array()) { - if (resource_has_descendant_with_pending_pattern(item, false)) { - return true; - } - } - } - } - - for (const std::string_view single_keyword : - {"additionalProperties", "additionalItems", "not"}) { - if (subschema.defines(single_keyword)) { - if (resource_has_descendant_with_pending_pattern( - subschema.at(single_keyword), false)) { - return true; - } - } - } - - if (subschema.defines("items")) { - const auto &items{subschema.at("items")}; - if (items.is_array()) { - for (const auto &item : items.as_array()) { - if (resource_has_descendant_with_pending_pattern(item, false)) { - return true; - } - } - } else if (resource_has_descendant_with_pending_pattern(items, false)) { - return true; - } - } - - return false; - } - - static auto apply_anchor_renames_in_resource( - sourcemeta::core::JSON &subschema, const bool is_root, - const std::map &renames, - const std::optional &resource_base) -> void { - if (!subschema.is_object()) { - return; - } - - if (!is_root && subschema_starts_sub_resource(subschema)) { - return; - } - - if (subschema.defines("id") && subschema.at("id").is_string()) { - const auto &id_string{subschema.at("id").to_string()}; - const sourcemeta::core::URI uri{id_string}; - const auto fragment{uri.fragment()}; - if (fragment.has_value() && !fragment.value().empty()) { - const auto rename_iter{renames.find(std::string{fragment.value()})}; - if (rename_iter != renames.end()) { - if (uri.is_fragment_only()) { - subschema.assign("id", - sourcemeta::core::JSON{"#" + rename_iter->second}); - } else { - const auto without_fragment{uri.recompose_without_fragment()}; - subschema.assign( - "id", sourcemeta::core::JSON{(without_fragment.has_value() - ? without_fragment.value() - : std::string{}) + - "#" + rename_iter->second}); - } - } - } - } - - if (subschema.defines("$ref") && subschema.at("$ref").is_string()) { - const auto &ref_string{subschema.at("$ref").to_string()}; - const sourcemeta::core::URI ref_uri{ref_string}; - const auto fragment{ref_uri.fragment()}; - if (fragment.has_value() && - renames.contains(std::string{fragment.value()})) { - const auto without_fragment{ref_uri.recompose_without_fragment()}; - const bool same_base = - ref_uri.is_fragment_only() || - (resource_base.has_value() && without_fragment.has_value() && - without_fragment.value() == resource_base.value()); - if (same_base) { - const auto &new_name{renames.at(std::string{fragment.value()})}; - subschema.assign( - "$ref", sourcemeta::core::JSON{ - ref_uri.is_fragment_only() - ? "#" + new_name - : (without_fragment.value() + "#" + new_name)}); - } - } - } - - for (const std::string_view object_keyword : - {"definitions", "properties", "patternProperties", "dependencies"}) { - if (subschema.defines(object_keyword) && - subschema.at(object_keyword).is_object()) { - std::vector keys; - keys.reserve(subschema.at(object_keyword).size()); - for (const auto &entry : subschema.at(object_keyword).as_object()) { - keys.push_back(entry.first); - } - for (const auto &key : keys) { - apply_anchor_renames_in_resource(subschema.at(object_keyword).at(key), - false, renames, resource_base); - } - } - } - - for (const std::string_view array_keyword : {"allOf", "anyOf", "oneOf"}) { - if (subschema.defines(array_keyword) && - subschema.at(array_keyword).is_array()) { - auto &array_value{subschema.at(array_keyword)}; - for (std::size_t index{0}; index < array_value.size(); ++index) { - apply_anchor_renames_in_resource(array_value.at(index), false, - renames, resource_base); - } - } - } - - for (const std::string_view single_keyword : - {"additionalProperties", "additionalItems", "not"}) { - if (subschema.defines(single_keyword)) { - apply_anchor_renames_in_resource(subschema.at(single_keyword), false, - renames, resource_base); - } - } - - if (subschema.defines("items")) { - auto &items{subschema.at("items")}; - if (items.is_array()) { - for (std::size_t index{0}; index < items.size(); ++index) { - apply_anchor_renames_in_resource(items.at(index), false, renames, - resource_base); - } - } else { - apply_anchor_renames_in_resource(items, false, renames, resource_base); - } - } - } - - static auto resource_needs_anchor_sanitization( - const sourcemeta::core::JSON &resource_root) -> bool { - std::vector invalid; - collect_invalid_anchors(resource_root, true, invalid); - return !invalid.empty(); - } - - static auto enclosing_resource_has_pending_sanitization( - const sourcemeta::core::SchemaFrame::Location &location, - const sourcemeta::core::JSON &root, - const sourcemeta::core::SchemaFrame &frame) -> bool { - std::optional closest; - frame.for_each_location( - [&](const sourcemeta::core::SchemaReferenceType, const std::string_view, - const sourcemeta::core::SchemaFrame::Location &entry) -> void { - const bool entry_is_resource_scope = - entry.type == - sourcemeta::core::SchemaFrame::LocationType::Resource || - entry.pointer.empty(); - if (!entry_is_resource_scope) { - return; - } - if (entry.pointer.size() > location.pointer.size()) { - return; - } - bool is_ancestor{true}; - for (std::size_t index{0}; index < entry.pointer.size(); ++index) { - if (!(entry.pointer.at(index) == location.pointer.at(index))) { - is_ancestor = false; - break; - } - } - if (!is_ancestor) { - return; - } - if (!closest.has_value() || - entry.pointer.size() > closest.value().size()) { - closest = entry.pointer; - } - }); - if (!closest.has_value()) { - return false; - } - const auto closest_pointer{sourcemeta::core::to_pointer(closest.value())}; - const auto &resource_schema{sourcemeta::core::get(root, closest_pointer)}; - return resource_needs_anchor_sanitization(resource_schema); - } }; diff --git a/vendor/blaze/src/convert/rules/upgrade_draft_6_to_draft_7.h b/vendor/blaze/src/convert/rules/upgrade_draft_6_to_draft_7.h index 98d204a6..f8b79ebf 100644 --- a/vendor/blaze/src/convert/rules/upgrade_draft_6_to_draft_7.h +++ b/vendor/blaze/src/convert/rules/upgrade_draft_6_to_draft_7.h @@ -1,42 +1,23 @@ class UpgradeDraft6ToDraft7 final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; UpgradeDraft6ToDraft7() : SchemaTransformRule{"upgrade_draft_6_to_draft_7"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, - const sourcemeta::core::JSON &root, + const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &frame, - const sourcemeta::core::SchemaFrame::Location &location, - const sourcemeta::core::SchemaWalker &, + const Site &site, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( - vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6) && - subschema_at_dialect(schema, location, DRAFT_6_URL)); + vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_6)); - return !frame.any_subschema_under( - location.pointer, - [&root](const sourcemeta::core::SchemaFrame::Location &entry) -> bool { - const auto entry_pointer{sourcemeta::core::to_pointer(entry.pointer)}; - const auto &entry_schema{sourcemeta::core::get(root, entry_pointer)}; - if (entry_schema.is_object() && entry_schema.defines("$ref")) { - return false; - } - - return has_pending_pattern(entry_schema); - }); + return at_dialect_declaration(schema, site); } - auto transform(sourcemeta::core::JSON &schema) const -> void override { - if (schema.defines("$schema") && schema.at("$schema").is_string() && - schema.at("$schema").to_string() == DRAFT_6_URL) { - schema.assign("$schema", sourcemeta::core::JSON{DRAFT_7_URL}); - drop_dialect_overrides(schema, true, DRAFT_7_URL); - } else { - mark_dialect_override(schema, DRAFT_7_URL); - } + auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void override { + bump_dialect(schema, site, DRAFT_7_URL); } private: @@ -53,26 +34,4 @@ class UpgradeDraft6ToDraft7 final : public SchemaTransformRule { static inline const std::array PROMOTED_KEYWORDS{ {"$comment", "if", "then", "else", "readOnly", "writeOnly", "contentMediaType", "contentEncoding"}}; - - static auto has_pending_pattern(const sourcemeta::core::JSON &subschema) - -> bool { - if (!subschema.is_object()) { - return false; - } - - if (subschema.defines("$schema") && subschema.at("$schema").is_string()) { - const auto &dialect{subschema.at("$schema").to_string()}; - if (dialect == DRAFT_4_URL || dialect == DRAFT_6_URL) { - return true; - } - } - - for (const auto &keyword : PROMOTED_KEYWORDS) { - if (subschema.defines(keyword)) { - return true; - } - } - - return false; - } }; diff --git a/vendor/blaze/src/convert/rules/upgrade_draft_7_to_draft_2019_09.h b/vendor/blaze/src/convert/rules/upgrade_draft_7_to_draft_2019_09.h index 101fe6a1..8f4e635d 100644 --- a/vendor/blaze/src/convert/rules/upgrade_draft_7_to_draft_2019_09.h +++ b/vendor/blaze/src/convert/rules/upgrade_draft_7_to_draft_2019_09.h @@ -1,73 +1,40 @@ class UpgradeDraft7To201909 final : public SchemaTransformRule { public: - using reframe_after_transform = std::true_type; UpgradeDraft7To201909() : SchemaTransformRule{"upgrade_draft_7_to_2019_09"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, - const sourcemeta::core::JSON &root, + const sourcemeta::core::JSON &, const sourcemeta::core::SchemaVocabularies &vocabularies, - const sourcemeta::core::SchemaFrame &frame, - const sourcemeta::core::SchemaFrame::Location &location, - const sourcemeta::core::SchemaWalker &, + const Site &site, const sourcemeta::core::SchemaWalker &, const sourcemeta::core::SchemaResolver &) const -> bool override { ONLY_CONTINUE_IF( vocabularies.contains(SchemaVocabularies::Known::JSON_SCHEMA_DRAFT_7) && schema.is_object()); - ONLY_CONTINUE_IF(subschema_at_dialect(schema, location, DRAFT_7_URL) || - has_actionable_id_fragment(schema) || - has_actionable_dependencies(schema) || - has_actionable_ref_siblings(schema)); - - if (frame.any_subschema_under( - location.pointer, - [&root]( - const sourcemeta::core::SchemaFrame::Location &entry) -> bool { - const auto entry_pointer{ - sourcemeta::core::to_pointer(entry.pointer)}; - const auto &entry_schema{ - sourcemeta::core::get(root, entry_pointer)}; - - return has_descendant_pending_pattern(entry_schema, - entry.dialect); - })) { - return false; - } - - return true; + return at_dialect_declaration(schema, site) || + has_actionable_id_fragment(schema) || + has_actionable_dependencies(schema) || + has_actionable_ref_siblings(schema); } - auto transform(sourcemeta::core::JSON &schema) const -> void override { + auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void override { this->renames_.clear(); this->prefix_ref_siblings(schema); this->split_id_fragment(schema); this->split_dependencies(schema); - if (bump_schema(schema)) { - drop_dialect_overrides(schema, true, DRAFT_2019_09_URL); - } else { - mark_dialect_override(schema, DRAFT_2019_09_URL); - } + bump_dialect(schema, site, DRAFT_2019_09_URL); } - [[nodiscard]] auto rereference(const std::string_view, - const sourcemeta::core::Pointer &, - const sourcemeta::core::Pointer &target, - const sourcemeta::core::Pointer ¤t) const - -> std::optional override { - for (const auto &[old_pointer, new_pointer] : this->renames_) { - const auto result{target.rebase(current.concat(old_pointer), - current.concat(new_pointer))}; - if (result != target) { - return result; - } - } - - return target; + [[nodiscard]] auto relocations() const -> std::vector override { + return {this->renames_.cbegin(), this->renames_.cend()}; } private: + static constexpr std::string_view DRAFT_3_URL{ + "http://json-schema.org/draft-03/schema#"}; static constexpr std::string_view DRAFT_4_URL{ "http://json-schema.org/draft-04/schema#"}; static constexpr std::string_view DRAFT_6_URL{ @@ -77,10 +44,16 @@ class UpgradeDraft7To201909 final : public SchemaTransformRule { static constexpr std::string_view DRAFT_2019_09_URL{ "https://json-schema.org/draft/2019-09/schema"}; + // A sibling that only ever annotated says the same thing once 2019-09 starts + // reading it, so shadowing it would lose a value for nothing. `$schema` is + // exempt because it names the dialect the subschema is read as rather than + // taking part in validation. `$id` is not exempt: Draft 7 ignored it beside a + // `$ref`, so it did not move the base the reference resolved against, and + // 2019-09 would start moving it and send the reference somewhere else // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) - static inline const std::array SHADOW_EXEMPT_KEYWORDS{ - {"$schema", "$id", "title", "description", "default", "examples", - "$comment", "readOnly", "writeOnly", "deprecated", "contentMediaType", + static inline const std::array SHADOW_EXEMPT_KEYWORDS{ + {"$schema", "title", "description", "default", "examples", "$comment", + "readOnly", "writeOnly", "deprecated", "contentMediaType", "contentEncoding"}}; static inline const std::array @@ -305,15 +278,6 @@ class UpgradeDraft7To201909 final : public SchemaTransformRule { schema.at("dependentRequired").into(std::move(dependent_required)); } - static auto bump_schema(sourcemeta::core::JSON &schema) -> bool { - if (schema.defines("$schema") && schema.at("$schema").is_string() && - schema.at("$schema").to_string() == DRAFT_7_URL) { - schema.assign("$schema", sourcemeta::core::JSON{DRAFT_2019_09_URL}); - return true; - } - return false; - } - static auto has_pending_pattern(const sourcemeta::core::JSON &subschema) -> bool { if (!subschema.is_object()) { @@ -329,6 +293,10 @@ class UpgradeDraft7To201909 final : public SchemaTransformRule { has_actionable_ref_siblings(subschema); } + static auto names_legacy_identifier(const std::string_view dialect) -> bool { + return dialect == DRAFT_3_URL || dialect == DRAFT_4_URL; + } + static auto has_descendant_pending_pattern(const sourcemeta::core::JSON &subschema, const std::string_view descendant_dialect) @@ -337,6 +305,13 @@ class UpgradeDraft7To201909 final : public SchemaTransformRule { return false; } + // A descendant read as a dialect the ladder does not name is never + // rewritten by anything, so it can never stop having work pending and + // waiting for it would block this upgrade for good + if (!names_ladder_dialect(descendant_dialect)) { + return false; + } + if (subschema.defines("$schema") && subschema.at("$schema").is_string()) { const auto &dialect{subschema.at("$schema").to_string()}; if (dialect == DRAFT_4_URL || dialect == DRAFT_6_URL || @@ -345,19 +320,27 @@ class UpgradeDraft7To201909 final : public SchemaTransformRule { } } - if (subschema.defines("id") && subschema.at("id").is_string() && - !subschema.defines("$id")) { - return true; - } + // Both of these name a keyword whose meaning depends on the dialect the + // descendant is read as. `id` identifies a resource through Draft 4 and is + // ordinary data from Draft 6 on, and the exclusive bounds are booleans over + // the same span and numbers afterwards. A descendant on a later dialect + // carrying either one is holding data that no rule will ever rewrite, so + // waiting for it would block this upgrade for good + if (names_legacy_identifier(descendant_dialect)) { + if (subschema.defines("id") && subschema.at("id").is_string() && + !subschema.defines("$id")) { + return true; + } - const auto *exclusive_minimum{subschema.try_at("exclusiveMinimum")}; - if (exclusive_minimum != nullptr && exclusive_minimum->is_boolean()) { - return true; - } + const auto *exclusive_minimum{subschema.try_at("exclusiveMinimum")}; + if (exclusive_minimum != nullptr && exclusive_minimum->is_boolean()) { + return true; + } - const auto *exclusive_maximum{subschema.try_at("exclusiveMaximum")}; - if (exclusive_maximum != nullptr && exclusive_maximum->is_boolean()) { - return true; + const auto *exclusive_maximum{subschema.try_at("exclusiveMaximum")}; + if (exclusive_maximum != nullptr && exclusive_maximum->is_boolean()) { + return true; + } } if (descendant_dialect == DRAFT_4_URL) { diff --git a/vendor/blaze/src/convert/rules/upgrade_openapi_3_0_to_draft_4.h b/vendor/blaze/src/convert/rules/upgrade_openapi_3_0_to_draft_4.h new file mode 100644 index 00000000..fa0efd4e --- /dev/null +++ b/vendor/blaze/src/convert/rules/upgrade_openapi_3_0_to_draft_4.h @@ -0,0 +1,524 @@ +class UpgradeOpenAPI30ToDraft4 final : public SchemaTransformRule { +public: + UpgradeOpenAPI30ToDraft4(const ConvertTarget target) + : SchemaTransformRule{"upgrade_openapi_3_0_to_draft_4"}, + keeps_read_only_{names_read_only(target)}, + keeps_deprecated_{names_deprecated(target)}, + keeps_openapi_annotations_{names_openapi_annotations(target)}, + keeps_default_mapping_{target == ConvertTarget::OpenAPI32} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &vocabularies, + const Site &site, const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> bool override { + ONLY_CONTINUE_IF( + vocabularies.contains(SchemaVocabularies::Known::OPENAPI_3_0_BASE) && + schema.is_object()); + + return holds_openapi_3_0_override(schema) || + holds_ignored_sibling(schema) || + this->has_pending_openapi_3_0_pattern(schema) || + holds_promoted_keyword(schema) || + holds_movable_definitions(schema) || holds_pending_shape(schema) || + at_dialect_declaration(schema, site); + } + + auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void override { + this->renames_.clear(); + this->relocations_.clear(); + + // OpenAPI 3.0 section 4.7.23: a Reference Object cannot be extended with + // additional properties, and any property added to one SHALL be ignored. An + // object carrying `$ref` can only be a Reference Object, because the Schema + // Object closes itself to additional properties and does not name `$ref`, + // so every member beside it was ignored and has to go on being ignored. + // + // Carrying one across as the keyword a target names does two wrong things + // at once: it reads a constraint the source never applied, and it hands the + // target's own meta-schema a value to type-check that nothing ever checked. + // So none of the rewriting below applies to a Reference Object, and its + // members go across as the author data they already were + if (schema.defines("$ref")) { + for (const auto &name : member_names(schema)) { + if (name == "$ref" || name == "$schema" || name.starts_with("x-")) { + continue; + } + + this->shadow(schema, name); + } + } else { + // Before the shadowing below, so that a `nullable` this consumes is gone + // by the time the annotations are renamed and one it declines is still + // there to be renamed with them + fold_nullable_into_type(schema); + + // Comparing the two meta-schemas member by member, the OpenAPI 3.0 Schema + // Object is looser in exactly three places and equal or stricter + // everywhere else: `enum` states `uniqueItems: false`, the three + // applicator arrays carry no `minItems`, and it states none of the + // keyword dependencies Draft 4 does. The first two are shapes, rewritten + // here, and the third is answered for below + deduplicate_enum(schema); + fold_empty_applicators(schema); + + // Draft 4 requires each of these to accompany the bound it modifies, + // while the OpenAPI 3.0 Schema Object states no dependency between + // keywords at all. One standing on its own modifies a bound that is not + // there, and an `allOf` with no branches asserts nothing of the instance, + // so each goes across as the author data it was. None of the three can + // stay where it is: Draft 4 reads an incomplete bound and an empty + // applicator as keywords it will not accept + for (const auto &[keyword, companion] : UNACCOMPANIED) { + if (schema.defines(sourcemeta::core::JSON::String{keyword}) && + !schema.defines(sourcemeta::core::JSON::String{companion})) { + this->shadow(schema, keyword); + } + } + + // After the fold above, which puts what an empty `anyOf` or `oneOf` means + // into `allOf` and so reuses an empty one as the container it needs. An + // `allOf` still empty here is one the author wrote alone + if (schema.defines("allOf") && schema.at("allOf").is_array() && + schema.at("allOf").empty()) { + this->shadow(schema, "allOf"); + } + + for (const auto &keyword : KEYWORDS) { + if (!schema.defines(sourcemeta::core::JSON::String{keyword}) || + this->carries_as_keyword(schema, keyword)) { + continue; + } + + this->shadow(schema, keyword); + } + + // Taken out before the shadowing below, so that what moves is the map the + // author wrote under this name and not a `definitions` that the shadowing + // has since put there. Doing it the other way round hands the shadowed + // member straight back to the move, which promotes the author's data to + // the keyword the shadowing was there to protect it from + auto definitions{take_definitions(schema, this->relocations_)}; + + for (const auto &keyword : PROMOTED_BY_DRAFT_4) { + this->shadow(schema, keyword); + } + + if (!definitions.empty()) { + schema.assign("definitions", std::move(definitions)); + } + } + + bump_dialect(schema, site, DRAFT_4_URL); + } + + // A reference is rebased through these in order, so one whose source names a + // position another's destination sits under has to come first. Shadowing + // `definitions` names the very position the move below places its entries + // under, so the renames are published ahead of the moves: the other way + // round, the rename catches an entry the move has just put there and sends + // every reference to it somewhere nothing lives + [[nodiscard]] auto relocations() const -> std::vector override { + std::vector result; + result.reserve(this->renames_.size() + this->relocations_.size()); + for (const auto &[old_name, new_name] : this->renames_) { + result.emplace_back(sourcemeta::core::Pointer{old_name}, + sourcemeta::core::Pointer{new_name}); + } + + result.insert(result.cend(), this->relocations_.cbegin(), + this->relocations_.cend()); + return result; + } + +private: + // The only member the dialect reads a named subschema under, which is how a + // document on it keeps anything reusable at all. Its applicators hold + // subschemas too, but none of them by a name the author chooses + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) + static inline const std::string DEFINITIONS{"x-definitions"}; + + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) + static inline const std::string DRAFT_4_URL{ + "http://json-schema.org/draft-04/schema#"}; + + // What the OpenAPI 3.0 Schema Object adds that Draft 4 has no word for, less + // the `nullable` the rewrite above consumes. Every one of these annotates + // rather than asserts, so shadowing one changes no verdict, and Draft 4 reads + // an `x-` member as the unknown keyword it is. All but `nullable` are + // shadowed only where the target has no word for them, which + // `carries_as_keyword` settles: three became keywords of a later draft, and + // four are named by the OpenAPI dialects above 3.0 + // Which targets name each of the keywords this edge can carry across. Spelled + // out rather than compared, because `ConvertTarget` is a public enumeration + // that promises no ordering, so reading one out of the declaration order + // would make rearranging that header change what this rule does + static auto names_read_only(const ConvertTarget target) -> bool { + return target == ConvertTarget::Draft7 || + target == ConvertTarget::Draft201909 || + target == ConvertTarget::Draft202012 || + target == ConvertTarget::OpenAPI31 || + target == ConvertTarget::OpenAPI32; + } + + static auto names_deprecated(const ConvertTarget target) -> bool { + return target == ConvertTarget::Draft201909 || + target == ConvertTarget::Draft202012 || + target == ConvertTarget::OpenAPI31 || + target == ConvertTarget::OpenAPI32; + } + + static auto names_openapi_annotations(const ConvertTarget target) -> bool { + return target == ConvertTarget::OpenAPI31 || + target == ConvertTarget::OpenAPI32; + } + + static constexpr std::array KEYWORDS{ + {"nullable", "discriminator", "xml", "externalDocs", "example", + "deprecated", "readOnly", "writeOnly"}}; + + // Where a `mapping` entry names a schema inside the container this edge moves + // NOLINTNEXTLINE(cert-err58-cpp,bugprone-throwing-static-initialization) + static inline const std::string MAPPING_INTO_DEFINITIONS{"#/x-definitions/"}; + + // Each of these modifies the bound beside it, which Draft 4 requires to be + // there and OpenAPI 3.0 does not + static constexpr std::array, 2> + UNACCOMPANIED{ + {{"exclusiveMinimum", "minimum"}, {"exclusiveMaximum", "maximum"}}}; + + // The other direction: what Draft 4 names that the OpenAPI 3.0 Schema Object + // does not, so a document carrying one carries author data that the target + // would start reading as a keyword. `id` is the one that matters most, as + // Draft 4 takes it for the identifier that every reference in the document + // resolves against. + // + // Two positions can carry one of these, and the Schema Object is neither: + // there the dialect prohibits all five outright. A Reference Object + // constrains nothing beyond its `$ref`, though the branch above answers for + // that one by shadowing every member it holds. The other is an entry of an + // `x-definitions` map, which the dialect constrains with `^x-` and so not at + // all. That is the position this list is for, and it is narrow: an entry + // something references is read as a Schema Object and refused, so what + // survives to here sits in an entry nothing reads. It still has to be + // shadowed, because the member would otherwise reach the target as a keyword + // whose value makes the output invalid against the dialect it declares. + // + // `$schema` is left out because it is the one of the six that names a + // dialect. Where framing would read one, `bump_dialect` writes the rung's own + // in its place, and anywhere else `ShadowStrayDialectDeclaration` carries it + // across as the author data it is + static constexpr std::array PROMOTED_BY_DRAFT_4{ + {"id", "definitions", "dependencies", "patternProperties", + "additionalItems"}}; + + // Carry a member across as the author data it is, under a name the target + // reads as nothing. A name already taken gains another prefix rather than + // overwriting what is there + auto shadow(sourcemeta::core::JSON &schema, + const std::string_view keyword) const -> void { + const std::string keyword_name{keyword}; + if (!schema.defines(keyword_name)) { + return; + } + + std::string prefixed_name{"x-" + keyword_name}; + while (schema.defines(prefixed_name) || names_core_reads(prefixed_name)) { + prefixed_name.insert(0, "x-"); + } + + this->renames_.emplace(keyword_name, prefixed_name); + schema.rename(keyword_name, std::move(prefixed_name)); + } + + // Whether this subschema still carries something of the dialect that the + // transform will act on. Each of those is either consumed or shadowed, so the + // answer turns false once it has run and the conversion stops finding work + // here. One carried across as itself is not work, or the answer would never + // turn false + [[nodiscard]] auto + has_pending_openapi_3_0_pattern(const sourcemeta::core::JSON &subschema) const + -> bool { + for (const auto &keyword : KEYWORDS) { + if (subschema.defines(sourcemeta::core::JSON::String{keyword}) && + !this->carries_as_keyword(subschema, keyword)) { + return true; + } + } + + return false; + } + + // Three of these became keywords of a later draft with the meaning OpenAPI + // 3.0 already gave them, so a target defining one takes it as it stands + // rather than as the author data the shadowing would make of it. `readOnly` + // and `writeOnly` are native from Draft 7 and `deprecated` from 2019-09. + // + // This is the one place in the module where a rule reads the target the + // caller finally asked for rather than the rung in front of it. It has to: + // Draft 4 cannot express any of the three, so what the waypoint should carry + // is only answerable from where the document is going. The cost is that + // converting by way of Draft 4 no longer reproduces the direct result for + // these three, which `convertsuite` records per fixture + [[nodiscard]] auto carries_as_keyword(const sourcemeta::core::JSON &subschema, + const std::string_view keyword) const + -> bool { + if (keyword == "readOnly" || keyword == "writeOnly") { + return this->keeps_read_only_; + } + + if (keyword == "deprecated") { + return this->keeps_deprecated_; + } + + if (!this->keeps_openapi_annotations_) { + return false; + } + + // Of the four the OpenAPI dialects name for themselves, only this one is + // stated more loosely by 3.0 than by the dialects above it, so only this + // one has a shape to answer for + if (keyword == "discriminator") { + const auto &discriminator{ + subschema.at(sourcemeta::core::JSON::String{keyword})}; + return this->is_expressible_discriminator(discriminator) && + mapping_survives_the_move(discriminator); + } + + // `nullable` is not among them: no OpenAPI dialect above 3.0 has it, as a + // type union says it there instead + return keyword == "xml" || keyword == "externalDocs" || + keyword == "example"; + } + + // A `mapping` value may name a schema by reference, and the frame indexes no + // such reference, so nothing follows one when the schema it names moves. The + // one member this edge moves is the subschema container, so a mapping naming + // a position inside it would be left pointing where nothing is any more. + // Carrying that across as a keyword would publish a discriminator that lies + // about where its branches are, so it goes across as author data instead, + // which at least reads as the record of the author's intent that it is + static auto + mapping_survives_the_move(const sourcemeta::core::JSON &discriminator) + -> bool { + const auto *mapping{discriminator.try_at("mapping")}; + if (mapping == nullptr || !mapping->is_object()) { + return true; + } + + for (const auto &entry : mapping->as_object()) { + if (entry.second.is_string() && + entry.second.to_string().starts_with(MAPPING_INTO_DEFINITIONS)) { + return false; + } + } + + return true; + } + + // OpenAPI 3.0 leaves its Discriminator Object open, naming `propertyName` and + // `mapping` without closing the object to anything else, while 3.1 and 3.2 + // close theirs to those two and the extension prefix. One carrying anything + // more is a discriminator the target cannot read, so it goes across as the + // author data it has to be there rather than as a keyword that would make the + // output invalid against the dialect it declares. + // + // Which members are asked about depends on the target, because the two + // shapes differ: 3.1 names `propertyName` and `mapping`, and 3.2 names + // `defaultMapping` as well. A discriminator carrying that one is a + // discriminator 3.2 reads and 3.1 does not + [[nodiscard]] auto is_expressible_discriminator( + const sourcemeta::core::JSON &discriminator) const -> bool { + if (!discriminator.is_object()) { + return false; + } + + for (const auto &entry : discriminator.as_object()) { + if (entry.first == "propertyName" || entry.first == "mapping" || + entry.first.starts_with("x-")) { + continue; + } + + if (entry.first == "defaultMapping" && this->keeps_default_mapping_) { + continue; + } + + return false; + } + + return true; + } + + static auto holds_promoted_keyword(const sourcemeta::core::JSON &schema) + -> bool { + for (const auto &keyword : PROMOTED_BY_DRAFT_4) { + if (schema.defines(sourcemeta::core::JSON::String{keyword})) { + return true; + } + } + + return false; + } + + // The members of a Reference Object that the dialect ignored and that a + // target would start reading. One already carrying the extension prefix is + // read as nothing anywhere, and a `$schema` is answered for by the rule that + // settles a dialect declaration + static auto holds_ignored_sibling(const sourcemeta::core::JSON &schema) + -> bool { + if (!schema.defines("$ref")) { + return false; + } + + for (const auto &entry : schema.as_object()) { + if (entry.first != "$ref" && entry.first != "$schema" && + !entry.first.starts_with("x-")) { + return true; + } + } + + return false; + } + + // Taken as values, since shadowing renames the members being walked + static auto member_names(const sourcemeta::core::JSON &schema) + -> std::vector { + std::vector names; + names.reserve(schema.size()); + for (const auto &entry : schema.as_object()) { + names.push_back(entry.first); + } + + return names; + } + + // The extension prefix is not a free namespace: core reads two names in it, + // this dialect's subschema container and the marker that names a dialect. A + // member prefixed onto either would hand core a schema map or a dialect the + // author never wrote, so both count as taken and the prefix grows past them + static auto names_core_reads(const std::string_view name) -> bool { + return name == DEFINITIONS || name == DIALECT_OVERRIDE_KEYWORD; + } + + // Core reads the marker as the dialect of the position carrying it, so one + // naming OpenAPI 3.0 holds that position on this dialect until it is cleared. + // Nothing else can clear it: every rung of the ladder is gated on a + // vocabulary this dialect does not have, so the position would keep both the + // marker and whatever spelling the dialect gave its keywords + static auto holds_openapi_3_0_override(const sourcemeta::core::JSON &schema) + -> bool { + const auto *value{schema.try_at(DIALECT_OVERRIDE_KEYWORD)}; + return value != nullptr && value->is_string() && + names_openapi_3_0_dialect(value->to_string()); + } + + // Whether a shape only the OpenAPI 3.0 dialect admits is still here. Each is + // either rewritten or shadowed by the transform, so the answer turns false + // once it has run + static auto holds_pending_shape(const sourcemeta::core::JSON &schema) + -> bool { + const auto *options{schema.try_at("enum")}; + if (options != nullptr && options->is_array() && !options->unique()) { + return true; + } + + if ((schema.defines("exclusiveMinimum") && !schema.defines("minimum")) || + (schema.defines("exclusiveMaximum") && !schema.defines("maximum"))) { + return true; + } + + for (const auto &keyword : {"allOf", "anyOf", "oneOf"}) { + const auto *branches{ + schema.try_at(sourcemeta::core::JSON::String{keyword})}; + if (branches != nullptr && branches->is_array() && branches->empty()) { + return true; + } + } + + return false; + } + + // The extension prefix constrains nothing, so a member of this one holds + // whatever its author put there, while `definitions` reads every member of + // itself as a schema. Only what is one has somewhere to go + static auto holds_schema(const sourcemeta::core::JSON &entry) -> bool { + return entry.is_object() || entry.is_boolean(); + } + + // Neither OpenAPI 3.0 nor Draft 4 has a boolean schema, and yet the walker + // reads every member of this one as a subschema, so a reference naming a + // boolean here is asserting something and has to keep asserting it. Draft 4 + // says the same two things with the schema every instance satisfies and the + // schema none does + static auto as_object_schema(const sourcemeta::core::JSON &entry) + -> sourcemeta::core::JSON { + if (!entry.is_boolean()) { + return entry; + } + + auto result{sourcemeta::core::JSON::make_object()}; + if (!entry.to_boolean()) { + result.assign("not", sourcemeta::core::JSON::make_object()); + } + + return result; + } + + static auto holds_movable_definitions(const sourcemeta::core::JSON &schema) + -> bool { + if (!schema.defines(DEFINITIONS) || !schema.at(DEFINITIONS).is_object()) { + return false; + } + + for (const auto &entry : schema.at(DEFINITIONS).as_object()) { + if (holds_schema(entry.second)) { + return true; + } + } + + return false; + } + + // Draft 4 keeps under `definitions` what the OpenAPI 3.0 Schema Object keeps + // here, and reads nothing of a schema under a member it does not know, so + // every entry that is a schema is taken out for the caller to place and the + // references reaching one follow it. Anything else stays where it was rather + // than being carried somewhere that would read it as a schema it is not + static auto take_definitions(sourcemeta::core::JSON &schema, + std::vector &relocations) + -> sourcemeta::core::JSON { + auto moved{sourcemeta::core::JSON::make_object()}; + if (!holds_movable_definitions(schema)) { + return moved; + } + + auto kept{sourcemeta::core::JSON::make_object()}; + for (const auto &entry : schema.at(DEFINITIONS).as_object()) { + if (!holds_schema(entry.second)) { + kept.assign(entry.first, entry.second); + continue; + } + + relocations.emplace_back( + sourcemeta::core::Pointer{DEFINITIONS, entry.first}, + sourcemeta::core::Pointer{"definitions", entry.first}); + moved.assign(entry.first, as_object_schema(entry.second)); + } + + schema.erase(DEFINITIONS); + if (!kept.empty()) { + schema.assign(DEFINITIONS, std::move(kept)); + } + + return moved; + } + + const bool keeps_read_only_; + const bool keeps_deprecated_; + const bool keeps_openapi_annotations_; + const bool keeps_default_mapping_; + mutable std::unordered_map renames_; + mutable std::vector relocations_; +}; diff --git a/vendor/blaze/src/convert/rules/upgrade_openapi_3_1_to_openapi_3_2.h b/vendor/blaze/src/convert/rules/upgrade_openapi_3_1_to_openapi_3_2.h new file mode 100644 index 00000000..01af6f75 --- /dev/null +++ b/vendor/blaze/src/convert/rules/upgrade_openapi_3_1_to_openapi_3_2.h @@ -0,0 +1,26 @@ +class UpgradeOpenAPI31ToOpenAPI32 final : public SchemaTransformRule { +public: + UpgradeOpenAPI31ToOpenAPI32() + : SchemaTransformRule{"upgrade_openapi_3_1_to_openapi_3_2"} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &vocabularies, + const Site &site, const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const -> bool override { + // Asking for the 3.1 vocabulary rather than for 2020-12 is what keeps this + // rule off a document that has yet to reach the rung below, which is the + // other OpenAPI rule's business + ONLY_CONTINUE_IF( + vocabularies.contains(SchemaVocabularies::Known::OPENAPI_3_1_BASE) && + !vocabularies.contains(SchemaVocabularies::Known::OPENAPI_3_2_BASE)); + + return at_dialect_declaration(schema, site); + } + + auto transform(sourcemeta::core::JSON &schema, const Site &site) const + -> void override { + bump_dialect(schema, site, OPENAPI_3_2_DIALECT); + } +}; diff --git a/vendor/blaze/src/evaluator/evaluator_describe.cc b/vendor/blaze/src/evaluator/evaluator_describe.cc index 17a6c982..4efe585d 100644 --- a/vendor/blaze/src/evaluator/evaluator_describe.cc +++ b/vendor/blaze/src/evaluator/evaluator_describe.cc @@ -1083,6 +1083,17 @@ auto describe(const bool valid, const Instruction &step, return "The value was expected to be an integer above the given minimum"; } + if (step.type == + sourcemeta::blaze::InstructionIndex::AssertionNumberIntegerBounded) { + std::ostringstream message; + const auto &value{instruction_value(step)}; + message << "The " << value_type_name(target) << " value "; + describe_stringify(target, message); + message << " was expected to be an integer between " << value.first + << " and " << value.second; + return message.str(); + } + if (step.type == sourcemeta::blaze::InstructionIndex::AssertionObjectPropertiesSimple) { return "The object value was expected to validate against the defined " @@ -1717,6 +1728,32 @@ auto describe(const bool valid, const Instruction &step, "string is short enough to satisfy a negative maximum length"; } + // The lower bounds below compile the same way when the bound is too large + // to hold, as nothing can be big enough to satisfy one + if (keyword == "minLength") { + return "The value was expected to be of a type other than string, as no " + "string is long enough to satisfy a minimum length that large"; + } + + if (keyword == "minItems") { + return "The value was expected to be of a type other than array, as no " + "array has enough items to satisfy a minimum size that large"; + } + + if (keyword == "minProperties") { + return "The value was expected to be of a type other than object, as no " + "object has enough properties to satisfy a minimum size that " + "large"; + } + + // `contains` compiles the same way when its `minContains` is too large to + // hold, as no array has that many items for the subschema to match + if (keyword == "contains") { + return "The value was expected to be of a type other than array, as no " + "array has enough matching items to satisfy a minimum count that " + "large"; + } + std::ostringstream message; describe_not_types_check(valid, target.type(), instruction_value(step), message); @@ -2291,6 +2328,23 @@ auto describe(const bool valid, const Instruction &step, return message.str(); } + if (step.type == sourcemeta::blaze::InstructionIndex::AssertionNumberType) { + std::ostringstream message; + message << "The " << value_type_name(target) << " value "; + describe_stringify(target, message); + message << " was expected to be exactly representable as an IEEE 754 "; + switch (instruction_value(step)) { + case ValueNumberType::Float: + message << "single precision floating point number"; + break; + case ValueNumberType::Double: + message << "double precision floating point number"; + break; + } + + return message.str(); + } + if (step.type == sourcemeta::blaze::InstructionIndex::AssertionStringType) { assert(target.is_string()); std::ostringstream message; @@ -2360,6 +2414,9 @@ auto describe(const bool valid, const Instruction &step, case ValueStringType::Color: message << " CSS 2 color"; break; + case ValueStringType::Base64: + message << " RFC 4648 Base64 string"; + break; default: return unknown(); } diff --git a/vendor/blaze/src/evaluator/evaluator_json.cc b/vendor/blaze/src/evaluator/evaluator_json.cc index 28d0f9f1..7f159f9f 100644 --- a/vendor/blaze/src/evaluator/evaluator_json.cc +++ b/vendor/blaze/src/evaluator/evaluator_json.cc @@ -44,6 +44,7 @@ auto value_from_json(const sourcemeta::core::JSON &wrapper) case 22: return sourcemeta::core::from_json(value); case 23: return sourcemeta::core::from_json(value); case 24: return sourcemeta::core::from_json(value); + case 25: return sourcemeta::core::from_json(value); // clang-format on default: std::unreachable(); diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h index 60ba80c6..4ffdd52e 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator.h @@ -46,7 +46,7 @@ struct Template { }; /// @ingroup evaluator -constexpr std::size_t JSON_VERSION{7}; +constexpr std::size_t JSON_VERSION{9}; /// @ingroup evaluator /// Parse a template from JSON diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h index dd606c18..33998096 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_dispatch.h @@ -13,6 +13,8 @@ #include #include +#include // assert + // TODO(C++23): Replace SOURCEMETA_ASSUME with [[assume]] when available // across all compilers (Clang 19, GCC 13) #if defined(__clang__) @@ -242,6 +244,33 @@ inline auto effective_type_strict_real(const JSON &instance) noexcept } } +// An integral number that no 64-bit integer can hold cannot be narrowed to one +// to be compared, and `as_integer` throws rather than saturating, so the +// comparison happens in a domain wide enough to hold any of them instead. +// A real converts through `exact_from` because the constructor spells a double +// with 17 significant digits, which is too few to tell apart two integers that +// a bound of this width can sit between +inline auto as_decimal(const JSON &instance) -> Decimal { + assert(instance.is_integral()); + return instance.is_decimal() ? instance.to_decimal() + : Decimal::exact_from(instance.to_real()); +} + +inline auto integer_within_bounds(const JSON &target, + const ValueIntegerBounds &value) -> bool { + if (target.is_integer()) { + const auto integer{target.to_integer()}; + return integer >= value.first && integer <= value.second; + } + + if (target.is_integral()) { + const auto decimal{as_decimal(target)}; + return decimal >= Decimal{value.first} && decimal <= Decimal{value.second}; + } + + return false; +} + template inline auto assume_value(const Value &variant) noexcept -> const T & { const auto *pointer{std::get_if(&variant)}; @@ -837,15 +866,7 @@ INSTRUCTION_HANDLER(AssertionDivisible) { } INSTRUCTION_DIRECT(AssertionTypeIntegerBounded, ValueIntegerBounds) { - if (target.is_integer()) { - const auto integer{target.to_integer()}; - return integer >= value.first && integer <= value.second; - } - if (target.is_integral()) { - const auto integer{target.as_integer()}; - return integer >= value.first && integer <= value.second; - } - return false; + return integer_within_bounds(target, value); } INSTRUCTION_HANDLER(AssertionTypeIntegerBounded) { @@ -875,7 +896,7 @@ INSTRUCTION_DIRECT(AssertionTypeIntegerLowerBound, ValueIntegerBounds) { return target.to_integer() >= value.first; } if (target.is_integral()) { - return target.as_integer() >= value.first; + return as_decimal(target) >= Decimal{value.first}; } return false; } @@ -891,6 +912,13 @@ INSTRUCTION_DIRECT(AssertionTypeIntegerLowerBoundStrict, ValueIntegerBounds) { if (target.is_integer()) { return target.to_integer() >= value.first; } + // An integer too large for 64 bits is held as a decimal, which the strict + // type check still reads as an integer when it was written as digits alone. + // The bounded counterpart needs no such branch, as a value out of that range + // cannot sit within bounds of that width either way + if (effective_type_strict_real(target) == JSON::Type::Integer) { + return target.to_decimal() >= Decimal{value.first}; + } return false; } @@ -901,6 +929,30 @@ INSTRUCTION_HANDLER(AssertionTypeIntegerLowerBoundStrict) { EVALUATE_END(AssertionTypeIntegerLowerBoundStrict); } +INSTRUCTION_DIRECT(AssertionNumberIntegerBounded, ValueIntegerBounds) { + return integer_within_bounds(target, value); +} + +INSTRUCTION_HANDLER(AssertionNumberIntegerBounded) { + EVALUATE_BEGIN_NON_STRING(AssertionNumberIntegerBounded, target.is_number()); + result = DIRECT(AssertionNumberIntegerBounded, target, + assume_value_copy(instruction.value)); + EVALUATE_END(AssertionNumberIntegerBounded); +} + +INSTRUCTION_HANDLER(AssertionNumberType) { + EVALUATE_BEGIN_NON_STRING(AssertionNumberType, target.is_number()); + switch (assume_value_copy(instruction.value)) { + case ValueNumberType::Float: + result = target.is_float(); + break; + case ValueNumberType::Double: + result = target.is_double(); + break; + } + EVALUATE_END(AssertionNumberType); +} + INSTRUCTION_HANDLER(AssertionStringType) { EVALUATE_BEGIN_IF_STRING(AssertionStringType); const auto value{assume_value_copy(instruction.value)}; @@ -968,6 +1020,9 @@ INSTRUCTION_HANDLER(AssertionStringType) { case ValueStringType::Color: result = is_css2_color(target); break; + case ValueStringType::Base64: + result = sourcemeta::core::is_base64(target); + break; default: std::unreachable(); } @@ -2484,12 +2539,12 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash) { // Otherwise why emit this instruction? assert(!value.second.first.empty()); + result = true; + if (!target.is_array()) [[unlikely]] { EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash); } - result = true; - const auto hashes_size{value.second.first.size()}; for (const auto &item : target.as_array()) { if (!item.is_object()) [[unlikely]] { @@ -2595,6 +2650,7 @@ INSTRUCTION_HANDLER(LoopItemsPropertiesExactlyTypeStrictHash3) { assert(!value.second.first.empty()); if (!target.is_array()) [[unlikely]] { + result = true; EVALUATE_END(LoopItemsPropertiesExactlyTypeStrictHash3); } @@ -2826,7 +2882,7 @@ using DispatchHandler = bool (*)( template // Must have same order as InstructionIndex // NOLINTNEXTLINE(modernize-avoid-c-arrays) -static constexpr DispatchHandler HANDLERS[101] = { +static constexpr DispatchHandler HANDLERS[103] = { AssertionFail, AssertionDefines, AssertionDefinesStrict, @@ -2867,6 +2923,8 @@ static constexpr DispatchHandler HANDLERS[101] = { AssertionTypeIntegerBoundedStrict, AssertionTypeIntegerLowerBound, AssertionTypeIntegerLowerBoundStrict, + AssertionNumberIntegerBounded, + AssertionNumberType, AssertionStringType, AssertionPropertyType, AssertionPropertyTypeEvaluate, diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_instruction.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_instruction.h index 0c926a27..8e08a4ed 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_instruction.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_instruction.h @@ -60,6 +60,8 @@ enum class InstructionIndex : std::uint8_t { AssertionTypeIntegerBoundedStrict, AssertionTypeIntegerLowerBound, AssertionTypeIntegerLowerBoundStrict, + AssertionNumberIntegerBounded, + AssertionNumberType, AssertionStringType, AssertionPropertyType, AssertionPropertyTypeEvaluate, @@ -166,6 +168,8 @@ constexpr std::string_view INSTRUCTION_NAMES[] = { "AssertionTypeIntegerBoundedStrict", "AssertionTypeIntegerLowerBound", "AssertionTypeIntegerLowerBoundStrict", + "AssertionNumberIntegerBounded", + "AssertionNumberType", "AssertionStringType", "AssertionPropertyType", "AssertionPropertyTypeEvaluate", diff --git a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_value.h b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_value.h index 54dbe212..50475831 100644 --- a/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_value.h +++ b/vendor/blaze/src/evaluator/include/sourcemeta/blaze/evaluator_value.h @@ -139,7 +139,8 @@ enum class ValueStringType : std::uint8_t { RelativeJSONPointer, UUID, Regex, - Color + Color, + Base64 }; /// @ingroup evaluator @@ -189,13 +190,19 @@ using ValueObjectProperties = std::vector< std::tuple>; /// @ingroup evaluator -using Value = std::variant< - ValueNone, ValueJSON, ValueSet, ValueString, ValueProperty, ValueStrings, - ValueStringSet, ValueTypes, ValueType, ValueRegex, ValueUnsignedInteger, - ValueRange, ValueBoolean, ValueNamedIndexes, ValueStringType, - ValueStringMap, ValuePropertyFilter, ValueIndexPair, ValuePointer, - ValueTypedProperties, ValueStringHashes, ValueTypedHashes, - ValueIntegerBounds, ValueIntegerBoundsWithSize, ValueObjectProperties>; +/// Represents a compiler step number logical type +enum class ValueNumberType : std::uint8_t { Float, Double }; + +/// @ingroup evaluator +using Value = + std::variant; } // namespace sourcemeta::blaze diff --git a/vendor/blaze/src/output/include/sourcemeta/blaze/output_standard.h b/vendor/blaze/src/output/include/sourcemeta/blaze/output_standard.h index 60b88a59..25cd45f7 100644 --- a/vendor/blaze/src/output/include/sourcemeta/blaze/output_standard.h +++ b/vendor/blaze/src/output/include/sourcemeta/blaze/output_standard.h @@ -80,10 +80,17 @@ standard(Evaluator &evaluator, const Template &schema, /// /// An overload of the standard output function that includes line and column /// position information as an extension. +/// +/// When validating a subtree of the document the given tracker was populated +/// from, set `instance_base` to the location of that subtree within the +/// document. The reported instance locations remain relative to the instance +/// that was validated, as the specification mandates, while positions are +/// looked up from the base, as they refer to the document as a whole. auto SOURCEMETA_BLAZE_OUTPUT_EXPORT standard(Evaluator &evaluator, const Template &schema, const sourcemeta::core::JSON &instance, const StandardOutput format, - const sourcemeta::core::PointerPositionTracker &instance_tracker) + const sourcemeta::core::PointerPositionTracker &instance_tracker, + const sourcemeta::core::Pointer &instance_base = {}) -> sourcemeta::core::JSON; } // namespace sourcemeta::blaze diff --git a/vendor/blaze/src/output/output_standard.cc b/vendor/blaze/src/output/output_standard.cc index c2c99de2..c7ac2085 100644 --- a/vendor/blaze/src/output/output_standard.cc +++ b/vendor/blaze/src/output/output_standard.cc @@ -7,6 +7,7 @@ #include // assert #include // std::ref, std::reference_wrapper #include // std::map +#include // std::optional #include // std::string #include // std::tie #include // std::vector @@ -50,10 +51,23 @@ auto group_annotations(const SimpleOutput &output) return result; } +auto lookup_position(const sourcemeta::core::PointerPositionTracker &tracker, + const sourcemeta::core::Pointer &base, + const sourcemeta::core::WeakPointer &instance_location) + -> std::optional { + const auto pointer{sourcemeta::core::to_pointer(instance_location)}; + if (base.empty()) { + return tracker.get(pointer); + } + + return tracker.get(base.concat(pointer)); +} + auto handle_standard(Evaluator &evaluator, const Template &schema, const sourcemeta::core::JSON &instance, const StandardOutput format, - const sourcemeta::core::PointerPositionTracker *tracker) + const sourcemeta::core::PointerPositionTracker *tracker, + const sourcemeta::core::Pointer &base) -> sourcemeta::core::JSON { // We avoid a callback for this specific case for performance reasons if (format == StandardOutput::Flag) { @@ -83,8 +97,8 @@ auto handle_standard(Evaluator &evaluator, const Template &schema, annotation.first.instance_location)}); if (tracker != nullptr) { - const auto position{tracker->get( - sourcemeta::core::to_pointer(annotation.first.instance_location))}; + const auto position{lookup_position( + *tracker, base, annotation.first.instance_location)}; if (position.has_value()) { unit.assign_assume_new("instancePosition", sourcemeta::core::to_json(position.value())); @@ -125,7 +139,7 @@ auto handle_standard(Evaluator &evaluator, const Template &schema, if (tracker != nullptr) { const auto position{ - tracker->get(sourcemeta::core::to_pointer(entry.instance_location))}; + lookup_position(*tracker, base, entry.instance_location)}; if (position.has_value()) { unit.assign_assume_new("instancePosition", sourcemeta::core::to_json(position.value())); @@ -146,16 +160,17 @@ auto handle_standard(Evaluator &evaluator, const Template &schema, auto standard(Evaluator &evaluator, const Template &schema, const sourcemeta::core::JSON &instance, const StandardOutput format) -> sourcemeta::core::JSON { - return handle_standard(evaluator, schema, instance, format, nullptr); + return handle_standard(evaluator, schema, instance, format, nullptr, {}); } auto standard(Evaluator &evaluator, const Template &schema, const sourcemeta::core::JSON &instance, const StandardOutput format, - const sourcemeta::core::PointerPositionTracker &instance_tracker) + const sourcemeta::core::PointerPositionTracker &instance_tracker, + const sourcemeta::core::Pointer &instance_base) -> sourcemeta::core::JSON { - return handle_standard(evaluator, schema, instance, format, - &instance_tracker); + return handle_standard(evaluator, schema, instance, format, &instance_tracker, + instance_base); } } // namespace sourcemeta::blaze diff --git a/vendor/blaze/src/test/include/sourcemeta/blaze/test.h b/vendor/blaze/src/test/include/sourcemeta/blaze/test.h index aec2fb2e..9c303004 100644 --- a/vendor/blaze/src/test/include/sourcemeta/blaze/test.h +++ b/vendor/blaze/src/test/include/sourcemeta/blaze/test.h @@ -93,8 +93,50 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestOutcome { #endif }; +/// @ingroup test +/// Where a target of a test document lives, as the caller resolved it +/// +/// The document and the frame are borrowed rather than copied, and the +/// exhaustive template of a target is compiled on the first request, so both +/// must outlive the test suite +struct SOURCEMETA_BLAZE_TEST_EXPORT TestTarget { +// See +// https://learn.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-1-c4251?view=msvc-170 +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + /// The document that holds the schema under test, which may be that schema + /// or a wrapper that holds it, such as an OpenAPI description + const sourcemeta::core::JSON &document; + /// The frame of the schemas that the document holds. For a wrapper, this is + /// what framing the places it keeps its schemas in reports, such as what + /// sourcemeta::core::OpenAPIFrame::schemas returns + const sourcemeta::core::SchemaFrame &frame; + /// The URI of the schema under test, which the frame must locate. This is + /// typically the target itself, as what a test document names a schema by + /// and what framing the document it lives in keys that schema by are the + /// same URI. An empty string names whatever the frame was rooted at + sourcemeta::core::JSON::String entrypoint; +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif +}; + +/// @ingroup test +/// How to reach what a target names, invoked once per target of a test +/// document in the order that the document lists them +// TODO(C++23): Use std::move_only_function when available in libc++ +using TestTargetResolver = + std::function; + /// @ingroup test /// Represents a test suite containing multiple test cases +/// +/// A test suite does not resolve or frame anything. The caller hands over the +/// document that holds each target, a frame of the schemas in it, and where in +/// it the schema under test sits. That is what lets a target name a schema +/// inside a document that is not a schema itself, such as an OpenAPI +/// description, without this module knowing anything about wrappers struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { /// The result of running a test suite @@ -114,7 +156,8 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { #if defined(_MSC_VER) #pragma warning(disable : 4251) #endif - /// The target schema URIs or file paths + /// The target schema URIs or file paths, resolved against the location of + /// the test document std::vector targets; /// The list of test cases in the suite std::vector tests; @@ -127,9 +170,9 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { /// A callback invoked for each test case during execution // TODO(C++23): Use std::move_only_function when available in libc++ using Callback = std::function; + const sourcemeta::core::JSON::String &target, std::size_t target_index, + std::size_t index, std::size_t total, const TestCase &test_case, + const TestOutcome &outcome, TestTimestamp start, TestTimestamp end)>; /// The compiled schema template for fast validation of the given target [[nodiscard]] auto fast(std::size_t target_index) const -> const Template &; @@ -155,15 +198,9 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { /// #include /// /// const auto input{R"JSON({ - /// "target": "https://json-schema.org/draft/2020-12/schema", + /// "target": "https://example.com/string", /// "tests": [ - /// { - /// "data": { - /// "$schema": "https://json-schema.org/draft/2020-12/schema" - /// }, - /// "valid": true, - /// "description": "valid schema" - /// } + /// { "data": "foo", "valid": true, "description": "a string" } /// ] /// })JSON"}; /// @@ -171,15 +208,28 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { /// sourcemeta::core::JSON document{nullptr}; /// sourcemeta::core::parse_json(input, document, std::ref(tracker)); /// + /// const auto schema{sourcemeta::core::parse_json(R"JSON({ + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "$id": "https://example.com/string", + /// "type": "string" + /// })JSON")}; + /// + /// const sourcemeta::core::SchemaFrame frame{ + /// sourcemeta::core::SchemaFrame::Mode::References, schema, + /// sourcemeta::core::schema_walker, sourcemeta::core::schema_resolver}; + /// /// auto suite{sourcemeta::blaze::TestSuite::parse( /// document, tracker, std::filesystem::current_path(), - /// sourcemeta::core::schema_resolver, - /// sourcemeta::core::schema_walker, + /// [&schema, &frame](const sourcemeta::core::JSON::String &target) + /// -> sourcemeta::blaze::TestTarget { + /// return {.document = schema, .frame = frame, .entrypoint = target}; + /// }, + /// sourcemeta::core::schema_resolver, sourcemeta::core::schema_walker, /// sourcemeta::blaze::default_schema_compiler)}; /// /// const auto result{suite.run( /// [](const sourcemeta::core::JSON::String &target, - /// std::size_t index, std::size_t total, + /// std::size_t, std::size_t index, std::size_t total, /// const sourcemeta::blaze::TestCase &test_case, /// const sourcemeta::blaze::TestOutcome &outcome, /// sourcemeta::blaze::TestTimestamp start, @@ -192,9 +242,27 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { /// /// std::cout << result.passed << "/" << result.total << " passed\n"; /// ``` + /// + /// A target that names a schema inside a document that is not a schema + /// itself is reached the same way, by handing over that document and a frame + /// of the schemas it holds. Nothing else about it differs, as framing a + /// document under the base it was read from keys a schema within it by that + /// base and the place it sits in, which is what the target spells: + /// + /// ```cpp + /// // A frame can neither be copied nor moved, and this one has to outlive + /// // the suite, so the caller keeps it somewhere of its own and hands over + /// // a reference to it + /// const auto &frame{*frames.emplace_back( + /// std::make_unique( + /// description, walker, resolver, base))}; + /// return {.document = description, .frame = frame.schemas(), + /// .entrypoint = target}; + /// ``` auto run(const Callback &callback) -> Result; - /// Parse a test suite from a JSON object. For example: + /// Parse a test suite from a JSON object, resolving and compiling every + /// target that it names. For example: /// /// ```cpp /// #include @@ -209,10 +277,10 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { /// #include /// /// const auto input{R"JSON({ - /// "target": "https://json-schema.org/draft/2020-12/schema", + /// "target": "https://example.com/string", /// "tests": [ - /// { "data": {}, "valid": true }, - /// { "data": [], "valid": false, "description": "Not an object" } + /// { "data": "foo", "valid": true }, + /// { "data": [], "valid": false, "description": "Not a string" } /// ] /// })JSON"}; /// @@ -220,40 +288,69 @@ struct SOURCEMETA_BLAZE_TEST_EXPORT TestSuite { /// sourcemeta::core::JSON document{nullptr}; /// sourcemeta::core::parse_json(input, document, std::ref(tracker)); /// + /// const auto schema{sourcemeta::core::parse_json(R"JSON({ + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "$id": "https://example.com/string", + /// "type": "string" + /// })JSON")}; + /// + /// const sourcemeta::core::SchemaFrame frame{ + /// sourcemeta::core::SchemaFrame::Mode::References, schema, + /// sourcemeta::core::schema_walker, sourcemeta::core::schema_resolver}; + /// /// const auto suite{sourcemeta::blaze::TestSuite::parse( /// document, tracker, std::filesystem::current_path(), - /// sourcemeta::core::schema_resolver, - /// sourcemeta::core::schema_walker, + /// [&schema, &frame](const sourcemeta::core::JSON::String &target) + /// -> sourcemeta::blaze::TestTarget { + /// return {.document = schema, .frame = frame, .entrypoint = target}; + /// }, + /// sourcemeta::core::schema_resolver, sourcemeta::core::schema_walker, /// sourcemeta::blaze::default_schema_compiler)}; /// /// assert(suite.targets.size() == 1); - /// assert(suite.targets.front() == - /// "https://json-schema.org/draft/2020-12/schema"); + /// assert(suite.targets.front() == "https://example.com/string"); /// assert(suite.tests.size() == 2); /// ``` - static auto - parse(const sourcemeta::core::JSON &document, - const sourcemeta::core::PointerPositionTracker &tracker, - const std::filesystem::path &base_path, - const sourcemeta::core::SchemaResolver &schema_resolver, - const sourcemeta::core::SchemaWalker &walker, const Compiler &compiler, - std::string_view default_dialect = "", std::string_view default_id = "", - const std::optional &tweaks = std::nullopt) -> TestSuite; + /// + /// The frame that a target resolves to must contain reference information + /// for the document it frames, and that document must be bundled, which are + /// the same pre-conditions that the overload of sourcemeta::blaze::compile + /// taking a frame states. The frame must also locate the entry point, so a + /// document is framed under the base that the target was resolved against + /// rather than under no base at all + static auto parse(const sourcemeta::core::JSON &document, + const sourcemeta::core::PointerPositionTracker &tracker, + const std::filesystem::path &base_path, + const TestTargetResolver &target_resolver, + const sourcemeta::core::SchemaResolver &schema_resolver, + const sourcemeta::core::SchemaWalker &walker, + const Compiler &compiler, + const std::optional &tweaks = std::nullopt) + -> TestSuite; private: - [[nodiscard]] auto compile_target(std::size_t target_index, Mode mode) const - -> Template; - +// See +// https://learn.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-1-c4251?view=msvc-170 #if defined(_MSC_VER) #pragma warning(disable : 4251) #endif + // What a target resolved to, held the way a container can hold it, as an + // entry with a reference member cannot be assigned + struct ResolvedTarget { + const sourcemeta::core::JSON *document; + const sourcemeta::core::SchemaFrame *frame; + sourcemeta::core::JSON::String entrypoint; + }; + + [[nodiscard]] auto compile_target(std::size_t target_index, Mode mode) const + -> Template; + + std::vector resolved_targets_; std::vector