diff --git a/DEPENDENCIES b/DEPENDENCIES index f4ef60d76..4b454cae1 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -1,5 +1,5 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core f503133930d61dabbd59cded7e432a0addb11625 +core https://github.com/sourcemeta/core 64f15b119964b3d5a1219eb797953d4698e69fa2 jsonbinpack https://github.com/sourcemeta/jsonbinpack ad91fc9e1129b5ee71983fdb709e3340632f6fbd blaze https://github.com/sourcemeta/blaze e680badab795e9cdf30752f7dcfe77be708e05d7 ctrf https://github.com/ctrf-io/ctrf 93ea827d951390190171d37443bff169cf47c808 diff --git a/src/command_fmt.cc b/src/command_fmt.cc index 9c7d325dc..d2cea46a4 100644 --- a/src/command_fmt.cc +++ b/src/command_fmt.cc @@ -155,12 +155,7 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) const auto display_path{stdin_path()}; std::string raw_stdin; - const auto parsed{read_from_stdin(&raw_stdin)}; - if (parsed.yaml) { - throw YAMLInputError{"This command does not support YAML input files yet", - display_path}; - } - + const auto parsed{read_from_stdin(&raw_stdin, InputFormatting::Preserve)}; const auto &document{parsed.document}; const auto dialect{default_dialect(options, configuration)}; const auto is_test_document = @@ -179,14 +174,15 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) if (options.contains("check")) { std::ostringstream expected; if (options.contains("keep-ordering")) { - sourcemeta::core::prettify(document, expected, indentation); + sourcemeta::jsonschema::write_schema(document, expected, indentation, + parsed.roundtrip); } else { auto copy = document; sourcemeta::jsonschema::format_schema(copy, custom_resolver, effective_dialect); - sourcemeta::core::prettify(copy, expected, indentation); + sourcemeta::jsonschema::write_schema(copy, expected, indentation, + parsed.roundtrip); } - expected << "\n"; if (raw_stdin == expected.str()) { const auto status = @@ -200,14 +196,15 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) } } else { if (options.contains("keep-ordering")) { - sourcemeta::core::prettify(document, std::cout, indentation); + sourcemeta::jsonschema::write_schema(document, std::cout, indentation, + parsed.roundtrip); } else { auto copy = document; sourcemeta::jsonschema::format_schema(copy, custom_resolver, effective_dialect); - sourcemeta::core::prettify(copy, std::cout, indentation); + sourcemeta::jsonschema::write_schema(copy, std::cout, indentation, + parsed.roundtrip); } - std::cout << "\n"; } } catch (const sourcemeta::core::SchemaKeywordError &error) { throw sourcemeta::core::FileError( @@ -244,11 +241,6 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) }; const auto handle_file_entry = [&](const InputJSON &entry) { - if (entry.yaml) { - throw YAMLInputError{"This command does not support YAML input files yet", - entry.resolution_base}; - } - if (entry.multidocument) { throw MultiDocumentInputError{ "This command does not support input with multiple documents", @@ -283,14 +275,15 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) std::ostringstream expected; if (options.contains("keep-ordering")) { - sourcemeta::core::prettify(entry.second, expected, indentation); + sourcemeta::jsonschema::write_schema(entry.second, expected, + indentation, entry.roundtrip); } else { auto copy = entry.second; sourcemeta::jsonschema::format_schema(copy, custom_resolver, effective_dialect); - sourcemeta::core::prettify(copy, expected, indentation); + sourcemeta::jsonschema::write_schema(copy, expected, indentation, + entry.roundtrip); } - expected << "\n"; const auto current{ sourcemeta::core::read_file_to_string(entry.resolution_base)}; @@ -356,7 +349,8 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) // When no positional arguments are given, default to for_each_json(options) // which scans the current directory. if (options.positional().empty()) { - for (const auto &entry : for_each_json(options)) { + for (const auto &entry : + for_each_json(options, InputFormatting::Preserve)) { handle_file_entry(entry); } } else { @@ -365,7 +359,8 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) if (arg == "-") { handle_stdin(); } else { - for (const auto &entry : for_each_json({arg}, options)) { + for (const auto &entry : + for_each_json({arg}, options, InputFormatting::Preserve)) { handle_file_entry(entry); } } diff --git a/src/command_lint.cc b/src/command_lint.cc index 8efc47dde..72450f850 100644 --- a/src/command_lint.cc +++ b/src/command_lint.cc @@ -472,7 +472,7 @@ auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options) const auto indentation{parse_indentation(options)}; if (options.contains("fix")) { - auto entries = for_each_json(options); + auto entries = for_each_json(options, InputFormatting::Preserve); retag_openapi_stdin(entries); for (const auto &entry : entries) { @@ -485,12 +485,6 @@ auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options) const auto &custom_resolver{ resolver(options, options.contains("http"), dialect, configuration)}; LOG_VERBOSE(options) << "Linting: " << entry.first << "\n"; - if (entry.yaml) { - throw YAMLInputError{ - "The --fix option is not supported for YAML input files", - entry.resolution_base}; - } - if (entry.multidocument) { throw MultiDocumentInputError{ "The --fix option is not supported for input with multiple " @@ -697,8 +691,8 @@ auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options) } } - sourcemeta::core::prettify(copy, std::cout, indentation); - std::cout << "\n"; + sourcemeta::jsonschema::write_schema(copy, std::cout, indentation, + entry.roundtrip); } else if (format_output) { if (!keep_ordering) { sourcemeta::jsonschema::format_schema(copy, custom_resolver, @@ -706,8 +700,8 @@ auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options) } std::ostringstream expected; - sourcemeta::core::prettify(copy, expected, indentation); - expected << "\n"; + sourcemeta::jsonschema::write_schema(copy, expected, indentation, + entry.roundtrip); const auto current{ sourcemeta::core::read_file_to_string(entry.resolution_base)}; @@ -719,9 +713,9 @@ auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options) } else if (copy != entry.second) { sourcemeta::core::atomic_write_file( entry.resolution_base, - [©, &indentation](std::ostream &stream) -> void { - sourcemeta::core::prettify(copy, stream, indentation); - stream << "\n"; + [©, &indentation, &entry](std::ostream &stream) -> void { + sourcemeta::jsonschema::write_schema(copy, stream, indentation, + entry.roundtrip); }); } } else { diff --git a/src/error.h b/src/error.h index a2ba9468e..2c87ec336 100644 --- a/src/error.h +++ b/src/error.h @@ -157,19 +157,6 @@ class NotSchemaError : public std::runtime_error { std::filesystem::path path_; }; -class YAMLInputError : public std::runtime_error { -public: - YAMLInputError(const std::string &message, std::filesystem::path path) - : std::runtime_error{message}, path_{std::move(path)} {} - - [[nodiscard]] auto path() const noexcept -> const std::filesystem::path & { - return this->path_; - } - -private: - std::filesystem::path path_; -}; - class MultiDocumentInputError : public std::runtime_error { public: MultiDocumentInputError(const std::string &message, @@ -1172,10 +1159,6 @@ inline auto try_catch(const sourcemeta::core::Options &options, const auto is_json{options.contains("json")}; print_exception(is_json, error); return EXIT_SCHEMA_INPUT_ERROR; - } catch (const YAMLInputError &error) { - const auto is_json{options.contains("json")}; - print_exception(is_json, error); - return EXIT_NOT_SUPPORTED; } catch (const MultiDocumentInputError &error) { const auto is_json{options.contains("json")}; print_exception(is_json, error); diff --git a/src/input.h b/src/input.h index f0d763aac..c28a66440 100644 --- a/src/input.h +++ b/src/input.h @@ -34,6 +34,10 @@ namespace sourcemeta::jsonschema { enum class InputRequirement : std::uint8_t { Optional, NonEmpty }; +// Whether to collect the metadata that YAML input needs to be written back +// out with the formatting it was read with +enum class InputFormatting : std::uint8_t { Discard, Preserve }; + struct InputJSON { std::string first; std::filesystem::path resolution_base; @@ -44,6 +48,7 @@ struct InputJSON { bool yaml{false}; bool from_stdin{false}; std::shared_ptr> property_storage; + std::optional roundtrip{std::nullopt}; auto operator<(const InputJSON &other) const noexcept -> bool { return this->first < other.first; } @@ -146,12 +151,15 @@ struct ParsedJSON { sourcemeta::core::PointerPositionTracker positions; std::shared_ptr> property_storage; bool yaml{false}; + bool multidocument{false}; + std::optional roundtrip{std::nullopt}; }; struct MultiDocEntry { sourcemeta::core::JSON document; sourcemeta::core::PointerPositionTracker positions; std::shared_ptr> property_storage; + std::optional roundtrip{std::nullopt}; }; inline auto @@ -193,43 +201,96 @@ inline auto at_end_of_stream(const std::string &input, std::istream &stream) .empty(); } -// Parse every YAML document the stream holds, keeping line numbers running +// Parse every YAML document the input holds, keeping line numbers running // across the documents that follow the first -inline auto read_yaml_documents(std::istream &stream) +inline auto read_yaml_documents(const std::string &input, + const InputFormatting formatting) -> std::vector { std::vector documents; + std::istringstream stream{input}; std::uint64_t line_offset{0}; - std::uint64_t max_line{0}; + std::size_t consumed{0}; while (stream.peek() != std::char_traits::eof()) { sourcemeta::core::PointerPositionTracker positions; auto property_storage = std::make_shared>(); const std::uint64_t current_offset{line_offset}; - max_line = 0; - auto callback = [&positions, &property_storage, current_offset, &max_line]( + auto callback = [&positions, &property_storage, current_offset]( const sourcemeta::core::JSON::ParsePhase phase, const sourcemeta::core::JSON::Type type, const std::uint64_t line, const std::uint64_t column, const sourcemeta::core::JSON::ParseContext context, const std::size_t index, const sourcemeta::core::JSON::String &property) { - max_line = std::max(max_line, line); property_storage->emplace_back(property); positions(phase, type, line + current_offset, column, context, index, property_storage->back()); }; sourcemeta::core::JSON document{sourcemeta::core::JSON{nullptr}}; - sourcemeta::core::parse_yaml(stream, document, callback); + std::optional roundtrip{std::nullopt}; + + if (formatting == InputFormatting::Preserve) { + sourcemeta::core::parse_yaml(stream, roundtrip.emplace(), document, + callback); + } else { + sourcemeta::core::parse_yaml(stream, document, callback); + } + documents.push_back({.document = std::move(document), .positions = std::move(positions), - .property_storage = std::move(property_storage)}); - line_offset += max_line > 0 ? max_line - 1 : 0; + .property_storage = std::move(property_storage), + .roundtrip = std::move(roundtrip)}); + + // A document that carries no values reports no lines of its own, so the + // lines it occupies are counted from the input it consumed + const auto position{stream.tellg()}; + const auto offset{position < 0 ? input.size() + : static_cast(position)}; + line_offset += static_cast( + std::count(input.cbegin() + static_cast(consumed), + input.cbegin() + static_cast(offset), '\n')); + consumed = offset; } return documents; } -inline auto read_file(const std::filesystem::path &path) -> ParsedJSON { +struct ParsedYAML { + std::optional roundtrip{std::nullopt}; + bool multidocument{false}; +}; + +inline auto +read_yaml_file(const std::filesystem::path &path, + sourcemeta::core::JSON &output, + const sourcemeta::core::JSON::ParseCallback &callback, + const InputFormatting formatting) -> ParsedYAML { + if (formatting == InputFormatting::Discard) { + sourcemeta::core::read_yaml(path, output, callback); + return {}; + } + + // A file that holds more than one document cannot be written back from a + // single set of round-trip metadata, but it is still valid YAML, so the + // caller gets to report that rather than a parse error + const auto input{sourcemeta::core::read_file_to_string(path)}; + std::istringstream stream{input}; + sourcemeta::core::YAMLRoundTrip roundtrip; + + try { + sourcemeta::core::parse_yaml(stream, roundtrip, output, callback); + } catch (const sourcemeta::core::YAMLParseError &error) { + throw sourcemeta::core::YAMLFileParseError{path, error}; + } + + return {.roundtrip = std::move(roundtrip), + .multidocument = !at_end_of_stream(input, stream)}; +} + +inline auto +read_file(const std::filesystem::path &path, + const InputFormatting formatting = InputFormatting::Discard) + -> ParsedJSON { const auto extension{path.extension()}; sourcemeta::core::PointerPositionTracker positions; auto property_storage = std::make_shared>(); @@ -237,11 +298,13 @@ inline auto read_file(const std::filesystem::path &path) -> ParsedJSON { if (extension == ".yaml" || extension == ".yml") { auto callback = make_position_callback(positions, property_storage); - sourcemeta::core::read_yaml(path, document, callback); + auto parsed{read_yaml_file(path, document, callback, formatting)}; return {.document = std::move(document), .positions = std::move(positions), .property_storage = std::move(property_storage), - .yaml = true}; + .yaml = true, + .multidocument = parsed.multidocument, + .roundtrip = std::move(parsed.roundtrip)}; } if (extension == ".json") { @@ -263,24 +326,26 @@ inline auto read_file(const std::filesystem::path &path) -> ParsedJSON { auto yaml_property_storage = std::make_shared>(); auto callback = make_position_callback(yaml_positions, yaml_property_storage); - sourcemeta::core::read_yaml(path, document, callback); + auto parsed{read_yaml_file(path, document, callback, formatting)}; return {.document = std::move(document), .positions = std::move(yaml_positions), .property_storage = std::move(yaml_property_storage), - .yaml = true}; + .yaml = true, + .multidocument = parsed.multidocument, + .roundtrip = std::move(parsed.roundtrip)}; } } // Read standard input as the one or more YAML documents it holds, reporting // the JSON error that sent us here if it cannot be read that way either inline auto read_stdin_yaml(const std::string &input, - const sourcemeta::core::JSONParseError &json_error) + const sourcemeta::core::JSONParseError &json_error, + const InputFormatting formatting) -> std::vector { - std::istringstream stream{input}; std::vector documents; try { - documents = read_yaml_documents(stream); + documents = read_yaml_documents(input, formatting); } catch (...) { throw sourcemeta::core::JSONFileParseError(stdin_path(), json_error); } @@ -295,7 +360,8 @@ inline auto read_stdin_yaml(const std::string &input, result.push_back({.document = std::move(entry.document), .positions = std::move(entry.positions), .property_storage = std::move(entry.property_storage), - .yaml = true}); + .yaml = true, + .roundtrip = std::move(entry.roundtrip)}); } return result; @@ -304,7 +370,8 @@ inline auto read_stdin_yaml(const std::string &input, // Read every document the given standard input buffer holds, trying JSON // first, then JSONL for input that carries more than one JSON document, and // finally YAML -inline auto read_stdin_documents(const std::string &input) +inline auto read_stdin_documents(const std::string &input, + const InputFormatting formatting) -> std::vector { std::istringstream json_stream{input}; sourcemeta::core::PointerPositionTracker positions; @@ -315,7 +382,7 @@ inline auto read_stdin_documents(const std::string &input) try { sourcemeta::core::parse_json(json_stream, document, callback); } catch (const sourcemeta::core::JSONParseError &json_error) { - return read_stdin_yaml(input, json_error); + return read_stdin_yaml(input, json_error, formatting); } if (at_end_of_stream(input, json_stream)) { @@ -340,20 +407,23 @@ inline auto read_stdin_documents(const std::string &input) .property_storage = {}}); } } catch (const sourcemeta::core::JSONParseError &error) { - return read_stdin_yaml(input, error); + return read_stdin_yaml(input, error, formatting); } return result; } // Read the single document standard input is expected to hold -inline auto read_from_stdin(std::string *raw_input = nullptr) -> ParsedJSON { +inline auto +read_from_stdin(std::string *raw_input = nullptr, + const InputFormatting formatting = InputFormatting::Discard) + -> ParsedJSON { const auto input{sourcemeta::core::read_stdin()}; if (raw_input != nullptr) { *raw_input = input; } - auto documents{read_stdin_documents(input)}; + auto documents{read_stdin_documents(input, formatting)}; assert(!documents.empty()); if (documents.size() > 1) { throw MultiDocumentInputError{"This command does not support reading " @@ -367,7 +437,8 @@ inline auto read_from_stdin(std::string *raw_input = nullptr) -> ParsedJSON { inline auto handle_input_file(const std::filesystem::path &canonical, std::vector &result, - const sourcemeta::core::Options &options) -> void { + const sourcemeta::core::Options &options, + const InputFormatting formatting) -> void { const auto canonical_string{canonical.generic_string()}; if (canonical_string.ends_with(".jsonl.gz")) { LOG_VERBOSE(options) << "Interpreting input as GZIP-compressed JSONL: " @@ -430,10 +501,10 @@ handle_input_file(const std::filesystem::path &canonical, if (std::filesystem::is_empty(canonical)) { return; } - auto stream{sourcemeta::core::read_file(canonical)}; std::vector documents; try { - documents = read_yaml_documents(stream); + documents = read_yaml_documents( + sourcemeta::core::read_file_to_string(canonical), formatting); } catch (const sourcemeta::core::YAMLParseError &error) { throw sourcemeta::core::YAMLFileParseError{canonical, error}; } @@ -443,15 +514,15 @@ handle_input_file(const std::filesystem::path &canonical, << canonical_string << "\n"; std::size_t index{0}; for (auto &entry : documents) { - result.push_back( - {.first = canonical_string, - .resolution_base = canonical, - .second = std::move(entry.document), - .positions = std::move(entry.positions), - .index = index, - .multidocument = true, - .yaml = true, - .property_storage = std::move(entry.property_storage)}); + result.push_back({.first = canonical_string, + .resolution_base = canonical, + .second = std::move(entry.document), + .positions = std::move(entry.positions), + .index = index, + .multidocument = true, + .yaml = true, + .property_storage = std::move(entry.property_storage), + .roundtrip = std::move(entry.roundtrip)}); index += 1; } } else if (documents.size() == 1) { @@ -461,7 +532,8 @@ handle_input_file(const std::filesystem::path &canonical, .second = std::move(documents.front().document), .positions = std::move(documents.front().positions), .yaml = true, - .property_storage = std::move(documents.front().property_storage)}); + .property_storage = std::move(documents.front().property_storage), + .roundtrip = std::move(documents.front().roundtrip)}); } } else { if (std::filesystem::is_regular_file(canonical) && @@ -469,13 +541,15 @@ handle_input_file(const std::filesystem::path &canonical, return; } // TODO: Print a verbose message for what is getting parsed - auto parsed{read_file(canonical)}; + auto parsed{read_file(canonical, formatting)}; result.push_back({.first = canonical_string, .resolution_base = canonical, .second = std::move(parsed.document), .positions = std::move(parsed.positions), + .multidocument = parsed.multidocument, .yaml = parsed.yaml, - .property_storage = std::move(parsed.property_storage)}); + .property_storage = std::move(parsed.property_storage), + .roundtrip = std::move(parsed.roundtrip)}); } } @@ -484,9 +558,11 @@ handle_json_entry(const std::filesystem::path &entry_path, const std::set &blacklist, const std::set &extensions, std::vector &result, - const sourcemeta::core::Options &options) -> void { + const sourcemeta::core::Options &options, + const InputFormatting formatting) -> void { if (entry_path == "-") { - auto documents{read_stdin_documents(sourcemeta::core::read_stdin())}; + auto documents{ + read_stdin_documents(sourcemeta::core::read_stdin(), formatting)}; assert(!documents.empty()); const auto path{stdin_path()}; const auto multidocument{documents.size() > 1}; @@ -509,7 +585,8 @@ handle_json_entry(const std::filesystem::path &entry_path, .multidocument = multidocument, .yaml = document.yaml, .from_stdin = true, - .property_storage = std::move(document.property_storage)}); + .property_storage = std::move(document.property_storage), + .roundtrip = std::move(document.roundtrip)}); index += 1; } @@ -536,7 +613,7 @@ handle_json_entry(const std::filesystem::path &entry_path, continue; } - handle_input_file(canonical, result, options); + handle_input_file(canonical, result, options, formatting); } } } else { @@ -546,7 +623,7 @@ handle_json_entry(const std::filesystem::path &entry_path, return sourcemeta::core::is_under_path(canonical, prefix); })) { - handle_input_file(canonical, result, options); + handle_input_file(canonical, result, options, formatting); } } } @@ -563,7 +640,8 @@ check_no_duplicate_stdin(const std::vector &arguments) inline auto for_each_json(const std::vector &arguments, const sourcemeta::core::Options &options, - const InputRequirement requirement) + const InputRequirement requirement, + const InputFormatting formatting) -> std::vector { check_no_duplicate_stdin(arguments); @@ -599,7 +677,8 @@ inline auto for_each_json(const std::vector &arguments, const auto extensions{parse_extensions(options, configuration)}; - handle_json_entry(scan_path, blacklist, extensions, result, options); + handle_json_entry(scan_path, blacklist, extensions, result, options, + formatting); if (result.empty() && requirement == InputRequirement::NonEmpty) { throw sourcemeta::core::FileError(scan_path); } @@ -643,7 +722,8 @@ inline auto for_each_json(const std::vector &arguments, load_configuration(options, entry_configuration_path)}; const auto &extensions{parse_extensions(options, entry_configuration)}; const auto before{result.size()}; - handle_json_entry(entry, blacklist, extensions, result, options); + handle_json_entry(entry, blacklist, extensions, result, options, + formatting); std::sort( result.begin() + static_cast(before), result.end(), [](const auto &left, const auto &right) { return left < right; }); @@ -659,21 +739,35 @@ inline auto for_each_json(const std::vector &arguments, } inline auto for_each_json(const std::vector &arguments, - const sourcemeta::core::Options &options) + const sourcemeta::core::Options &options, + const InputRequirement requirement) -> std::vector { - return for_each_json(arguments, options, InputRequirement::Optional); + return for_each_json(arguments, options, requirement, + InputFormatting::Discard); +} + +inline auto +for_each_json(const std::vector &arguments, + const sourcemeta::core::Options &options, + const InputFormatting formatting = InputFormatting::Discard) + -> std::vector { + return for_each_json(arguments, options, InputRequirement::Optional, + formatting); } inline auto for_each_json(const sourcemeta::core::Options &options, const InputRequirement requirement) -> std::vector { - return for_each_json(options.positional(), options, requirement); + return for_each_json(options.positional(), options, requirement, + InputFormatting::Discard); } -inline auto for_each_json(const sourcemeta::core::Options &options) +inline auto +for_each_json(const sourcemeta::core::Options &options, + const InputFormatting formatting = InputFormatting::Discard) -> std::vector { return for_each_json(options.positional(), options, - InputRequirement::Optional); + InputRequirement::Optional, formatting); } } // namespace sourcemeta::jsonschema diff --git a/src/main.cc b/src/main.cc index c85fad0f1..ccef365a5 100644 --- a/src/main.cc +++ b/src/main.cc @@ -98,7 +98,6 @@ constexpr std::string_view USAGE_COMMANDS{R"EOF( version / --version / -v [--indentation/-n ] Format the input schemas in-place or check they are formatted. - This command does not support YAML schemas yet. lint [schemas-or-directories...] [--fix/-f] [--format/-m] [--keep-ordering/-k] [--extension/-e ] @@ -108,7 +107,6 @@ constexpr std::string_view USAGE_COMMANDS{R"EOF( version / --version / -v [--format-assertion/-F] Lint the input schemas and potentially fix the reported issues. - The --fix/-f option is not supported when passing YAML schemas. Use --format/-m with --fix to format the output even when there are no linting issues. Use --keep-ordering/-k with --format to preserve key order. diff --git a/src/utils.h b/src/utils.h index 1f6eb7616..5255ae7f6 100644 --- a/src/utils.h +++ b/src/utils.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -160,6 +161,21 @@ inline auto format_schema(sourcemeta::core::JSON &schema, sourcemeta::core::schema_format(schema, frame); } +inline auto +write_schema(const sourcemeta::core::JSON &schema, std::ostream &stream, + const std::size_t indentation, + const std::optional &roundtrip) + -> void { + if (roundtrip.has_value()) { + sourcemeta::core::stringify_yaml(schema, stream, roundtrip.value(), + indentation); + return; + } + + sourcemeta::core::prettify(schema, stream, indentation); + stream << "\n"; +} + inline auto parse_jobs(const sourcemeta::core::Options &options) -> std::size_t { if (options.contains("jobs")) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4ada7a677..c7011ffb0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -61,7 +61,26 @@ add_jsonschema_test(format/pass_cwd) add_jsonschema_test(format/pass_cwd_config_no_path) add_jsonschema_test(format/pass_multi_extension) add_jsonschema_test(format/pass_yaml) +add_jsonschema_test(format/pass_yml) +add_jsonschema_test(format/pass_yaml_idempotent) +add_jsonschema_test(format/pass_yaml_comments) +add_jsonschema_test(format/pass_yaml_scalar_styles) +add_jsonschema_test(format/pass_yaml_document_markers) +add_jsonschema_test(format/pass_yaml_flow_root) +add_jsonschema_test(format/pass_yaml_anchors) +add_jsonschema_test(format/pass_yaml_anchor_without_alias) +add_jsonschema_test(format/pass_yaml_anchors_keep_ordering) +add_jsonschema_test(format/pass_yaml_keep_ordering) +add_jsonschema_test(format/pass_yaml_indentation) +add_jsonschema_test(format/pass_yaml_intact_mtime) +add_jsonschema_test(format/pass_yaml_stdin) +add_jsonschema_test(format/pass_yaml_directory) +add_jsonschema_test(format/fail_yaml_multidocument) +add_jsonschema_test(format/fail_yaml_multidocument_json) +add_jsonschema_test(format/fail_yaml_multidocument_stdin) add_jsonschema_test(format/pass_check_yaml) +add_jsonschema_test(format/fail_check_yaml) +add_jsonschema_test(format/fail_check_yaml_json) add_jsonschema_test(format/fail_check_single) add_jsonschema_test(format/fail_check_many) add_jsonschema_test(format/pass_check_single) @@ -95,11 +114,12 @@ add_jsonschema_test(format/fail_stdin_non_json_whitespace) add_jsonschema_test(format/fail_check_many_json) add_jsonschema_test(format/fail_check_single_json_indentation) add_jsonschema_test(format/pass_without_extension_json) -add_jsonschema_test(format/fail_without_extension_yaml) +add_jsonschema_test(format/pass_without_extension_yaml) +add_jsonschema_test(format/fail_without_extension_yaml_multidocument) add_jsonschema_test(format/pass_extension_empty_json) -add_jsonschema_test(format/fail_extension_empty_yaml) +add_jsonschema_test(format/pass_extension_empty_yaml) add_jsonschema_test(format/pass_custom_extension_json) -add_jsonschema_test(format/fail_custom_extension_yaml) +add_jsonschema_test(format/pass_custom_extension_yaml) add_jsonschema_test(format/fail_invalid_id_type) add_jsonschema_test(format/fail_invalid_schema_uri) add_jsonschema_test(format/fail_anchor_collision) @@ -114,6 +134,7 @@ add_jsonschema_test(format/fail_stdin_invalid_json) add_jsonschema_test(format/pass_stdin_keep_ordering) add_jsonschema_test_unix(format/fail_stdin_missing_newline) add_jsonschema_test_unix(format/pass_stdin_crlf) +add_jsonschema_test_unix(format/pass_yaml_crlf) add_jsonschema_test(format/pass_test_document) add_jsonschema_test(format/pass_check_test_document) add_jsonschema_test(format/fail_check_test_document) @@ -292,6 +313,8 @@ add_jsonschema_test(validate/fail_yaml_multi_one) add_jsonschema_test(validate/fail_yaml_multi_one_verbose) add_jsonschema_test(validate/fail_yaml_multi_one_json) add_jsonschema_test(validate/fail_yaml_multi_blank_lines) +add_jsonschema_test(validate/fail_yaml_multi_empty_document) +add_jsonschema_test(validate/fail_yaml_multi_empty_document_json) add_jsonschema_test(validate/pass_json_ref_yaml) add_jsonschema_test_unix(validate/pass_process_substitution) add_jsonschema_test(validate/pass_2020_12_fast_with_template) @@ -844,6 +867,7 @@ add_jsonschema_test(lint/fail_lint_openapi_stdin) add_jsonschema_test(lint/pass_lint_openapi_no_schemas) add_jsonschema_test(lint/fail_lint_openapi_top_level_rule) add_jsonschema_test(lint/pass_lint_openapi_fix) +add_jsonschema_test(lint/pass_lint_openapi_fix_yaml) add_jsonschema_test(lint/pass_lint_openapi_fix_paths) add_jsonschema_test(lint/fail_lint_openapi_fix_format) add_jsonschema_test(lint/fail_lint) @@ -863,8 +887,13 @@ add_jsonschema_test(lint/fail_lint_default_dialect) add_jsonschema_test(lint/fail_lint_default_dialect_config) add_jsonschema_test(lint/fail_lint_default_dialect_config_relative) add_jsonschema_test(lint/fail_lint_yaml) -add_jsonschema_test(lint/fail_lint_fix_yaml) -add_jsonschema_test(lint/fail_lint_fix_yml) +add_jsonschema_test(lint/pass_lint_fix_yaml) +add_jsonschema_test(lint/pass_lint_fix_yml) +add_jsonschema_test(lint/pass_lint_fix_yaml_comments) +add_jsonschema_test(lint/pass_lint_fix_yaml_indentation) +add_jsonschema_test(lint/pass_lint_fix_yaml_no_changes) +add_jsonschema_test(lint/pass_stdin_fix_yaml) +add_jsonschema_test(lint/fail_lint_fix_yaml_multidocument) add_jsonschema_test(lint/fail_lint_json) add_jsonschema_test(lint/fail_lint_json_color_always) add_jsonschema_test(lint/fail_lint_non_schema) @@ -919,9 +948,9 @@ add_jsonschema_test(lint/pass_extension_empty_json) add_jsonschema_test(lint/pass_extension_empty_yaml) add_jsonschema_test(lint/pass_custom_extension_json) add_jsonschema_test(lint/pass_custom_extension_yaml) -add_jsonschema_test(lint/fail_lint_fix_custom_extension_yaml) -add_jsonschema_test(lint/fail_lint_fix_without_extension_yaml) -add_jsonschema_test(lint/fail_lint_fix_extension_empty_yaml) +add_jsonschema_test(lint/pass_lint_fix_custom_extension_yaml) +add_jsonschema_test(lint/pass_lint_fix_without_extension_yaml) +add_jsonschema_test(lint/pass_lint_fix_extension_empty_yaml) add_jsonschema_test(lint/pass_lint_fix_keep_ordering) add_jsonschema_test(lint/pass_lint_exclude_single_string) add_jsonschema_test(lint/pass_lint_exclude_array) @@ -954,7 +983,8 @@ add_jsonschema_test(lint/pass_lint_fix_format_directory) add_jsonschema_test(lint/fail_lint_fix_format_unfixable) add_jsonschema_test(lint/fail_lint_format_without_fix) add_jsonschema_test(lint/fail_lint_keep_ordering_without_format) -add_jsonschema_test(lint/fail_lint_format_yaml) +add_jsonschema_test(lint/pass_lint_fix_format_yaml) +add_jsonschema_test(lint/pass_lint_fix_format_yaml_keep_ordering) add_jsonschema_test(lint/pass_lint_rule_no_violation) add_jsonschema_test(lint/pass_lint_rule_multiple) add_jsonschema_test(lint/pass_lint_rule_only_pass) diff --git a/test/format/fail_check_yaml.clitest b/test/format/fail_check_yaml.clitest new file mode 100644 index 000000000..14a8e5fa6 --- /dev/null +++ b/test/format/fail_check_yaml.clitest @@ -0,0 +1,39 @@ +WRITE schema.yaml UNTIL EOF +# Needs formatting +$schema: http://json-schema.org/draft-04/schema# +type: string +title: Test +description: Test schema +EOF + +// Validation failure +RUN fmt schema.yaml --check STDIN /dev/null IN . INTO result.txt EXPECTING 2 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> fail: [CWD]/schema.yaml +2> --- current +2> +++ expected +2> @@ -1,5 +1,5 @@ +2> # Needs formatting +2> $schema: http://json-schema.org/draft-04/schema# +2> -type: string +2> title: Test +2> description: Test schema +2> +type: string +2> +2> Run the `fmt` command without `--check/-c` to fix the formatting +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +# Needs formatting +$schema: http://json-schema.org/draft-04/schema# +type: string +title: Test +description: Test schema +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/fail_check_yaml_json.clitest b/test/format/fail_check_yaml_json.clitest new file mode 100644 index 000000000..dfe1d6c29 --- /dev/null +++ b/test/format/fail_check_yaml_json.clitest @@ -0,0 +1,46 @@ +WRITE schema.yaml UNTIL EOF +# Needs formatting +$schema: http://json-schema.org/draft-04/schema# +type: string +title: Test +description: Test schema +EOF + +// Validation failure +RUN fmt schema.yaml --check --json STDIN /dev/null IN . INTO result.txt EXPECTING 2 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +1> { +1> "valid": false, +1> "errors": [ +1> { +1> "path": "[CWD]/schema.yaml", +1> "diff": [ +1> { +1> "type": "equal", +1> "lines": [ +1> "# Needs formatting", +1> "$schema: http://json-schema.org/draft-04/schema#" +1> ] +1> }, +1> { +1> "type": "delete", +1> "lines": [ "type: string" ] +1> }, +1> { +1> "type": "equal", +1> "lines": [ "title: Test", "description: Test schema" ] +1> }, +1> { +1> "type": "insert", +1> "lines": [ "type: string" ] +1> } +1> ] +1> } +1> ] +1> } +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/test/format/fail_without_extension_yaml.clitest b/test/format/fail_without_extension_yaml.clitest deleted file mode 100644 index ef90e7215..000000000 --- a/test/format/fail_without_extension_yaml.clitest +++ /dev/null @@ -1,21 +0,0 @@ -WRITE schema UNTIL EOF -$schema: https://json-schema.org/draft/2020-12/schema -description: Test schema -additionalProperties: false -title: Hello World -properties: - foo: {} - bar: {} -EOF - -// Not supported -RUN fmt schema STDIN /dev/null IN . INTO result.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result.txt - -WRITE expected.txt UNTIL EOF -2> error: This command does not support YAML input files yet -2> at file path [CWD]/schema -EOF - -COMPARE result.txt AGAINST expected.txt diff --git a/test/format/fail_without_extension_yaml_multidocument.clitest b/test/format/fail_without_extension_yaml_multidocument.clitest new file mode 100644 index 000000000..c2aeca118 --- /dev/null +++ b/test/format/fail_without_extension_yaml_multidocument.clitest @@ -0,0 +1,43 @@ +WRITE schema UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +type: string +--- +$schema: https://json-schema.org/draft/2020-12/schema +type: integer +EOF + +// Not supported +RUN fmt schema STDIN /dev/null IN . INTO result.txt EXPECTING 3 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> error: This command does not support input with multiple documents +2> at file path [CWD]/schema +EOF + +COMPARE result.txt AGAINST expected.txt + +// Not supported +RUN fmt schema --json STDIN /dev/null IN . INTO result_json.txt EXPECTING 3 + +REPLACE $CWD WITH '[CWD]' IN result_json.txt + +WRITE expected_json.txt UNTIL EOF +1> { +1> "error": "This command does not support input with multiple documents", +1> "filePath": "[CWD]/schema" +1> } +EOF + +COMPARE result_json.txt AGAINST expected_json.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +type: string +--- +$schema: https://json-schema.org/draft/2020-12/schema +type: integer +EOF + +COMPARE schema AGAINST expected_file_0.txt diff --git a/test/format/fail_yaml_multidocument.clitest b/test/format/fail_yaml_multidocument.clitest new file mode 100644 index 000000000..956b49bb1 --- /dev/null +++ b/test/format/fail_yaml_multidocument.clitest @@ -0,0 +1,29 @@ +WRITE schema.yaml UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +type: string +--- +$schema: https://json-schema.org/draft/2020-12/schema +type: integer +EOF + +// Not supported +RUN fmt schema.yaml STDIN /dev/null IN . INTO result.txt EXPECTING 3 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> error: This command does not support input with multiple documents +2> at file path [CWD]/schema.yaml +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +type: string +--- +$schema: https://json-schema.org/draft/2020-12/schema +type: integer +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/fail_yaml_multidocument_json.clitest b/test/format/fail_yaml_multidocument_json.clitest new file mode 100644 index 000000000..6922f470f --- /dev/null +++ b/test/format/fail_yaml_multidocument_json.clitest @@ -0,0 +1,21 @@ +WRITE schema.yaml UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +type: string +--- +$schema: https://json-schema.org/draft/2020-12/schema +type: integer +EOF + +// Not supported +RUN fmt schema.yaml --json STDIN /dev/null IN . INTO result.txt EXPECTING 3 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +1> { +1> "error": "This command does not support input with multiple documents", +1> "filePath": "[CWD]/schema.yaml" +1> } +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/test/format/fail_yaml_multidocument_stdin.clitest b/test/format/fail_yaml_multidocument_stdin.clitest new file mode 100644 index 000000000..c6855a17c --- /dev/null +++ b/test/format/fail_yaml_multidocument_stdin.clitest @@ -0,0 +1,29 @@ +WRITE stdin_0 UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +type: string +--- +$schema: https://json-schema.org/draft/2020-12/schema +type: integer +EOF + +// Not supported +RUN fmt - STDIN stdin_0 IN . INTO result.txt EXPECTING 3 + +WRITE expected.txt UNTIL EOF +2> error: This command does not support reading multiple documents from standard input +2> at file path tag:sourcemeta.com,2026:jsonschema/stdin +EOF + +COMPARE result.txt AGAINST expected.txt + +// Not supported +RUN fmt - --json STDIN stdin_0 IN . INTO result_json.txt EXPECTING 3 + +WRITE expected_json.txt UNTIL EOF +1> { +1> "error": "This command does not support reading multiple documents from standard input", +1> "filePath": "tag:sourcemeta.com,2026:jsonschema/stdin" +1> } +EOF + +COMPARE result_json.txt AGAINST expected_json.txt diff --git a/test/format/pass_check_yaml.clitest b/test/format/pass_check_yaml.clitest index 0fb6b84f2..35897d020 100644 --- a/test/format/pass_check_yaml.clitest +++ b/test/format/pass_check_yaml.clitest @@ -1,16 +1,22 @@ WRITE schema.yaml UNTIL EOF -type: 1 $schema: http://json-schema.org/draft-04/schema# +title: Test +description: Test schema +type: string EOF -// Not supported -RUN fmt schema.yaml --check STDIN /dev/null IN . INTO result.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result.txt +RUN fmt schema.yaml --check STDIN /dev/null IN . INTO result.txt EXPECTING 0 WRITE expected.txt UNTIL EOF -2> error: This command does not support YAML input files yet -2> at file path [CWD]/schema.yaml EOF COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: http://json-schema.org/draft-04/schema# +title: Test +description: Test schema +type: string +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/fail_custom_extension_yaml.clitest b/test/format/pass_custom_extension_yaml.clitest similarity index 58% rename from test/format/fail_custom_extension_yaml.clitest rename to test/format/pass_custom_extension_yaml.clitest index 4a0d8cc67..2e19cd0c1 100644 --- a/test/format/fail_custom_extension_yaml.clitest +++ b/test/format/pass_custom_extension_yaml.clitest @@ -4,14 +4,17 @@ $schema: https://json-schema.org/draft/2020-12/schema type: string EOF -// Not supported -RUN fmt schema.custom STDIN /dev/null IN . INTO result.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result.txt +RUN fmt schema.custom STDIN /dev/null IN . INTO result.txt EXPECTING 0 WRITE expected.txt UNTIL EOF -2> error: This command does not support YAML input files yet -2> at file path [CWD]/schema.custom EOF COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://example.com +type: string +EOF + +COMPARE schema.custom AGAINST expected_file_0.txt diff --git a/test/format/fail_extension_empty_yaml.clitest b/test/format/pass_extension_empty_yaml.clitest similarity index 59% rename from test/format/fail_extension_empty_yaml.clitest rename to test/format/pass_extension_empty_yaml.clitest index 12ac3f244..51aa61502 100644 --- a/test/format/fail_extension_empty_yaml.clitest +++ b/test/format/pass_extension_empty_yaml.clitest @@ -5,15 +5,19 @@ additionalProperties: false title: Hello World EOF -// Not supported -RUN fmt schemas --extension '' STDIN /dev/null IN . INTO result.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result.txt +RUN fmt schemas --extension '' STDIN /dev/null IN . INTO result.txt EXPECTING 0 WRITE expected.txt UNTIL EOF 2> warning: Matching files with no extension -2> error: This command does not support YAML input files yet -2> at file path [CWD]/schemas/schema1 EOF COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +title: Hello World +description: Test schema +additionalProperties: false +EOF + +COMPARE schemas/schema1 AGAINST expected_file_0.txt diff --git a/test/format/pass_without_extension_yaml.clitest b/test/format/pass_without_extension_yaml.clitest new file mode 100644 index 000000000..069029446 --- /dev/null +++ b/test/format/pass_without_extension_yaml.clitest @@ -0,0 +1,28 @@ +WRITE schema UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +description: Test schema +additionalProperties: false +title: Hello World +properties: + foo: {} + bar: {} +EOF + +RUN fmt schema STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +title: Hello World +description: Test schema +properties: + foo: {} + bar: {} +additionalProperties: false +EOF + +COMPARE schema AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml.clitest b/test/format/pass_yaml.clitest index 4803ffd9a..68cba7d1c 100644 --- a/test/format/pass_yaml.clitest +++ b/test/format/pass_yaml.clitest @@ -1,23 +1,30 @@ WRITE schema.yaml UNTIL EOF +# A schema for greetings +$schema: https://json-schema.org/draft/2020-12/schema additionalProperties: false title: Hello World +description: Test schema +properties: + foo: {} + bar: {} EOF -// Not supported -RUN fmt schema.yaml STDIN /dev/null IN . INTO result.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result.txt +RUN fmt schema.yaml STDIN /dev/null IN . INTO result.txt EXPECTING 0 WRITE expected.txt UNTIL EOF -2> error: This command does not support YAML input files yet -2> at file path [CWD]/schema.yaml EOF COMPARE result.txt AGAINST expected.txt WRITE expected_file_0.txt UNTIL EOF -additionalProperties: false +# A schema for greetings +$schema: https://json-schema.org/draft/2020-12/schema title: Hello World +description: Test schema +properties: + foo: {} + bar: {} +additionalProperties: false EOF COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_anchor_without_alias.clitest b/test/format/pass_yaml_anchor_without_alias.clitest new file mode 100644 index 000000000..92e82d823 --- /dev/null +++ b/test/format/pass_yaml_anchor_without_alias.clitest @@ -0,0 +1,22 @@ +WRITE schema.yaml UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +title: Unaliased +description: &shared A shared description +type: object +EOF + +RUN fmt schema.yaml STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +title: Unaliased +description: &shared A shared description +type: object +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_anchors.clitest b/test/format/pass_yaml_anchors.clitest new file mode 100644 index 000000000..b9ea8dd45 --- /dev/null +++ b/test/format/pass_yaml_anchors.clitest @@ -0,0 +1,32 @@ +WRITE schema.yaml UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +$defs: + name: &name + type: string +title: Anchors +properties: + first: *name + last: *name +EOF + +RUN fmt schema.yaml STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +title: Anchors +properties: + first: + type: string + last: + type: string +$defs: + name: + type: string +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_anchors_keep_ordering.clitest b/test/format/pass_yaml_anchors_keep_ordering.clitest new file mode 100644 index 000000000..38f1dde13 --- /dev/null +++ b/test/format/pass_yaml_anchors_keep_ordering.clitest @@ -0,0 +1,28 @@ +WRITE schema.yaml UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +$defs: + name: &name + type: string +properties: + first: *name + last: *name +EOF + +RUN fmt schema.yaml --keep-ordering STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +$defs: + name: &name + type: string +properties: + first: *name + last: *name +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_comments.clitest b/test/format/pass_yaml_comments.clitest new file mode 100644 index 000000000..4da4ee65b --- /dev/null +++ b/test/format/pass_yaml_comments.clitest @@ -0,0 +1,36 @@ +WRITE schema.yaml UNTIL EOF +# Leading comment +$schema: https://json-schema.org/draft/2020-12/schema +type: object # inline on type +properties: + # About foo + foo: + type: string # inline on foo type +title: Comments +description: | + A literal + block +EOF + +RUN fmt schema.yaml STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +# Leading comment +$schema: https://json-schema.org/draft/2020-12/schema +title: Comments +description: | + A literal + block +type: object # inline on type +properties: + # About foo + foo: + type: string # inline on foo type +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_crlf.sh b/test/format/pass_yaml_crlf.sh new file mode 100755 index 000000000..c1430b6dc --- /dev/null +++ b/test/format/pass_yaml_crlf.sh @@ -0,0 +1,31 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/lf.yaml" +$schema: https://json-schema.org/draft/2020-12/schema +type: string +title: Hello World +EOF +awk '{ printf "%s\r\n", $0 }' "$TMP/lf.yaml" > "$TMP/schema.yaml" + +"$1" fmt "$TMP/schema.yaml" > "$TMP/output.txt" + +cat << 'EOF' > "$TMP/expected_output.txt" +EOF + +diff "$TMP/output.txt" "$TMP/expected_output.txt" + +cat << 'EOF' > "$TMP/expected_lf.yaml" +$schema: https://json-schema.org/draft/2020-12/schema +title: Hello World +type: string +EOF +awk '{ printf "%s\r\n", $0 }' "$TMP/expected_lf.yaml" > "$TMP/expected.yaml" + +diff "$TMP/schema.yaml" "$TMP/expected.yaml" diff --git a/test/format/pass_yaml_directory.clitest b/test/format/pass_yaml_directory.clitest new file mode 100644 index 000000000..15a2aff31 --- /dev/null +++ b/test/format/pass_yaml_directory.clitest @@ -0,0 +1,40 @@ +WRITE schemas/one.yaml UNTIL EOF +# The YAML one +$schema: https://json-schema.org/draft/2020-12/schema +type: string +title: One +EOF + +WRITE schemas/two.json UNTIL EOF +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "title": "Two" +} +EOF + +RUN fmt schemas STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +# The YAML one +$schema: https://json-schema.org/draft/2020-12/schema +title: One +type: string +EOF + +COMPARE schemas/one.yaml AGAINST expected_file_0.txt + +WRITE expected_file_1.txt UNTIL EOF +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Two", + "type": "string" +} +EOF + +COMPARE schemas/two.json AGAINST expected_file_1.txt diff --git a/test/format/pass_yaml_document_markers.clitest b/test/format/pass_yaml_document_markers.clitest new file mode 100644 index 000000000..d753070a7 --- /dev/null +++ b/test/format/pass_yaml_document_markers.clitest @@ -0,0 +1,26 @@ +WRITE schema.yaml UNTIL EOF +%YAML 1.2 +--- +$schema: https://json-schema.org/draft/2020-12/schema +type: object +title: Markers +... +EOF + +RUN fmt schema.yaml STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +%YAML 1.2 +--- +$schema: https://json-schema.org/draft/2020-12/schema +title: Markers +type: object +... +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_flow_root.clitest b/test/format/pass_yaml_flow_root.clitest new file mode 100644 index 000000000..127294bbf --- /dev/null +++ b/test/format/pass_yaml_flow_root.clitest @@ -0,0 +1,16 @@ +WRITE schema.yaml UNTIL EOF +{ $schema: "https://json-schema.org/draft/2020-12/schema", type: string, title: Flow } +EOF + +RUN fmt schema.yaml STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ $schema: "https://json-schema.org/draft/2020-12/schema", title: Flow, type: string } +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_idempotent.clitest b/test/format/pass_yaml_idempotent.clitest new file mode 100644 index 000000000..550a49b32 --- /dev/null +++ b/test/format/pass_yaml_idempotent.clitest @@ -0,0 +1,35 @@ +WRITE schema.yaml UNTIL EOF +# A schema for greetings +$schema: https://json-schema.org/draft/2020-12/schema +additionalProperties: false +title: Hello World +properties: + foo: {} +EOF + +RUN fmt schema.yaml STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_0.txt UNTIL EOF +EOF + +COMPARE result_0.txt AGAINST expected_0.txt + +WRITE expected_file_0.txt UNTIL EOF +# A schema for greetings +$schema: https://json-schema.org/draft/2020-12/schema +title: Hello World +properties: + foo: {} +additionalProperties: false +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt + +RUN fmt schema.yaml STDIN /dev/null IN . INTO result_1.txt EXPECTING 0 + +WRITE expected_1.txt UNTIL EOF +EOF + +COMPARE result_1.txt AGAINST expected_1.txt + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_indentation.clitest b/test/format/pass_yaml_indentation.clitest new file mode 100644 index 000000000..0bde5773a --- /dev/null +++ b/test/format/pass_yaml_indentation.clitest @@ -0,0 +1,26 @@ +WRITE schema.yaml UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +title: Hello World +type: object +properties: + foo: + type: string +EOF + +RUN fmt schema.yaml --indentation 4 STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +title: Hello World +type: object +properties: + foo: + type: string +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_intact_mtime.clitest b/test/format/pass_yaml_intact_mtime.clitest new file mode 100644 index 000000000..0d860b60b --- /dev/null +++ b/test/format/pass_yaml_intact_mtime.clitest @@ -0,0 +1,23 @@ +WRITE schema.yaml UNTIL EOF +$schema: http://json-schema.org/draft-04/schema# +title: Test +description: Test schema +type: string +EOF + +COPY schema.yaml TO original.yaml + +STAT MTIME schema.yaml INTO mtime_before.txt + +RUN fmt schema.yaml STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_0.txt UNTIL EOF +EOF + +COMPARE result_0.txt AGAINST expected_0.txt + +STAT MTIME schema.yaml INTO mtime_after.txt + +COMPARE mtime_after.txt AGAINST mtime_before.txt + +COMPARE schema.yaml AGAINST original.yaml diff --git a/test/format/pass_yaml_keep_ordering.clitest b/test/format/pass_yaml_keep_ordering.clitest new file mode 100644 index 000000000..e59871857 --- /dev/null +++ b/test/format/pass_yaml_keep_ordering.clitest @@ -0,0 +1,26 @@ +WRITE schema.yaml UNTIL EOF +# A schema for greetings +$schema: https://json-schema.org/draft/2020-12/schema +additionalProperties: false +title: Hello World +properties: + foo: {} +EOF + +RUN fmt schema.yaml --keep-ordering STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +# A schema for greetings +$schema: https://json-schema.org/draft/2020-12/schema +additionalProperties: false +title: Hello World +properties: + foo: {} +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_scalar_styles.clitest b/test/format/pass_yaml_scalar_styles.clitest new file mode 100644 index 000000000..ab76dbba1 --- /dev/null +++ b/test/format/pass_yaml_scalar_styles.clitest @@ -0,0 +1,34 @@ +WRITE schema.yaml UNTIL EOF +$schema: http://json-schema.org/draft-04/schema# +title: 'Single quoted' +description: >- + A folded + description +type: string +examples: + - "one" + - two +enum: [ "one", two ] +EOF + +RUN fmt schema.yaml STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: http://json-schema.org/draft-04/schema# +title: 'Single quoted' +description: >- + A folded + description +examples: + - "one" + - two +type: string +enum: [ "one", two ] +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/format/pass_yaml_stdin.clitest b/test/format/pass_yaml_stdin.clitest new file mode 100644 index 000000000..d33b57be2 --- /dev/null +++ b/test/format/pass_yaml_stdin.clitest @@ -0,0 +1,17 @@ +WRITE stdin_0 UNTIL EOF +# A schema for greetings +$schema: https://json-schema.org/draft/2020-12/schema +additionalProperties: false +title: Hello World +EOF + +RUN fmt - STDIN stdin_0 IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +1> # A schema for greetings +1> $schema: https://json-schema.org/draft/2020-12/schema +1> title: Hello World +1> additionalProperties: false +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/test/format/pass_yml.clitest b/test/format/pass_yml.clitest new file mode 100644 index 000000000..6d164af95 --- /dev/null +++ b/test/format/pass_yml.clitest @@ -0,0 +1,20 @@ +WRITE schema.yml UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +type: object +title: Hello World +EOF + +RUN fmt schema.yml STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +title: Hello World +type: object +EOF + +COMPARE schema.yml AGAINST expected_file_0.txt diff --git a/test/help_command.clitest b/test/help_command.clitest index 6039256d7..f0f9fb248 100644 --- a/test/help_command.clitest +++ b/test/help_command.clitest @@ -108,7 +108,6 @@ WRITE expected.txt UNTIL EOF 1> [--indentation/-n ] 1> 1> Format the input schemas in-place or check they are formatted. -1> This command does not support YAML schemas yet. 1> 1> lint [schemas-or-directories...] [--fix/-f] [--format/-m] 1> [--keep-ordering/-k] [--extension/-e ] @@ -118,7 +117,6 @@ WRITE expected.txt UNTIL EOF 1> [--format-assertion/-F] 1> 1> Lint the input schemas and potentially fix the reported issues. -1> The --fix/-f option is not supported when passing YAML schemas. 1> Use --format/-m with --fix to format the output even when there 1> are no linting issues. 1> Use --keep-ordering/-k with --format to preserve key order. diff --git a/test/help_option_long.clitest b/test/help_option_long.clitest index 388612a93..151185bd9 100644 --- a/test/help_option_long.clitest +++ b/test/help_option_long.clitest @@ -99,7 +99,6 @@ WRITE expected.txt UNTIL EOF 1> [--indentation/-n ] 1> 1> Format the input schemas in-place or check they are formatted. -1> This command does not support YAML schemas yet. 1> 1> [ANSI_BOLD_CYAN]lint[ANSI_RESET] [schemas-or-directories...] [--fix/-f] [--format/-m] 1> [--keep-ordering/-k] [--extension/-e ] @@ -109,7 +108,6 @@ WRITE expected.txt UNTIL EOF 1> [--format-assertion/-F] 1> 1> Lint the input schemas and potentially fix the reported issues. -1> The --fix/-f option is not supported when passing YAML schemas. 1> Use --format/-m with --fix to format the output even when there 1> are no linting issues. 1> Use --keep-ordering/-k with --format to preserve key order. diff --git a/test/help_option_short.clitest b/test/help_option_short.clitest index 32d6f9b87..659623428 100644 --- a/test/help_option_short.clitest +++ b/test/help_option_short.clitest @@ -96,7 +96,6 @@ WRITE expected.txt UNTIL EOF 1> [--indentation/-n ] 1> 1> Format the input schemas in-place or check they are formatted. -1> This command does not support YAML schemas yet. 1> 1> lint [schemas-or-directories...] [--fix/-f] [--format/-m] 1> [--keep-ordering/-k] [--extension/-e ] @@ -106,7 +105,6 @@ WRITE expected.txt UNTIL EOF 1> [--format-assertion/-F] 1> 1> Lint the input schemas and potentially fix the reported issues. -1> The --fix/-f option is not supported when passing YAML schemas. 1> Use --format/-m with --fix to format the output even when there 1> are no linting issues. 1> Use --keep-ordering/-k with --format to preserve key order. diff --git a/test/lint/fail_lint_fix_custom_extension_yaml.clitest b/test/lint/fail_lint_fix_custom_extension_yaml.clitest deleted file mode 100644 index bb8a8f758..000000000 --- a/test/lint/fail_lint_fix_custom_extension_yaml.clitest +++ /dev/null @@ -1,17 +0,0 @@ -WRITE schema.custom UNTIL EOF -$schema: http://json-schema.org/draft-06/schema# -type: string -enum: [ foo ] -EOF - -// Not supported -RUN lint schema.custom --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result_0.txt - -WRITE expected_0.txt UNTIL EOF -2> error: The --fix option is not supported for YAML input files -2> at file path [CWD]/schema.custom -EOF - -COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/fail_lint_fix_extension_empty_yaml.clitest b/test/lint/fail_lint_fix_extension_empty_yaml.clitest deleted file mode 100644 index 894406da3..000000000 --- a/test/lint/fail_lint_fix_extension_empty_yaml.clitest +++ /dev/null @@ -1,20 +0,0 @@ -MAKE DIRECTORY schemas - -WRITE schemas/schema1 UNTIL EOF -$schema: http://json-schema.org/draft-06/schema# -type: string -enum: [ foo ] -EOF - -// Not supported -RUN lint schemas --extension '' --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result_0.txt - -WRITE expected_0.txt UNTIL EOF -2> warning: Matching files with no extension -2> error: The --fix option is not supported for YAML input files -2> at file path [CWD]/schemas/schema1 -EOF - -COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/fail_lint_fix_without_extension_yaml.clitest b/test/lint/fail_lint_fix_without_extension_yaml.clitest deleted file mode 100644 index 3d6ff8f1c..000000000 --- a/test/lint/fail_lint_fix_without_extension_yaml.clitest +++ /dev/null @@ -1,17 +0,0 @@ -WRITE schema UNTIL EOF -$schema: http://json-schema.org/draft-06/schema# -type: string -enum: [ foo ] -EOF - -// Not supported -RUN lint schema --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result_0.txt - -WRITE expected_0.txt UNTIL EOF -2> error: The --fix option is not supported for YAML input files -2> at file path [CWD]/schema -EOF - -COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/fail_lint_fix_yaml.clitest b/test/lint/fail_lint_fix_yaml_multidocument.clitest similarity index 52% rename from test/lint/fail_lint_fix_yaml.clitest rename to test/lint/fail_lint_fix_yaml_multidocument.clitest index cc1309730..93cce0ce7 100644 --- a/test/lint/fail_lint_fix_yaml.clitest +++ b/test/lint/fail_lint_fix_yaml_multidocument.clitest @@ -1,7 +1,9 @@ WRITE schema.yaml UNTIL EOF -$schema: http://json-schema.org/draft-06/schema# +$schema: https://json-schema.org/draft/2020-12/schema type: string -enum: [ foo ] +--- +$schema: https://json-schema.org/draft/2020-12/schema +type: integer EOF // Not supported @@ -10,7 +12,7 @@ RUN lint schema.yaml --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 3 REPLACE $CWD WITH '[CWD]' IN result_0.txt WRITE expected_0.txt UNTIL EOF -2> error: The --fix option is not supported for YAML input files +2> error: The --fix option is not supported for input with multiple documents 2> at file path [CWD]/schema.yaml EOF @@ -23,9 +25,19 @@ REPLACE $CWD WITH '[CWD]' IN result_1.txt WRITE expected_1.txt UNTIL EOF 1> { -1> "error": "The --fix option is not supported for YAML input files", +1> "error": "The --fix option is not supported for input with multiple documents", 1> "filePath": "[CWD]/schema.yaml" 1> } EOF COMPARE result_1.txt AGAINST expected_1.txt + +WRITE expected_file_0.txt UNTIL EOF +$schema: https://json-schema.org/draft/2020-12/schema +type: string +--- +$schema: https://json-schema.org/draft/2020-12/schema +type: integer +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt diff --git a/test/lint/fail_lint_fix_yml.clitest b/test/lint/fail_lint_fix_yml.clitest deleted file mode 100644 index 3c620acb9..000000000 --- a/test/lint/fail_lint_fix_yml.clitest +++ /dev/null @@ -1,17 +0,0 @@ -WRITE schema.yml UNTIL EOF -$schema: http://json-schema.org/draft-06/schema# -type: string -enum: [ foo ] -EOF - -// Not supported -RUN lint schema.yml --fix STDIN /dev/null IN . INTO result.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result.txt - -WRITE expected.txt UNTIL EOF -2> error: The --fix option is not supported for YAML input files -2> at file path [CWD]/schema.yml -EOF - -COMPARE result.txt AGAINST expected.txt diff --git a/test/lint/fail_lint_format_yaml.clitest b/test/lint/fail_lint_format_yaml.clitest deleted file mode 100644 index d91037896..000000000 --- a/test/lint/fail_lint_format_yaml.clitest +++ /dev/null @@ -1,30 +0,0 @@ -WRITE schema.yaml UNTIL EOF -$schema: http://json-schema.org/draft-06/schema# -type: string -EOF - -// Not supported -RUN lint schema.yaml --fix --format STDIN /dev/null IN . INTO result_0.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result_0.txt - -WRITE expected_0.txt UNTIL EOF -2> error: The --fix option is not supported for YAML input files -2> at file path [CWD]/schema.yaml -EOF - -COMPARE result_0.txt AGAINST expected_0.txt - -// Not supported -RUN lint schema.yaml --fix --format --json STDIN /dev/null IN . INTO result_1.txt EXPECTING 3 - -REPLACE $CWD WITH '[CWD]' IN result_1.txt - -WRITE expected_1.txt UNTIL EOF -1> { -1> "error": "The --fix option is not supported for YAML input files", -1> "filePath": "[CWD]/schema.yaml" -1> } -EOF - -COMPARE result_1.txt AGAINST expected_1.txt diff --git a/test/lint/pass_lint_fix_custom_extension_yaml.clitest b/test/lint/pass_lint_fix_custom_extension_yaml.clitest new file mode 100644 index 000000000..d81c4c780 --- /dev/null +++ b/test/lint/pass_lint_fix_custom_extension_yaml.clitest @@ -0,0 +1,28 @@ +WRITE schema.custom UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint schema.custom --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +const: foo +title: I should not be moved up +EOF + +COMPARE schema.custom AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> . +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/pass_lint_fix_extension_empty_yaml.clitest b/test/lint/pass_lint_fix_extension_empty_yaml.clitest new file mode 100644 index 000000000..e0eeae3c2 --- /dev/null +++ b/test/lint/pass_lint_fix_extension_empty_yaml.clitest @@ -0,0 +1,31 @@ +MAKE DIRECTORY schemas + +WRITE schemas/schema1 UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint schemas --extension '' --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +const: foo +title: I should not be moved up +EOF + +COMPARE schemas/schema1 AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> warning: Matching files with no extension +2> . +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/pass_lint_fix_format_yaml.clitest b/test/lint/pass_lint_fix_format_yaml.clitest new file mode 100644 index 000000000..21944d567 --- /dev/null +++ b/test/lint/pass_lint_fix_format_yaml.clitest @@ -0,0 +1,52 @@ +WRITE schema.yaml UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint schema.yaml --fix --format STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +title: I should not be moved up +description: Test schema +examples: [ foo ] +const: foo +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> . +EOF + +COMPARE result_0.txt AGAINST expected_0.txt + +WRITE schema_json.yaml UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint schema_json.yaml --fix --format --json STDIN /dev/null IN . INTO result_1.txt EXPECTING 0 + +COMPARE schema_json.yaml AGAINST expected_file_0.txt + +WRITE expected_1.txt UNTIL EOF +1> { +1> "valid": true, +1> "health": 100, +1> "errors": [] +1> } +EOF + +COMPARE result_1.txt AGAINST expected_1.txt diff --git a/test/lint/pass_lint_fix_format_yaml_keep_ordering.clitest b/test/lint/pass_lint_fix_format_yaml_keep_ordering.clitest new file mode 100644 index 000000000..e373767a0 --- /dev/null +++ b/test/lint/pass_lint_fix_format_yaml_keep_ordering.clitest @@ -0,0 +1,28 @@ +WRITE schema.yaml UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint schema.yaml --fix --format --keep-ordering STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +const: foo +title: I should not be moved up +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> . +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/pass_lint_fix_without_extension_yaml.clitest b/test/lint/pass_lint_fix_without_extension_yaml.clitest new file mode 100644 index 000000000..7965de732 --- /dev/null +++ b/test/lint/pass_lint_fix_without_extension_yaml.clitest @@ -0,0 +1,28 @@ +WRITE schema UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint schema --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +const: foo +title: I should not be moved up +EOF + +COMPARE schema AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> . +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/pass_lint_fix_yaml.clitest b/test/lint/pass_lint_fix_yaml.clitest new file mode 100644 index 000000000..77cc4c632 --- /dev/null +++ b/test/lint/pass_lint_fix_yaml.clitest @@ -0,0 +1,52 @@ +WRITE schema.yaml UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint schema.yaml --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +const: foo +title: I should not be moved up +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> . +EOF + +COMPARE result_0.txt AGAINST expected_0.txt + +WRITE schema_json.yaml UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint schema_json.yaml --fix --json STDIN /dev/null IN . INTO result_1.txt EXPECTING 0 + +COMPARE schema_json.yaml AGAINST expected_file_0.txt + +WRITE expected_1.txt UNTIL EOF +1> { +1> "valid": true, +1> "health": 100, +1> "errors": [] +1> } +EOF + +COMPARE result_1.txt AGAINST expected_1.txt diff --git a/test/lint/pass_lint_fix_yaml_comments.clitest b/test/lint/pass_lint_fix_yaml_comments.clitest new file mode 100644 index 000000000..51ce35f6b --- /dev/null +++ b/test/lint/pass_lint_fix_yaml_comments.clitest @@ -0,0 +1,34 @@ +WRITE schema.yaml UNTIL EOF +# Top level +$schema: http://json-schema.org/draft-06/schema# +title: Comments +description: Test schema +examples: [ foo ] +properties: + # About foo + foo: + type: string # redundant with const + const: bar +EOF + +RUN lint schema.yaml --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +# Top level +$schema: http://json-schema.org/draft-06/schema# +title: Comments +description: Test schema +examples: [ foo ] +properties: + # About foo + foo: + const: bar +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> . +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/pass_lint_fix_yaml_indentation.clitest b/test/lint/pass_lint_fix_yaml_indentation.clitest new file mode 100644 index 000000000..7d114e85f --- /dev/null +++ b/test/lint/pass_lint_fix_yaml_indentation.clitest @@ -0,0 +1,30 @@ +WRITE schema.yaml UNTIL EOF +$schema: http://json-schema.org/draft-06/schema# +title: Nested +description: Test schema +examples: [ foo ] +properties: + foo: + type: string + enum: [ bar ] +EOF + +RUN lint schema.yaml --fix --indentation 4 STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +$schema: http://json-schema.org/draft-06/schema# +title: Nested +description: Test schema +examples: [ foo ] +properties: + foo: + const: bar +EOF + +COMPARE schema.yaml AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> .. +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/pass_lint_fix_yaml_no_changes.clitest b/test/lint/pass_lint_fix_yaml_no_changes.clitest new file mode 100644 index 000000000..a518e0ce4 --- /dev/null +++ b/test/lint/pass_lint_fix_yaml_no_changes.clitest @@ -0,0 +1,19 @@ +WRITE schema.yaml UNTIL EOF +# Nothing to fix here +$schema: http://json-schema.org/draft-06/schema# +title: Clean +description: Test schema +examples: [ foo ] +const: foo +EOF + +COPY schema.yaml TO original.yaml + +RUN lint schema.yaml --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_0.txt UNTIL EOF +EOF + +COMPARE result_0.txt AGAINST expected_0.txt + +COMPARE schema.yaml AGAINST original.yaml diff --git a/test/lint/pass_lint_fix_yml.clitest b/test/lint/pass_lint_fix_yml.clitest new file mode 100644 index 000000000..94da1f340 --- /dev/null +++ b/test/lint/pass_lint_fix_yml.clitest @@ -0,0 +1,28 @@ +WRITE schema.yml UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint schema.yml --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +const: foo +title: I should not be moved up +EOF + +COMPARE schema.yml AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> . +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/pass_lint_openapi_fix_yaml.clitest b/test/lint/pass_lint_openapi_fix_yaml.clitest new file mode 100644 index 000000000..81f3ecd30 --- /dev/null +++ b/test/lint/pass_lint_openapi_fix_yaml.clitest @@ -0,0 +1,42 @@ +WRITE api.yaml UNTIL EOF +# My API +openapi: 3.1.0 +info: + title: Example + version: 1.0.0 +components: + schemas: + # A name + Name: + title: Name + description: The name of a person + examples: [ foo ] + type: string + enum: [ foo ] +EOF + +RUN lint api.yaml --fix STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_file_0.txt UNTIL EOF +# My API +openapi: 3.1.0 +info: + title: Example + version: 1.0.0 +components: + schemas: + # A name + Name: + title: Name + description: The name of a person + examples: [ foo ] + const: foo +EOF + +COMPARE api.yaml AGAINST expected_file_0.txt + +WRITE expected_0.txt UNTIL EOF +2> .. +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/lint/pass_stdin_fix_yaml.clitest b/test/lint/pass_stdin_fix_yaml.clitest new file mode 100644 index 000000000..c9f8cdd0b --- /dev/null +++ b/test/lint/pass_stdin_fix_yaml.clitest @@ -0,0 +1,23 @@ +WRITE stdin_0 UNTIL EOF +# A constant string +$schema: http://json-schema.org/draft-06/schema# +description: Test schema +examples: [ foo ] +type: string +const: foo +title: I should not be moved up +EOF + +RUN lint --fix - STDIN stdin_0 IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +1> # A constant string +1> $schema: http://json-schema.org/draft-06/schema# +1> description: Test schema +1> examples: [ foo ] +1> const: foo +1> title: I should not be moved up +2> . +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/test/validate/fail_yaml_multi_empty_document.clitest b/test/validate/fail_yaml_multi_empty_document.clitest new file mode 100644 index 000000000..4f0680e46 --- /dev/null +++ b/test/validate/fail_yaml_multi_empty_document.clitest @@ -0,0 +1,41 @@ +WRITE schema.json UNTIL EOF +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Test", + "description": "Test schema", + "properties": { + "foo": { + "type": "string" + } + } +} +EOF + +WRITE instance.yaml UNTIL EOF +--- +--- +foo: 1 +EOF + +// Validation failure +RUN validate schema.json instance.yaml STDIN /dev/null IN . INTO result_0.txt EXPECTING 2 + +WRITE expected_0.txt UNTIL EOF +2> fail: instance.yaml (entry #2) +2> +2> { +2> "foo": 1 +2> } +2> +2> error: Schema validation failure +2> The value was expected to be of type string but it was of type integer +2> at instance location "/foo" (line 3, column 1) +2> at evaluate path "/properties/foo/type" +2> The object value was expected to validate against the single defined property subschema +2> at instance location "" (line 3, column 1) +2> at evaluate path "/properties" +2> +2> 2 validated, 1 passed, 1 failed +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/validate/fail_yaml_multi_empty_document_json.clitest b/test/validate/fail_yaml_multi_empty_document_json.clitest new file mode 100644 index 000000000..4ab4d9579 --- /dev/null +++ b/test/validate/fail_yaml_multi_empty_document_json.clitest @@ -0,0 +1,49 @@ +WRITE schema.json UNTIL EOF +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Test", + "description": "Test schema", + "properties": { + "foo": { + "type": "string" + } + } +} +EOF + +WRITE instance.yaml UNTIL EOF +--- +--- +foo: 1 +EOF + +// Validation failure +RUN validate schema.json instance.yaml --json STDIN /dev/null IN . INTO result_0.txt EXPECTING 2 + +REPLACE $CWD_URI WITH '[CWD_URI]' IN result_0.txt +WRITE expected_0.txt UNTIL EOF +1> { +1> "valid": true +1> } +1> { +1> "valid": false, +1> "errors": [ +1> { +1> "keywordLocation": "/properties/foo/type", +1> "absoluteKeywordLocation": "[CWD_URI]/schema.json#/properties/foo/type", +1> "instanceLocation": "/foo", +1> "instancePosition": [ 3, 1, 3, 6 ], +1> "error": "The value was expected to be of type string but it was of type integer" +1> }, +1> { +1> "keywordLocation": "/properties", +1> "absoluteKeywordLocation": "[CWD_URI]/schema.json#/properties", +1> "instanceLocation": "", +1> "instancePosition": [ 3, 1, 4, 0 ], +1> "error": "The object value was expected to validate against the single defined property subschema" +1> } +1> ] +1> } +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/vendor/core/src/core/jsonschema/bundle.cc b/vendor/core/src/core/jsonschema/bundle.cc index 708bd4d09..65272778b 100644 --- a/vendor/core/src/core/jsonschema/bundle.cc +++ b/vendor/core/src/core/jsonschema/bundle.cc @@ -167,12 +167,11 @@ auto elevate_embedded_resources( if (bundled.contains(identifier_string)) { if (container_exists && root_container->is_object()) { for (const auto &root_entry : root_container->as_object()) { - if (!root_entry.first.starts_with(identifier_string)) { - continue; - } - // Same reasoning as above: rule out what cannot match, and what - // framing would reject, before paying for a frame + // framing would reject, before paying for a frame. What a container + // calls an entry is no guide to what that entry identifies, since a + // caller may hold one under a name of its own choosing, so the + // declared identifier below is what rules an entry in or out if (!root_entry.second.is_object()) { continue; } diff --git a/vendor/core/src/core/openapi/bundle.cc b/vendor/core/src/core/openapi/bundle.cc index 18eac7202..492b95370 100644 --- a/vendor/core/src/core/openapi/bundle.cc +++ b/vendor/core/src/core/openapi/bundle.cc @@ -39,9 +39,9 @@ struct OpenAPIPending { // cannot reach what is named bool mapping{false}; // Whether a Security Requirement Object is what names it. OpenAPI - // Specification 3.2.1, Section 4.30 lets one name a Security Scheme Object - // "by URI", and that URI is the member the scopes sit under rather than a - // value of its own, so making it whole renames a member rather than writing + // Specification 3.2.1, Section 4.30 lets a name "be the URI of a Security + // Scheme Object", and that URI is the member the scopes sit under rather than + // a value of its own, so making it whole renames a member rather than writing // to one. Nothing else this brings in is spelled that way bool requirement{false}; // What the reference resolves against, which for everything but a Schema @@ -61,8 +61,8 @@ auto pending(const sourcemeta::core::OpenAPIWalk &walk) std::vector result; for (const auto &entry : walk.references) { if (walk.locations.contains(entry.second.destination) || - sourcemeta::core::openapi_document_uri(entry.second.destination) == - walk.base) { + sourcemeta::core::openapi_within_document(entry.second.destination, + walk.base)) { continue; } @@ -81,8 +81,8 @@ auto pending(const sourcemeta::core::OpenAPIWalk &walk) // several schemes, which is more than one entry keyed by where it sits for (const auto &entry : walk.security_references) { if (walk.locations.contains(entry.second.destination) || - sourcemeta::core::openapi_document_uri(entry.second.destination) == - walk.base) { + sourcemeta::core::openapi_within_document(entry.second.destination, + walk.base)) { continue; } @@ -124,9 +124,10 @@ auto promote(const sourcemeta::core::Pointer &target) // Where the Path Item Object holding an Operation Object sits. OpenAPI // Specification 3.1.1, Section 4.8.9 puts an Operation Object directly under // the Path Item Object that holds it, and 3.2.1, Section 4.9 adds -// `additionalOperations`, "a map of additional operations keyed by HTTP -// method", which puts a map of its own between the two. So which place holds -// it is what the walk recorded rather than a fixed number of steps up +// `additionalOperations`, "A map of additional operations on this path. The +// map key is the HTTP method with the same capitalization that is to be sent +// in the request", which puts a map of its own between the two. So which place +// holds it is what the walk recorded rather than a fixed number of steps up auto path_item_of(const sourcemeta::core::OpenAPIWalk &remote, const sourcemeta::core::Pointer &operation) -> sourcemeta::core::Pointer { @@ -166,7 +167,7 @@ auto container_of(const sourcemeta::core::Pointer &origin, auto rebase(const sourcemeta::core::JSON::String &destination, const sourcemeta::core::JSON::String &base) -> sourcemeta::core::JSON::String { - if (sourcemeta::core::openapi_document_uri(destination) != base) { + if (!sourcemeta::core::openapi_within_document(destination, base)) { return destination; } @@ -243,6 +244,15 @@ auto absolutize_schemas(sourcemeta::core::JSON &value, continue; } + // Section 4.3.3 resolves a name against the Components Object of the entry + // document wherever the Discriminator Object naming it sits, so moving the + // schema leaves it naming exactly what it named. That holds whether or not + // the name also happens to lead into what moves, so it is settled before + // any route is, or a name would be written out as the route it took + if (sourcemeta::core::openapi_is_component_key(written->to_string())) { + continue; + } + const auto target{frame.traverse(discriminator.destination)}; if (target.has_value() && target.value().get().pointer.starts_with( sourcemeta::core::to_weak_pointer(origin))) { @@ -258,8 +268,8 @@ auto absolutize_schemas(sourcemeta::core::JSON &value, // reads its fragment hands back a view into it const sourcemeta::core::URI destination{discriminator.destination}; const auto fragment{destination.fragment()}; - if (sourcemeta::core::openapi_document_uri(discriminator.destination) != - remote.base || + if (!sourcemeta::core::openapi_within_document(discriminator.destination, + remote.base) || !fragment.has_value() || !fragment.value().starts_with('/')) { continue; } @@ -273,13 +283,6 @@ auto absolutize_schemas(sourcemeta::core::JSON &value, continue; } - // Section 4.3.3 resolves a name against the Components Object of the entry - // document wherever the Discriminator Object naming it sits, so moving the - // schema leaves it naming exactly what it named - if (sourcemeta::core::openapi_is_component_key(written->to_string())) { - continue; - } - if (written->to_string() != discriminator.destination) { sourcemeta::core::set(value, held, sourcemeta::core::JSON{discriminator.destination}); @@ -410,8 +413,8 @@ auto absolutize_schemas(sourcemeta::core::JSON &value, // resolved against an identifier a schema declares for itself counts // from that schema, which moves along whole, and a name is not a // pointer at all - if (sourcemeta::core::openapi_document_uri(reference.destination) != - remote.base || + if (!sourcemeta::core::openapi_within_document(reference.destination, + remote.base) || !reference.fragment.has_value() || !reference.fragment.value().starts_with('/')) { return; @@ -663,7 +666,7 @@ auto bundle_schemas(sourcemeta::core::JSON &document, sourcemeta::core::to_pointer(location)); }; - // Section 4.8.24 scopes what the OpenAPI Object sets to the Schema Objects + // Section 4.8.24.5 scopes what the OpenAPI Object sets to the Schema Objects // "contained within an OAS document", and says of the rest: "For standalone // JSON Schema documents that do not set `$schema` [...] the dialect SHOULD // be assumed to be the OAS dialect". A document a resolver hands back is one @@ -675,8 +678,8 @@ auto bundle_schemas(sourcemeta::core::JSON &document, [&resolver, &standalone, &base](const std::string_view identifier) -> sourcemeta::core::SchemaResolverResult { auto result{resolver(identifier)}; - // Section 4.1.2 has every document of a description hold "either an - // OpenAPI Object or a Schema Object at the root", and what answers + // 3.2.1 Section 4.1.2 has every document of a description hold "either + // an OpenAPI Object or a Schema Object at the root", and what answers // here answers as the second. One that is the first instead would be // read as a schema, which Appendix G leaves undefined and lets this // turn down: "If the same JSON/YAML object is parsed multiple times @@ -785,8 +788,8 @@ auto schema_pending(const sourcemeta::core::JSON &document, } if (frame.traverse(reference.destination).has_value() || - sourcemeta::core::openapi_document_uri(reference.destination) == - walk.base) { + sourcemeta::core::openapi_within_document(reference.destination, + walk.base)) { return; } @@ -810,8 +813,8 @@ auto schema_pending(const sourcemeta::core::JSON &document, for (auto &discriminator : sourcemeta::core::openapi_discriminators( document, frame, walk.base, walker, resolver)) { if (sourcemeta::core::openapi_discriminator_lands(frame, discriminator) || - sourcemeta::core::openapi_document_uri(discriminator.destination) == - walk.base) { + sourcemeta::core::openapi_within_document(discriminator.destination, + walk.base)) { continue; } @@ -929,9 +932,9 @@ auto schema_of(const sourcemeta::core::OpenAPIWalk &remote, // the OpenAPI Object's `$self` field and the Schema Object's `$id`, `$anchor`, // and `$dynamicAnchor` keywords". Neither is a document of its // own, which is why nothing that goes looking for documents finds them, and -// Section 4.1.2 leaves none of them to be given up on while the document that -// declares one has been read: "Implementations MUST NOT treat a reference as -// unresolvable before completely parsing all documents provided to the +// 3.2.1 Section 4.1.2 leaves none of them to be given up on while the document +// that declares one has been read: "Implementations MUST NOT treat a reference +// as unresolvable before completely parsing all documents provided to the // implementation as possible parts of the OAD" auto index_schemas(const sourcemeta::core::JSON &remote_document, const sourcemeta::core::OpenAPIWalk &remote, @@ -969,7 +972,7 @@ auto index_schemas(const sourcemeta::core::JSON &remote_document, // What a document itself answers to is already how it is reached, and taking // that for a place within it would embed a part of it where the whole was // named. A document answers to the URI it was retrieved by as well as to the - // one it names itself with, and Section 4.1.1 keeps the two apart, so a + // one it names itself with, and 3.2.1 Section 4.1.1 keeps the two apart, so a // Schema Object claiming either of them is a Schema Object claiming a whole // document frame.for_each_resource([&identifier, &remote, &result]( @@ -1102,10 +1105,10 @@ auto absolutize(JSON &value, const OpenAPIWalk &remote, const Pointer &origin, const auto renamed{group.second.find(member.first)}; const auto &name{renamed == group.second.cend() ? member.first : renamed->second}; - // Section 4.30 says nothing against two names of one Object leading to - // one scheme, and reading such an Object is no trouble. Writing one back - // out is what cannot keep both, as the single name they come to is a key - // that holds one list of scopes rather than two + // 3.2.1 Section 4.30 says nothing against two names of one Object leading + // to one scheme, and reading such an Object is no trouble. Writing one + // back out is what cannot keep both, as the single name they come to is a + // key that holds one list of scopes rather than two const auto *taken{rebuilt.try_at(name)}; if (taken != nullptr && *taken != member.second) { throw OpenAPIError{remote.base, held.concat(JSON::String{member.first}), @@ -1130,7 +1133,7 @@ auto absolutize(JSON &value, const OpenAPIWalk &remote, const Pointer &origin, const auto relative{(entry.second.pointer).resolve_from(origin)}; // Section 4.8.5 makes a Server Object URL a template rather than a URI - // reference, and Section 4.3 has a relative one name a place "relative to + // reference, and Section 4.8.5 has a relative one name a place "relative to // the location where the document containing the Server Object is being // served", so it resolves as a template against the document it was // written in @@ -1138,12 +1141,13 @@ auto absolutize(JSON &value, const OpenAPIWalk &remote, const Pointer &origin, const auto held{relative.concat(JSON::String{"url"})}; const auto *written{try_get(value, held)}; if (written != nullptr && written->is_string()) { - // Section 4.5.2.1 works this very case through: a document retrieved - // from one place and naming itself another resolves what it says of - // the API against where it was found rather than against the name it - // gave itself - auto address{ - openapi_resolve_server_url(written->to_string(), remote.retrieval)}; + // 3.2.1 Section 4.5.2.1 works this very case through: a document + // retrieved from one place and naming itself another resolves what it + // says of the API against where it was found rather than against the + // name it gave itself + auto address{openapi_resolve_server_url( + written->to_string(), remote.retrieval, + try_get(value, relative.concat(JSON::String{"variables"})))}; if (!address.has_value()) { throw OpenAPIError{ remote.base, entry.second.pointer.concat(JSON::String{"url"}), @@ -1255,7 +1259,7 @@ auto adopt_operations(JSON &document, const OpenAPIWalk &walk, promote(path_item_of(other.second, operation->second.pointer))}; // Keyed by what the document answers to rather than by where it was // found, which is what every other place that fills this map uses. - // Section 4.1.1 has a reference name the former, so keying by the + // 3.2.1 Section 4.1.1 has a reference name the former, so keying by the // latter would embed one Path Item twice over const auto key{openapi_location_uri(other.second.base, origin)}; if (bundled.contains(key)) { @@ -1303,12 +1307,12 @@ auto tag_of(const JSON &document, const OpenAPIWalk &walk, // `name` of a tag that this tag is nested under. The named tag MUST exist in // the API description". A description is every document it spans rather than // the entry one alone, so that tag may be one another document declares, and -// Section 4.1 only ever puts a Tag Object at the root of a document. Bundling -// moves what the Components Object holds and leaves every root where it is, so -// a name that the description satisfied has to travel along to go on being -// satisfied by what bundling produces. Every document that holds one is one -// bundling has read, just as for a Link Object `operationId`, as a name is not -// something there is anywhere to go and fetch +// 3.2.1 Section 4.1 only ever puts a Tag Object at the root of a document. +// Bundling moves what the Components Object holds and leaves every root where +// it is, so a name that the description satisfied has to travel along to go on +// being satisfied by what bundling produces. Every document that holds one is +// one bundling has read, just as for a Link Object `operationId`, as a name is +// not something there is anywhere to go and fetch auto adopt_tags(JSON &document, const OpenAPIWalk &walk, const std::map &documents, const std::map &walks, @@ -1316,9 +1320,9 @@ auto adopt_tags(JSON &document, const OpenAPIWalk &walk, -> bool { bool changed{false}; // What this pass has already brought in. The names the walk holds are the - // ones it read before any of them travelled, and Section 4.1 has "Each tag - // name in the list MUST be unique", so two tags nested under one that only - // another document declares bring it along once between them + // ones it read before any of them travelled, and 3.2.1 Section 4.1 has "Each + // tag name in the list MUST be unique", so two tags nested under one that + // only another document declares bring it along once between them std::set adopted; for (const auto &entry : walk.tag_parents) { if (walk.tag_names.contains(entry.second.second) || @@ -1366,7 +1370,8 @@ auto adopt_tags(JSON &document, const OpenAPIWalk &walk, auto adopt_mappings( JSON &document, const std::map> &deferred, - std::map &adopted, const JSON::String &base, + std::map &adopted, + std::map &identities, const JSON::String &base, const SchemaWalker &walker, const SchemaResolver &schema_resolver, const JSON::StringView dialect, const OpenAPIBundleOptions &options, std::uint64_t &remaining) -> bool { @@ -1411,9 +1416,10 @@ auto adopt_mappings( continue; } - // Section 4.1.2 has a document of a description hold either an OpenAPI - // Object or a Schema Object at its root, and a mapping names the second - // of those. Reading the first as one is what Appendix G leaves undefined + // 3.2.1 Section 4.1.2 has a document of a description hold either an + // OpenAPI Object or a Schema Object at its root, and a mapping names the + // second of those. Reading the first as one is what Appendix G leaves + // undefined if (openapi_is_document(resolved.value())) { throw OpenAPIReferenceError{ base, entry.second.front().origin, identifier, @@ -1441,8 +1447,8 @@ auto adopt_mappings( // what lets a mapping that named a place within it go on naming that place. // // What it declares is a URI reference rather than a URI though, and - // Section 4.1.2.2 resolves one of those: "The most common base URI source - // that is used in the event of a missing or relative `$self` (in the + // 3.2.1 Section 4.1.2.2 resolves one of those: "The most common base URI + // source that is used in the event of a missing or relative `$self` (in the // OpenAPI Object) and (for Schema Object) `$id` is the retrieval URI". So // the URI it was fetched by is what a relative one is read against, which // is what makes the identifier that comes back one the description can use @@ -1475,8 +1481,12 @@ auto adopt_mappings( // reach one schema. Landing it a second time would leave the description // holding one identifier in two places, which is what whoever frames the // result turns down - const auto same{adopted.find(identity)}; - if (same != adopted.cend()) { + // What a schema declares is its own, and what it was fetched by is the + // description's. Reading one against the other would take a mapping whose + // URI happens to spell what another schema calls itself for a second + // mention of that schema, so the two are kept apart + const auto same{identities.find(identity)}; + if (same != identities.cend()) { adopted.emplace(identifier, same->second); continue; } @@ -1496,7 +1506,7 @@ auto adopt_mappings( identifier, options.namer)}; const auto landed{embed(document, "schemas"sv, name, std::move(schema))}; adopted.emplace(identifier, landed); - adopted.emplace(identity, landed); + identities.emplace(identity, landed); fresh.insert(identifier); if (options.callback) { options.callback(identifier, landed); @@ -1536,20 +1546,16 @@ auto bundle_internal(JSON &document, const SchemaWalker &walker, // these names a place rather than a document, so nothing that goes looking // for documents would ever find it std::map> identifiers; - // What each document read so far calls itself, against where it was found. - // OpenAPI Specification 3.2.1, Section 4.1.1 has a document name itself and - // holds every reference to that name: "Implementations MUST support - // identifying the targets of API description URIs using the URI defined by - // this field when it is present", and of the URI it was retrieved by the - // same section says only that an implementation "MAY choose to support - // referencing by other URIs such as the retrieval URI even when `$self` is - // present". So a document answers to the name it gives itself as well as to - // wherever it was found - std::map answers_to; // And of those, the schemas that a Discriminator Object mapping is what // names, which nothing but this brings in, along with the ones it already did std::map> deferred; std::map adopted; + // And where each of them ended up, against the identifier it declares for + // itself rather than the URI it was reached by. JSON Schema 2020-12 Section + // 8.2.1 has the first be "its canonical [RFC6596] URI" and says nothing of + // what is served at the second, so one schema reached by two URIs is told + // from two schemas that happen to spell each other's names + std::map identities; // The names the description gave before bundling moved anything. Section // 4.1.2.3 resolves the names a referenced document uses from the entry // document, and bundling embedding a Security Scheme Object puts a name @@ -1596,8 +1602,8 @@ auto bundle_internal(JSON &document, const SchemaWalker &walker, // into it rather than one to go looking for a document of that name. // // Whatever the description can reach as a document answers ahead of - // that, as Section 4.1.1 has a reference name a document by the URI that - // document answers to, and a Schema Object is free to declare an + // that, as 3.2.1 Section 4.1.1 has a reference name a document by the URI + // that document answers to, and a Schema Object is free to declare an // identifier that collides with one. So this is asked where a fragment // names no place to begin with, and otherwise only once the document has // turned out not to be one @@ -1611,8 +1617,8 @@ auto bundle_internal(JSON &document, const SchemaWalker &walker, : pointed}; // A fragment shaped like anything else names an identifier or an anchor // rather than a place, and which document declares one is only settled - // by framing the documents the description spans. Section 4.1.2 leaves - // none of those to be given up on before that has happened, so a + // by framing the documents the description spans. 3.2.1 Section 4.1.2 + // leaves none of those to be given up on before that has happened, so a // reference of this shape goes on to have its document read rather than // being left here const URI destination{reference.destination}; @@ -1635,10 +1641,8 @@ auto bundle_internal(JSON &document, const SchemaWalker &walker, "the document it points at"}; } - const auto answer{answers_to.find(holder)}; const auto identifier{declared.has_value() ? declared.value().first - : answer != answers_to.cend() ? answer->second - : holder}; + : holder}; if (names_a_schema && !declared.has_value() && unavailable.contains(identifier)) { @@ -1686,18 +1690,26 @@ auto bundle_internal(JSON &document, const SchemaWalker &walker, "that holds an OpenAPI Description"}; } - // Bundling ends in one document, and Section 4.1 has that document - // declare one revision, so everything it holds has to be what that - // revision can express. Section 2.1 leaves no room to assume an older - // one always can: "Occasionally, non-backwards compatible changes may - // be made in `minor` versions of the OAS where impact is believed to - // be low relative to the benefit provided". Neither direction is safe - // to take on faith, as 3.2 both adds fields that 3.1 has no way of - // spelling and holds what 3.1 already spells to rules 3.1 never had, - // so a description that spans revisions is turned down rather than - // merged. What the patch component says is no part of this, as - // Section 2.1 makes a revision the `major`.`minor` pair alone, which - // 3.1.1 says the same of under Section 4.1 + // Nothing in the specification asks for this. It says what a + // description may span and says nothing of the revisions the documents + // it spans declare, so turning one of these down is a choice this + // makes rather than a rule it follows. + // + // What makes the choice is where bundling ends. Section 4.1 has one + // document declare one revision, so everything the result holds has to + // be what that one revision can express, and there is no revision to + // pick that can express both. A 3.2 document holds fields that 3.1 has + // no way of spelling, and 3.2.1 Section 2.1 leaves no room to assume + // the other direction is safe either: "Occasionally, non-backwards + // compatible changes may be made in `minor` versions of the OAS where + // impact is believed to be low relative to the benefit provided". So + // the choice is between turning such a description down and handing + // back a document that says it is one revision while holding what + // another one means. + // + // What the patch component says is no part of this, as 3.2.1 + // Section 2.1 makes a revision the `major`.`minor` pair alone, + // which 3.1.1 says the same of under Section 4.1 const auto revision{openapi_version(candidate)}; if (revision.has_value() && revision.value() != walk.version) { throw OpenAPIReferenceError{ @@ -1714,21 +1726,17 @@ auto bundle_internal(JSON &document, const SchemaWalker &walker, charge(remaining, analysis.locations.size()); const auto &recorded{ walks.emplace(identifier, std::move(analysis)).first->second}; - if (recorded.base != identifier) { - answers_to.emplace(recorded.base, identifier); - } - index_schemas(held, recorded, identifier, walker, schema_resolver, identifiers, remaining); } // A fragment shaped like anything but a pointer names an identifier or // an anchor rather than a place, which only framing the document that - // declares it settles. Section 4.1.2 leaves none of those to be given up - // on before that document has been read, so this is asked again now - // that it has been. A fragment that is a pointer already named a place - // of the document just read, and nothing another document declares for - // itself is to take that place from it + // declares it settles. 3.2.1 Section 4.1.2 leaves none of those to be + // given up on before that document has been read, so this is asked again + // now that it has been. A fragment that is a pointer already named a + // place of the document just read, and nothing another document declares + // for itself is to take that place from it if (names_a_schema && !declared.has_value() && !pointed.has_value()) { declared = declared_target(identifiers, reference.destination); } @@ -1808,7 +1816,7 @@ auto bundle_internal(JSON &document, const SchemaWalker &walker, // What a Security Requirement Object names is the member the scopes sit // under, so what makes it whole is renaming that member to whatever the // scheme is called once it sits here, which carries the scopes across - // untouched. Section 4.30 then reads the result as a component name + // untouched. 3.2.1 Section 4.30 then reads the result as a component name // rather than as a URI: "Property names that are identical to a // component name under the Components Object MUST be treated as a // component name", and the name was taken free of that Object for it @@ -1879,7 +1887,7 @@ auto bundle_internal(JSON &document, const SchemaWalker &walker, // And so is what a mapping names, which is settled after the references // are, as bringing one schema in may be what lets the next be read - if (adopt_mappings(document, deferred, adopted, base, walker, + if (adopt_mappings(document, deferred, adopted, identities, base, walker, schema_resolver, openapi_dialect(walk.version), options, remaining)) { deferred.clear(); diff --git a/vendor/core/src/core/openapi/content.h b/vendor/core/src/core/openapi/content.h index 452838f99..87c723e4c 100644 --- a/vendor/core/src/core/openapi/content.h +++ b/vendor/core/src/core/openapi/content.h @@ -99,11 +99,14 @@ inline auto openapi_check_nested_encoding(const JSON &value, OpenAPIWalk &walk) -> void { const auto *named{value.try_at("encoding", OPENAPI_HASH_ENCODING)}; - // Section 4.14, of `encoding`: "This field MUST NOT be present if + // 3.2.1 Section 4.14, of `encoding`: "This field MUST NOT be present if // `prefixEncoding` or `itemEncoding` are present", and each of those two - // says the same of `encoding` in turn. Section 4.15 defines all three by - // reference to that Object, and the published meta-schema holds the pair to - // the same rule in both places + // says the same of `encoding` in turn. 3.2.1 Section 4.15 defines all three + // by reference to that Object and states no exclusion of its own, so what + // settles the nested pair is the corpus, which files both + // `encoding-enc-prefix-exclusion` and `encoding-enc-item-exclusion` as + // failing, and the published meta-schema, which holds the pair to the same + // rule in both places if (named != nullptr && (value.try_at("prefixEncoding", OPENAPI_HASH_PREFIX_ENCODING) != nullptr || @@ -148,8 +151,9 @@ inline auto openapi_check_encoding(const JSON &value, const Pointer &base, value, OPENAPI_ENCODING_FIELDS_3_1, OPENAPI_ENCODING_FIELDS_3_2, base, "The Encoding Object does not define this field", walk); - // The specification says media type definitions "SHOULD be in compliance - // with RFC6838", which is not a requirement, so only the type is checked + // 3.1.1 Section 3.6 says media type definitions "SHOULD be in compliance + // with RFC6838", which is not a requirement, and 3.2 drops the sentence + // rather than strengthening it, so only the type is checked openapi_check_optional_string( value, base, "contentType"sv, OPENAPI_HASH_CONTENT_TYPE, "The Encoding Object content type must be a string"); @@ -207,8 +211,8 @@ inline auto openapi_check_media_type(const JSON &value, const Pointer &base, "The Media Type Object example and examples are mutually exclusive", "The Media Type Object examples must be an object", walk); - // Section 4.14: "itemSchema | Schema Object", for a sequential media type, - // which is a fifth position where framing hands off to JSON Schema + // 3.2.1 Section 4.14: "itemSchema | Schema Object", for a sequential media + // type, which is a fifth position where framing hands off to JSON Schema const auto *item_schema{value.try_at("itemSchema", OPENAPI_HASH_ITEM_SCHEMA)}; if (item_schema != nullptr) { openapi_expect_schema(*item_schema, openapi_child(base, "itemSchema"sv), @@ -232,8 +236,9 @@ inline auto openapi_check_media_type_or_reference(const JSON &value, } // The map that the Request Body, Response, Parameter and Header Objects all -// key by media type. Section 4.5 says those definitions "SHOULD be in -// compliance with RFC6838", so the keys carry no requirement to enforce. +// key by media type. 3.1.1 Section 3.6 says those definitions "SHOULD be in +// compliance with RFC6838", which carries no requirement to enforce, and 3.2 +// drops the sentence rather than strengthening it. // OpenAPI Specification 3.2.1 widens what the values may be in all four of // those Objects, from "Map[string, Media Type Object]" to "Map[string, Media // Type Object | Reference Object]", which is what its Components Object entry @@ -262,8 +267,11 @@ inline auto openapi_check_header(const JSON &value, const Pointer &base, const auto *schema{value.try_at("schema", OPENAPI_HASH_SCHEMA)}; const auto *content{value.try_at("content", OPENAPI_HASH_CONTENT)}; - // OpenAPI Specification 3.1.1, Section 4.8.21: "The `schema` field and - // `content` field are mutually exclusive", and one of them has to be there + // OpenAPI Specification 3.1.1, Section 4.8.21 has "The Header Object + // follows the structure of the Parameter Object", and none of the changes it + // lists touches either field, so Section 4.8.12's rule reaches here whole: + // "Parameter Objects MUST include either a `content` field or a `schema` + // field, but not both" if (schema != nullptr && content != nullptr) { throw OpenAPIError{ base, "The Header Object schema and content are mutually exclusive"}; @@ -313,7 +321,8 @@ inline auto openapi_check_header(const JSON &value, const Pointer &base, const auto location{openapi_child(base, "content"sv)}; openapi_check_content(*content, location, "The Header Object content must be an object", walk); - // The meta-schema bounds this map at one entry in both directions + // Section 4.8.21: "The map MUST only contain one entry", which an empty + // map answers no better than a crowded one if (content->size() != 1) { throw OpenAPIError{ location, "The Header Object content must hold exactly one entry"}; diff --git a/vendor/core/src/core/openapi/discriminator.h b/vendor/core/src/core/openapi/discriminator.h index 38d6b6bd6..e403183b2 100644 --- a/vendor/core/src/core/openapi/discriminator.h +++ b/vendor/core/src/core/openapi/discriminator.h @@ -19,6 +19,7 @@ constexpr auto OPENAPI_HASH_DISCRIMINATOR{ constexpr auto OPENAPI_HASH_MAPPING{JSON::Object::hash("mapping"sv)}; constexpr auto OPENAPI_HASH_DEFAULT_MAPPING{ JSON::Object::hash("defaultMapping"sv)}; +constexpr auto OPENAPI_HASH_PROPERTY_NAME{JSON::Object::hash("propertyName"sv)}; /// Where a Discriminator Object names a schema, by the name of a component or /// by URI. OpenAPI Specification 3.1.1, Section 4.3 lists the URI form of a @@ -153,6 +154,26 @@ openapi_discriminators(const JSON &document, const SchemaFrame &schemas, const JSON::String scope{location.base}; origin.push_back(JSON::String{"discriminator"}); + // Section 4.8.25 marks this one "**REQUIRED**. The name of the property in + // the payload that will hold the discriminating value", and 3.2.1 Section + // 4.25 says as much, so a Discriminator Object the dialect does define + // carries one or it is no such Object. The dialect 3.2 publishes leaves it + // out of its own list of required fields where the one 3.1 publishes keeps + // it, and Section 4.8 leaves the text authoritative where the two differ + const auto *property_name{ + discriminator->try_at("propertyName", OPENAPI_HASH_PROPERTY_NAME)}; + if (property_name == nullptr) { + throw OpenAPIError{ + base, std::move(origin), + "The Discriminator Object must declare a property name"}; + } + + if (!property_name->is_string()) { + throw OpenAPIError{ + base, origin.concat(JSON::String{"propertyName"}), + "The Discriminator Object property name must be a string"}; + } + const auto *mapping{discriminator->try_at("mapping", OPENAPI_HASH_MAPPING)}; if (mapping != nullptr && mapping->is_object()) { const auto mapped{origin.concat(JSON::String{"mapping"})}; @@ -168,7 +189,7 @@ openapi_discriminators(const JSON &document, const SchemaFrame &schemas, // that revision defines the field, which is what settles whether there is // one to read rather than what the document says of itself. // - // Section 4.25.1 goes on to require one wherever the discriminating + // 3.2.1 Section 4.25.1 goes on to require one wherever the discriminating // property is optional, which is a demand on what the schema holding it // says of its own properties. Reading that far into a Schema Object is // the business of whatever understands JSON Schema, so it is left there diff --git a/vendor/core/src/core/openapi/document.h b/vendor/core/src/core/openapi/document.h index a4f8693fe..31201cef7 100644 --- a/vendor/core/src/core/openapi/document.h +++ b/vendor/core/src/core/openapi/document.h @@ -44,7 +44,15 @@ constexpr auto OPENAPI_DIALECT_3_1{ // URI instead: it "is identified by the URI of the form // `https://spec.openapis.org/oas/3.2/dialect/YYYY-MM-DD` [...] see the list of // current schemas for the specific URI". One date is published for 3.2, and it -// is the one this repository already resolves +// is the one this repository already resolves. +// +// A document that declares 3.2.0 gets this one as well, although that patch +// reads "identified by the URI +// `https://spec.openapis.org/oas/3.1/dialect/base`" and so names the dialect of +// the revision before it. 3.2.1 Section 2.1 makes a revision the +// `major`.`minor` pair alone, so what a later patch of one says is what the +// whole of it says, and the earlier wording is a mistake that patch corrects +// rather than a rule of its own constexpr auto OPENAPI_DIALECT_3_2{ "https://spec.openapis.org/oas/3.2/dialect/2025-09-17"sv}; @@ -84,19 +92,20 @@ inline auto openapi_check_document(const JSON &document, OpenAPIWalk &walk) // What a document's `$self` establishes as its base, or nothing when it // establishes none. OpenAPI Specification 3.2.1, Section 4.1: the field // "provides the self-assigned URI of this document, which also serves as its -// base URI in accordance with RFC3986 Section 5.1.1", and Section 4.1.2.2.1: -// "If -// `$self` is a relative URI reference, it is resolved against the next -// possible base URI source before being used". That next source is whatever -// base is in force here, which is the retrieval URI for the entry document and -// the URI a reference named for any other. RFC 3986 Section 5.2.1 has only the -// scheme required of a base, so a relative `$self` with nothing absolute to -// resolve against establishes nothing, and Section 5.2.2 never resolves -// against a fragment, so one written here is dropped rather than read. The -// specification's own published schema turns such a `$self` down outright, -// which Section 4 makes it no place to: "If the JSON Schema differs from this -// section, then this section MUST be considered authoritative", and the -// section it differs from asks only for a URI reference +// base URI in accordance with RFC3986 Section 5.1.1", and 3.2.1 +// Section 4.1.2.2.1: "If `$self` is a relative URI reference, it is resolved +// against the next possible base URI source ([RFC3986] Section 5.1.2 +// [...] 5.1.4) before being used for the resolution of other relative URI +// references". That source is whatever base is in force here, which is the +// retrieval URI for the entry document and the URI a reference named for any +// other. RFC 3986 +// Section 5.2.1 has only the scheme required of a base, so a relative `$self` +// with nothing absolute to resolve against establishes nothing, and +// Section 5.2.2 never resolves against a fragment, so one written here is +// dropped rather than read. The specification's own published schema turns such +// a `$self` down outright, which 3.2.1 Section 4 makes it no place to: "If the +// JSON Schema differs from this section, then this section MUST be considered +// authoritative", and the section it differs from asks only for a URI reference inline auto openapi_document_base(const JSON::StringView self, const OpenAPIWalk &walk) -> std::optional { @@ -344,8 +353,8 @@ inline auto openapi_check_document(const JSON &document, OpenAPIWalk &walk) document, OPENAPI_ROOT_FIELDS_3_1, OPENAPI_ROOT_FIELDS_3_2, EMPTY_POINTER, "The OpenAPI Object does not define this field", walk); - // Section 4.1: "$self | string | This string MUST be in the form of a URI - // reference as defined by RFC3986 Section 4.1". Only 3.2 defines the + // 3.2.1 Section 4.1: "$self | string | This string MUST be in the form of a + // URI reference as defined by RFC3986 Section 4.1". Only 3.2 defines the // field, and the table above has already turned it down for anything // earlier. What it establishes is the base that every location in this // document is keyed by, so it is settled before anything records one @@ -363,10 +372,10 @@ inline auto openapi_check_document(const JSON &document, OpenAPIWalk &walk) // // MUST NOT contain a fragment // - // but Section 4 settles which of the two answers for this: "This text is - // the only normative description of the format. A JSON Schema is hosted - // on spec.openapis.org for informational purposes. If the JSON Schema - // differs from this section, then this section MUST be considered + // but 3.2.1 Section 4 settles which of the two answers for this: "This + // text is the only normative description of the format. A JSON Schema is + // hosted on spec.openapis.org for informational purposes. If the JSON + // Schema differs from this section, then this section MUST be considered // authoritative". The text asks only for a URI reference, and RFC 3986 // Section 4.1 admits a fragment in one, so the pattern is a rule the // normative prose does not carry and is not enforced here. Nothing is @@ -377,9 +386,9 @@ inline auto openapi_check_document(const JSON &document, OpenAPIWalk &walk) if (established.has_value()) { walk.base = std::move(established.value()); - // Section 4.1.1: "To ensure interoperability, references MUST use the - // target document's `$self` URI if the `$self` field is present". So - // this is the URI the document answers to, and one that names it by + // 3.2.1 Section 4.1.1: "To ensure interoperability, references MUST use + // the target document's `$self` URI if the `$self` field is present". + // So this is the URI the document answers to, and one that names it by // where it was retrieved from instead names another document, which // the same paragraph calls "not interoperable" and NOT RECOMMENDED } @@ -408,9 +417,19 @@ inline auto openapi_check_document(const JSON &document, OpenAPIWalk &walk) openapi_collect_tags(document, walk); } - // Section 3.1: an OpenAPI Description "MUST contain at least one paths - // field, components field, or webhooks field" - if (document.try_at("paths", OPENAPI_HASH_PATHS) == nullptr && + // OpenAPI Specification 3.2.1, Section 4.1 binds every document that holds + // an OpenAPI Object: "In addition to the required fields, at least one of + // the `components`, `paths`, or `webhooks` fields MUST be present". + // + // 3.1.1, Section 3.1 binds the description instead, and names what it is + // made of while doing so: "An OpenAPI Description (OAD) [...] is composed + // of an entry document [...] and any/all of its referenced documents [...] + // and MUST contain at least one `paths` field, `components` field, or + // `webhooks` field". 3.1.0 asked it of a document and 3.1.1 moved the + // subject, so a document of that revision that another one reaches is free + // to hold none of the three as long as the description holds one + if ((!walk.referenced || walk.version == OpenAPIVersion::OPENAPI_3_2) && + document.try_at("paths", OPENAPI_HASH_PATHS) == nullptr && document.try_at("components", OPENAPI_HASH_COMPONENTS) == nullptr && document.try_at("webhooks", OPENAPI_HASH_WEBHOOKS) == nullptr) { throw OpenAPIError{ @@ -440,7 +459,8 @@ inline auto openapi_check_document(const JSON &document, OpenAPIWalk &walk) // Section 4.8.24.1: "To allow use of a different default `$schema` value // for all Schema Objects contained within an OAS document, a // `jsonSchemaDialect` value may be set within the OpenAPI Object. If this - // default is not set, then the OAS dialect schema id MUST be used". What a + // default is not set, then the OAS dialect schema id MUST be used for + // these Schema Objects". What a // Schema Object says about itself overrides this, which is a matter for // whatever reads inside one JSON::String effective_dialect{openapi_dialect(walk.version)}; @@ -533,10 +553,10 @@ inline auto openapi_check_tag_parents( // OpenAPI Specification 3.1.1, Section 4.8.20: "The identified or reference // operation MUST be unique, and in the case of an `operationId`, it MUST be -// resolved within the scope of the OpenAPI Description". Section 4.3.3 -// recommends resolving one "considering all Operation Objects from all parsed -// documents", so nothing is decided here until every document of the -// description is held at once +// resolved within the scope of the OpenAPI Description". Section 4.3.3 goes on +// that "This requires parsing all referenced documents prior to determining an +// `operationId` to be unresolvable", so nothing is decided here until every +// document of the description is held at once inline auto openapi_check_operation_id_links( const OpenAPIWalk &walk, const std::map &locations) -> void { @@ -569,7 +589,7 @@ inline auto openapi_check_schema_positions(const OpenAPIWalk &walk) -> void { // Which place holds this one is what the walk recorded of each place // above it rather than anything the order of them suggests. A location is - // held under a key that sorts as a string, and Section 4.7 admits both + // held under a key that sorts as a string, and Section 4.8.7 admits both // `-` and `.` into a component name, either of which falls below the `/` // that separates a place from what sits within it. So a sibling named // that way comes between an Object and its own contents, which is why diff --git a/vendor/core/src/core/openapi/example.h b/vendor/core/src/core/openapi/example.h index 9235260cc..9598141e6 100644 --- a/vendor/core/src/core/openapi/example.h +++ b/vendor/core/src/core/openapi/example.h @@ -56,8 +56,8 @@ inline auto openapi_check_example(const JSON &value, const Pointer &base, base, "The Example Object data value and value are mutually exclusive"}; } - // Section 4.19: "serializedValue | string | An example of the serialized - // form of the value [...] If this field is present, `value`, and + // 3.2.1 Section 4.19: "serializedValue | string | An example of the + // serialized form of the value [...] If this field is present, `value`, and // `externalValue` MUST be absent". The `externalValue` field states the // other half of that pair the same way const auto *serialized{ @@ -110,8 +110,8 @@ inline auto openapi_check_example_or_reference(const JSON &value, } // The Parameter, Media Type and Header Objects all carry this pair, and all -// three state that "The `example` field is mutually exclusive of the -// `examples` field" +// three state that "The `example` and `examples` fields are mutually +// exclusive" inline auto openapi_check_examples(const JSON &value, const Pointer &base, const char *exclusive_message, const char *type_message, OpenAPIWalk &walk) diff --git a/vendor/core/src/core/openapi/frame.cc b/vendor/core/src/core/openapi/frame.cc index ae20fdf5d..89316719c 100644 --- a/vendor/core/src/core/openapi/frame.cc +++ b/vendor/core/src/core/openapi/frame.cc @@ -1,4 +1,5 @@ #include +#include #include "discriminator.h" #include "document.h" @@ -13,6 +14,7 @@ #include // std::optional #include // std::set #include // std::string_view +#include // std::tuple #include // std::move, std::pair, std::unreachable #include // std::vector @@ -110,9 +112,12 @@ auto version_string(const sourcemeta::core::OpenAPIVersion version) // A Reference Object, and a Path Item Object that declares a `$ref`, stand in // for what they lead to. OpenAPI Specification 3.1.1, Section 4.8.9 has `$ref` -// "allow for a referenced definition of this path item" and leaves what a -// sibling field means undefined, so the definition is what the reference leads -// to rather than anything written alongside it +// "Allows for a referenced definition of this path item", and leaves undefined +// only what "appears both in the defined object and the referenced object", so +// what the reference leads to is where a place is looked for first. A field +// written beside it that the referenced Path Item Object does not declare is +// an ordinary field of the Object holding it, which the projection reads back +// rather than this auto follow_aliases(const sourcemeta::core::OpenAPIWalk &walk, const sourcemeta::core::JSON::String &position) -> sourcemeta::core::JSON::String { @@ -126,6 +131,25 @@ auto identity_of(const sourcemeta::core::OpenAPIWalk &walk, return sourcemeta::core::openapi_parameter_identity(walk, position); } +// Whether a Parameter Object is one the specification tells a reader to look +// past. Section 4.8.12 names three by their location and their name: "If `in` +// is `"header"` and the `name` field is `"Accept"`, `"Content-Type"` or +// `"Authorization"`, the parameter definition SHALL be ignored". Section +// 4.8.12.1 reads such a name under RFC 7230, which "states header names are +// case insensitive", and 3.2.1 Section 4.12.1 says as much under RFC 9110, so +// which letters it is written with settles nothing +auto ignored_by_the_specification( + const std::pair &identity) -> bool { + if (identity.second != "header") { + return false; + } + + auto name{identity.first}; + sourcemeta::core::to_lowercase(name); + return name == "accept" || name == "content-type" || name == "authorization"; +} + // The parameters in force where an operation sits. Section 4.8.9 has the ones // a Path Item Object declares "applicable for all the operations described // under this path. These parameters can be overridden at the operation level, @@ -151,12 +175,24 @@ auto parameters_of(const sourcemeta::core::OpenAPIWalk &walk, // A Reference Object that was never followed names no parameter, so // nothing can be said to override it const auto *identity{identity_of(walk, position)}; + if (identity != nullptr && ignored_by_the_specification(*identity)) { + continue; + } + if (identity == nullptr || !claimed.contains(*identity)) { result.push_back(position); } } - result.insert(result.cend(), operation.cbegin(), operation.cend()); + for (const auto &position : operation) { + const auto *identity{identity_of(walk, position)}; + if (identity != nullptr && ignored_by_the_specification(*identity)) { + continue; + } + + result.push_back(position); + } + return result; } @@ -181,8 +217,16 @@ auto tags_of(const sourcemeta::core::OpenAPIWalk &walk, // The servers in force where an operation sits. OpenAPI Specification 3.1.1, // Section 4.8.10 has an Operation Object's servers override those of "the Path -// Item Object or OpenAPI Object level", and Section 4.8.1 makes an empty array -// there stand for none being given at all, so an empty array carries on up +// Item Object or OpenAPI Object level", and Section 4.8.9 says as much of a +// Path Item Object's own. +// +// Neither says what an empty array means where it is written. Only Section +// 4.8.1 speaks of one, and only of the array the OpenAPI Object itself holds: +// "If the `servers` field is not provided, or is an empty array, the default +// value would be a Server Object with a url value of `/`". That sentence is no +// authority over the two levels below it, +// so reading an empty array there as nothing given is a choice this makes +// where the specification says nothing, rather than a rule it follows auto servers_of(const std::vector &operation, const std::vector &path_item, const std::vector &document) @@ -202,7 +246,8 @@ auto servers_of(const std::vector &operation, auto check_path_parameters( const sourcemeta::core::OpenAPIWalk &walk, const std::vector &templates, - const std::vector ¶meters) -> void { + const std::vector ¶meters, + const char *message) -> void { for (const auto &position : parameters) { const auto *identity{identity_of(walk, position)}; // A Reference Object that was never followed names no parameter, so @@ -212,13 +257,82 @@ auto check_path_parameters( } if (std::ranges::find(templates, identity->first) == templates.cend()) { - throw error_at(walk, position, - "A path Parameter Object must name a template expression " - "of the path it is under"); + throw error_at(walk, position, message); } } } +// Every Path Item Object a position leads through, from the one written down +// to the one the chain ends at. A `$ref` may lead to a Path Item Object that +// declares one of its own, and each of those is a place of the description in +// its own right, so reading only the two ends would pass over whatever the +// middle of a chain writes beside its own reference +auto aliased_path_items(const sourcemeta::core::OpenAPIWalk &walk, + const sourcemeta::core::JSON::String &position) + -> std::vector { + std::vector result; + // Every position walked through is one the caller or the walk already holds, + // so this runs once per path and keeps no string of its own + std::set seen; + const auto *current{&position}; + while (seen.insert(*current).second) { + const auto record{walk.path_items.find(*current)}; + if (record != walk.path_items.cend()) { + result.push_back(&record->second); + } + + const auto alias{walk.references.find(*current)}; + if (alias == walk.references.cend()) { + break; + } + + current = &alias->second.destination; + } + + return result; +} + +// The template expressions of every path that exposes a given Path Item +// Object, keyed by where that Path Item sits. The requirement above names the +// Paths Object rather than wherever the parameter happens to be written, so a +// Path Item reached as a webhook or through a callback expression is held to +// what the paths reaching that very Path Item declare, and one no path reaches +// leaves nothing for such a parameter to correspond to +auto exposing_expressions(const sourcemeta::core::OpenAPIWalk &walk) + -> std::pair>, + bool> { + std::map> + result; + bool whole{true}; + for (const auto &endpoint : walk.endpoints) { + if (endpoint.kind != sourcemeta::core::OpenAPIOperationKind::Path) { + continue; + } + + const auto position{follow_aliases(walk, endpoint.path_item)}; + // A path whose own Path Item Object the walk could not reach may be the + // very path that exposes another one, so what is gathered here is short of + // what the description holds and says nothing about a place it does not + // cover. OpenAPI Specification 3.2.1, Section 4.1.2.1 leaves no room to + // call a reference unresolvable while a document of the description has + // gone unread + if (!walk.path_items.contains(position)) { + whole = false; + continue; + } + + auto &expressions{result[position]}; + for (const auto &expression : + sourcemeta::core::openapi_brace_expressions(endpoint.path)) { + expressions.push_back(expression); + } + } + + return {std::move(result), whole}; +} + // OpenAPI Specification 3.2.1, Section 4.12, of a parameter whose location // is `querystring`: it "MUST NOT appear more than once, and MUST NOT appear in // the same operation (or in the operation's path-item) as any `in: "query"` @@ -262,25 +376,55 @@ auto check_querystring( // parameters in force for one operation rather than of either level alone, and // Section 3.5 excuses an empty Path Item from it, which is why nothing checks // it until there is an operation to check -auto check_path_templates( +auto collect_path_parameter_names( const sourcemeta::core::OpenAPIWalk &walk, - const sourcemeta::core::JSON::String &endpoint, - const std::vector &templates, - const std::vector ¶meters) -> void { - std::set named; + const std::vector ¶meters, + std::set &names) -> bool { for (const auto &position : parameters) { const auto *identity{identity_of(walk, position)}; // A reference the walk could not follow may be the very parameter a // template expression is looking for, and a description we do not hold in // full is one we cannot call incomplete. This is the same restraint - // Section 8.7.1 applies to a Link Object's operation identifier + // Section 4.3.3 applies to a Link Object's operation identifier, and it + // stops in the same place: one that ends inside this very document ends + // where no further reading can supply a parameter, so it is passed over + // rather than taken as a reason to say nothing if (identity == nullptr) { - return; + if (!sourcemeta::core::openapi_within_document( + follow_aliases(walk, position), walk.base)) { + return false; + } + + continue; } if (identity->second == "path") { - named.insert(identity->first); + names.insert(identity->first); + } + } + + return true; +} + +auto check_path_templates( + const sourcemeta::core::OpenAPIWalk &walk, + const sourcemeta::core::JSON::String &endpoint, + const std::vector &templates, + const std::vector &chain, + const std::vector ¶meters) -> void { + std::set named; + if (!collect_path_parameter_names(walk, parameters, named)) { + return; + } + + // Section 4.8.9 leaves undefined which of two lists a chain writes is in + // force, so a template expression is answered by a path parameter written at + // any place the chain leads through. Turning a description down on one + // reading alone would refuse what another equally licensed reading accepts + for (const auto *record : chain) { + if (!collect_path_parameter_names(walk, record->parameters, named)) { + return; } } @@ -304,49 +448,158 @@ namespace sourcemeta::core { auto openapi_project(const OpenAPIWalk &walk) -> std::vector { std::vector result; std::vector pending{walk.endpoints}; - std::set seen; + const auto expressions{exposing_expressions(walk)}; + // What tells one exposure from another, held as the four things it is + // rather than as one string spelling them out. A position carries a `#` of + // its own, so any character picked to join them is a character one of them + // may hold, and the spelling would not tell every pair of exposures apart + std::set>> + seen; for (std::size_t index = 0; index < pending.size(); index += 1) { const auto kind{pending[index].kind}; const auto path{pending[index].path}; const auto endpoint{pending[index].path_item}; + const auto parent{pending[index].parent}; auto position{follow_aliases(walk, endpoint)}; // A Path Item that leads back to one already exposed the same way exposes - // nothing further, which is what stops a cycle of them - JSON::String key{openapi_operation_kind_name(kind)}; - key.append("#").append(path).append("#").append(position); - if (!seen.insert(std::move(key)).second) { + // nothing further, which is what stops a cycle of them. Section 4.8.10 + // hangs a Callback Object off "the parent operation", so one that two of + // them reach is reached twice rather than once and the Object it hangs off + // is part of what tells the two apart + if (!seen.emplace(kind, path, position, parent).second) { continue; } - const auto entry{walk.path_items.find(position)}; - if (entry == walk.path_items.cend()) { + // Section 4.8.9 leaves undefined only what "appears both in the defined + // object and the referenced object", so a field written beside a `$ref` + // that the referenced Path Item Object does not declare is an ordinary + // field of the Path Item Object holding it, and Section 3.5 counts a path + // parameter "included in the Path Item itself" wherever it is written. So + // what the reference leads to answers first, and what sits beside it + // answers for whatever that leaves unsaid + const auto chain{aliased_path_items(walk, endpoint)}; + if (chain.empty()) { continue; } + // Whether what the chain ends at is settled by the document in hand. It is + // settled when that place is a Path Item Object this holds, and equally + // when the chain ends on a pointer into this very document that leads + // nowhere, since no further reading can rescue one of those. 3.2.1 Section + // 4.1.2.1 asks for every document to be parsed before a reference is + // called unresolvable, which leaves only a chain ending in a document this + // has not read unsettled + const auto settled{walk.path_items.contains(position) || + openapi_within_document(position, walk.base)}; + + // The last place the chain leads through that this one holds answers + // first, which is what the chain ends at whenever that landed, and each + // place nearer to it answers before the one that names it. Section 4.8.9 + // leaves that order undefined between any two of them and so free to pick + const auto *parameters_of_path_item{&chain.back()->parameters}; + const auto *servers_of_path_item{&chain.back()->servers}; + auto methods{chain.back()->operations}; + for (const auto *record : std::ranges::reverse_view{chain}) { + if (parameters_of_path_item->empty()) { + parameters_of_path_item = &record->parameters; + } + + if (servers_of_path_item->empty()) { + servers_of_path_item = &record->servers; + } + + for (const auto &method : record->operations) { + if (std::ranges::none_of(methods, [&method](const auto &known) -> bool { + return known.first == method.first; + })) { + methods.push_back(method); + } + } + } + + const auto &path_item_parameters{*parameters_of_path_item}; + const auto &path_item_servers{*servers_of_path_item}; + // A webhook name and a callback expression are not templated paths, so - // only what the Paths Object exposes has any templating to correspond to + // only what the Paths Object exposes has any templating of its own const auto templated{kind == OpenAPIOperationKind::Path}; - const auto templates{templated ? openapi_brace_expressions(path) - : std::vector{}}; - if (templated) { - check_path_parameters(walk, templates, entry->second.parameters); + const auto own_templates{templated ? openapi_brace_expressions(path) + : std::vector{}}; + // Section 4.8.12 asks a path Parameter Object to name "a template + // expression occurring within the path field in the Paths Object" rather + // than one of whichever path it happens to sit under, and one Path Item + // Object may be reached from several of them. So every path exposing this + // one answers, which is the reading a webhook and a callback already went + // by, and a Path Item Object no path reached falls back to the path being + // projected + const auto exposed{expressions.first.find(position)}; + const auto &templates{ + exposed == expressions.first.cend() ? own_templates : exposed->second}; + // A path answers for its own braces whatever else went unread, but the set + // gathered from every path exposing one Path Item Object is short by + // whatever a path this does not hold would have added to it, so only the + // first of those two is worth reading against on its own + const auto own_only{templated && exposed == expressions.first.cend()}; + const auto *const message{ + "A path Parameter Object must name a template expression of a path " + "that exposes it"}; + // What no path exposes is only known to be exposed by none once every path + // has been read, which a description held short of its documents leaves + // unsettled + // Section 4.8.12 binds every Parameter Object written down rather than + // whichever list the fold above carries forward, so each place the chain + // leads through answers for the ones it declares itself + // A path answers for its own braces whatever else is unread, but every + // other kind of endpoint is held to the expressions of the paths reaching + // the Path Item Object the chain ends at, and a chain that ends somewhere + // this does not hold is one whose end an unread document still decides + if ((own_only || expressions.second) && settled) { + for (const auto *record : chain) { + check_path_parameters(walk, templates, record->parameters, message); + // An Operation Object that a method of the same name nearer the end of + // the chain outranks is written down all the same, and Section 4.8.12 + // binds a Parameter Object wherever it is written + for (const auto &declared : record->operations) { + const auto operation{walk.operation_records.find(declared.second)}; + if (operation != walk.operation_records.cend()) { + check_path_parameters(walk, templates, operation->second.parameters, + message); + } + } + } } - for (const auto &[method, origin] : entry->second.operations) { + for (const auto &[method, origin] : methods) { const auto operation{walk.operation_records.find(origin)}; if (operation == walk.operation_records.cend()) { continue; } auto parameters{parameters_of(walk, operation->second.parameters, - entry->second.parameters)}; - if (templated) { - check_path_parameters(walk, templates, parameters); - check_path_templates(walk, endpoint, templates, parameters); + path_item_parameters)}; + // The two loops above already answer for every Parameter Object written + // at any place the chain leads through, and what is in force here is + // drawn from those same lists, so this holds nothing new. It abstains + // on the same terms all the same, as the expressions it would be read + // against are the ones an unread document settles + if ((own_only || expressions.second) && settled) { + check_path_parameters(walk, templates, parameters, message); + } + // This one turns on an expression having no parameter anywhere, and a + // chain that ends somewhere this does not hold may well end at the Path + // Item Object declaring it, so there is nothing to conclude yet + if (templated && settled) { + check_path_templates(walk, endpoint, own_templates, chain, + operation->second.parameters); } - check_querystring(walk, origin, parameters); + // Which of the chain's lists is in force decides whether these two ever + // meet, and that is not settled while the chain ends somewhere unread + if (settled) { + check_querystring(walk, origin, parameters); + } result.push_back( {.kind = kind, @@ -354,8 +607,9 @@ auto openapi_project(const OpenAPIWalk &walk) -> std::vector { .method = method, .origin = origin, .endpoint = endpoint, - .servers = servers_of(operation->second.servers, - entry->second.servers, walk.servers), + .parent = parent, + .servers = servers_of(operation->second.servers, path_item_servers, + walk.servers), // Section 4.8.10: "This definition overrides any declared top-level // security. To remove a top-level security declaration, an empty // array can be used", which is why declaring none and declaring an @@ -376,7 +630,8 @@ auto openapi_project(const OpenAPIWalk &walk) -> std::vector { for (const auto &[expression, path_item] : entries->second) { pending.push_back({.kind = OpenAPIOperationKind::Callback, .path = expression, - .path_item = path_item}); + .path_item = path_item, + .parent = origin}); } } } @@ -417,10 +672,6 @@ OpenAPIFrame::OpenAPIFrame(const JSON &document, const SchemaWalker &walker, max_locations)}; this->internal_->version = walk.version; this->internal_->info = walk.info; - // What the caller passed in is where the entry document was retrieved from, - // and from 3.2 onwards the document may give itself a URI of its own, which - // the walk settles and everything it holds is keyed by - this->internal_->base = std::move(walk.base); // A frame stands alone when everything it references is inside it, which is // what a caller asks before deciding whether it has the whole description. // Which references leave it is what making it whole comes down to, so each @@ -446,8 +697,13 @@ OpenAPIFrame::OpenAPIFrame(const JSON &document, const SchemaWalker &walker, } } - // Projecting reads the whole walk, so nothing is taken out of it until after + // Projecting reads the whole walk, the base included, so nothing is taken + // out of it until after this->internal_->operations = openapi_project(walk); + // What the caller passed in is where the entry document was retrieved from, + // and from 3.2 onwards the document may give itself a URI of its own, which + // the walk settles and everything it holds is keyed by + this->internal_->base = std::move(walk.base); const auto walk_locations{walk.locations.size()}; this->internal_->locations = std::move(walk.locations); this->internal_->references = std::move(walk.references); @@ -618,6 +874,12 @@ auto OpenAPIFrame::to_json() const -> JSON { entry.assign_assume_new("origin", JSON{operation.origin}); entry.assign_assume_new("endpoint", JSON{operation.endpoint}); + // Only an operation that a Callback Object exposes has one of these, which + // is the Operation Object that Callback Object hangs off + if (operation.parent.has_value()) { + entry.assign_assume_new("parent", JSON{operation.parent.value()}); + } + entry.assign_assume_new("tags", sourcemeta::core::to_json(operation.tags)); entry.assign_assume_new("servers", diff --git a/vendor/core/src/core/openapi/helpers.h b/vendor/core/src/core/openapi/helpers.h index 4b186f194..31961c67b 100644 --- a/vendor/core/src/core/openapi/helpers.h +++ b/vendor/core/src/core/openapi/helpers.h @@ -13,7 +13,7 @@ #include // std::initializer_list #include // std::numeric_limits #include // std::map -#include // std::optional +#include // std::optional, std::nullopt #include // std::set #include // std::span #include // std::string_view @@ -273,6 +273,11 @@ struct OpenAPIEndpoint { OpenAPIOperationKind kind; JSON::String path; JSON::String path_item; + /// Where the Operation Object that a Callback Object hangs off sits, which + /// only an expression of such an Object is exposed by. Section 4.8.10 makes + /// that Object the one a callback is "related to", so two of them reaching + /// one Callback Object expose it twice rather than once + std::optional parent{std::nullopt}; }; /// One operation of the described API, which is what an endpoint and the Path @@ -287,6 +292,13 @@ struct OpenAPIOperation { /// that gives it a URL rather than the one that defines it. The two differ /// whenever a reference stands between them JSON::String endpoint; + /// Where the Operation Object that a Callback Object hangs off sits, with no + /// value for an operation the Paths Object or the webhooks exposes. Section + /// 4.8.10 has a Callback Object be "a map of possible out-of band callbacks + /// related to the parent operation", and what the expression it is keyed by + /// evaluates against is that Object's request, so one Callback Object two + /// Operation Objects reach describes one callback for each of them + std::optional parent{std::nullopt}; /// Where the Server Objects in force sit, empty when nothing declares any, /// in which case Section 4.8.1 puts a single Server Object with a `url` of /// `/` in their place @@ -332,11 +344,11 @@ struct OpenAPIWalk { // as the base every URI it holds resolves against. OpenAPI Specification // 3.2.1, Section 4.5.2 keeps the addresses of the API itself out of that: // "Because the API is a distinct entity from the OpenAPI document, RFC3986's - // base URI rules for the OpenAPI document do not apply", and Section 4.5.2.1 - // says which base does apply instead: "For API URLs the `$self` field, which - // identifies the OpenAPI document, is ignored and the retrieval URI is used - // instead". So this is kept apart from the base above rather than replaced - // by it + // base URI rules for the OpenAPI document do not apply", and 3.2.1 + // Section 4.5.2.1 says which base does apply instead: "For API URLs the + // `$self` field, which identifies the OpenAPI document, is ignored and the + // retrieval URI is used instead". So this is kept apart from the base above + // rather than replaced by it JSON::String retrieval; // The document the checks are reading, which a reference that stays inside // it resolves its fragment against @@ -408,14 +420,15 @@ struct OpenAPIWalk { /// it, which is the name an Operation Object's tags resolve against std::map tags; /// What each Tag Object that declares a parent is called and which tag it - /// names, keyed by where that Tag Object sits. Section 4.22 has the named - /// tag exist and forbids a cycle, neither of which can be settled until + /// names, keyed by where that Tag Object sits. 3.2.1 Section 4.22 has the + /// named tag exist and forbids a cycle, neither of which can be settled until /// every tag has been read std::map> tag_parents; - /// Every name any document declares a Tag Object under. Section 4.22 has a - /// parent name "a tag that MUST exist in the API description", which is the - /// whole of it rather than the entry document alone, so this is a wider set - /// than the one above it + /// Every name any document declares a Tag Object under. 3.2.1 Section 4.22 + /// has a parent name "The `name` of a tag that this tag is nested under", + /// and of what it names, "The named tag MUST exist in the API description". + /// A description is the whole of what it spans rather than the entry + /// document alone, so this is a wider set than the one above it std::set tag_names; /// The operation each Link Object names, keyed by where that Link Object /// sits. Section 4.3.3 has resolving one of these require "parsing all @@ -506,7 +519,9 @@ inline auto openapi_child(const Pointer &base, const std::size_t index) // fields: fixed fields, which have a declared name, and patterned fields, // which have a declared pattern for the field name". These objects declare // `^x-` as their only pattern, so a member that is neither is not a field that -// this specification defines +// this specification defines. That sentence only tells the two apart, and what +// turns the leftover into a refusal is Section 4.9, which holds an extension +// to "MUST begin with `x-`, for example, `x-internal-id`" template auto openapi_reject_unknown_fields( const JSON &object, const std::array &fields, @@ -521,6 +536,30 @@ auto openapi_reject_unknown_fields( } } +// A field table may hold a field whose Description cell then restricts where +// it applies, which is a field the Object defines rather than one it does not. +// The two are turned down alike, so which of them a member is has to be said +// here for the reason given to be a true one +template +auto openapi_reject_unknown_fields( + const JSON &object, const std::array &fields, + const Pointer &base, const char *message, + const std::array &restricted, + const char *restricted_message) -> void { + for (const auto &entry : object.as_object()) { + if (entry.first.starts_with(OPENAPI_EXTENSION_PREFIX) || + std::ranges::find(fields, entry.first) != fields.cend()) { + continue; + } + + throw OpenAPIError{openapi_child(base, entry.first), + std::ranges::find(restricted, entry.first) != + restricted.cend() + ? restricted_message + : message}; + } +} + // A field table is a property of the revision a document declares, so an // Object whose table grew between revisions has one array per revision and // which of them applies is asked here rather than at every call site @@ -536,12 +575,29 @@ auto openapi_reject_unknown_fields( } } +template +auto openapi_reject_unknown_fields( + const JSON &object, const std::array &fields, + const std::array &later, const Pointer &base, + const char *message, const OpenAPIWalk &walk, + const std::array &restricted, + const char *restricted_message) -> void { + if (walk.version == OpenAPIVersion::OPENAPI_3_2) { + openapi_reject_unknown_fields(object, later, base, message, restricted, + restricted_message); + } else { + openapi_reject_unknown_fields(object, fields, base, message, restricted, + restricted_message); + } +} + // A location is keyed the way a schema frame keys its own: the document base // with the pointer hung off it as a fragment, or the pointer alone when no // base was established, and the base by itself for the root of a document. // // RFC 6901 Section 6: "A JSON Pointer can be represented in a URI fragment -// identifier by encoding it into octets using UTF-8, while percent-encoding +// identifier by encoding it into octets using UTF-8 [RFC3629], while +// percent-encoding // those characters not allowed by the fragment rule in [RFC3986]". A path // template holds braces and a callback expression holds a `#`, neither of // which a fragment admits, so writing the pointer out as it stands would give @@ -591,6 +647,16 @@ inline auto openapi_document_uri(const JSON::String &uri) -> JSON::String { return JSON::String{take_until(uri, '#')}; } +// Whether a location key names a place in a given document. A pointer into a +// document already in hand that leads nowhere leads nowhere for good, while +// one naming another document says only that the document has gone unread, so +// telling the two apart is what several checks abstain on. They abstain on one +// answer rather than each asking in its own words +inline auto openapi_within_document(const JSON::String &uri, + const JSON::String &base) -> bool { + return take_until(uri, '#') == base; +} + // Where a problem found once the walk is over belongs. A location says which // base it is keyed by and where under it the Object sits, and a field hangs // off that when the problem is with one rather than with the Object holding it @@ -613,8 +679,9 @@ openapi_error_at(const std::map &locations, // 5.1.2 and so leaves out 5.1.1, "Base URI Embedded in Content". A 3.1 // document therefore has no way of declaring its own base, and what remains is // 5.1.3, "Base URI from the Retrieval URI", which only the caller can supply. -// Section 4.6 says as much: implementations "SHOULD allow users to provide -// documents with their intended retrieval URIs" +// 3.2.1 Section 4.1.2.2.1 says as much, and 3.1 leaves it unsaid rather than +// otherwise: implementations "SHOULD allow users to provide documents with +// their intended retrieval URIs" inline auto openapi_canonical_base(const std::string_view input) -> JSON::String { if (input.empty()) { diff --git a/vendor/core/src/core/openapi/include/sourcemeta/core/openapi.h b/vendor/core/src/core/openapi/include/sourcemeta/core/openapi.h index 2180b3d79..e74328c67 100644 --- a/vendor/core/src/core/openapi/include/sourcemeta/core/openapi.h +++ b/vendor/core/src/core/openapi/include/sourcemeta/core/openapi.h @@ -45,7 +45,8 @@ enum class OpenAPIVersion : std::uint8_t { /// @ingroup openapi /// Determine the version of an OpenAPI Description from its `openapi` field -/// without framing it, returning no value for a version we do not recognise. +/// without framing it, returning no value for a version we do not recognise +/// and for anything that declares no such field to read. /// The patch component of the field carries no meaning, so every `3.1.x` /// release maps to the same result. For example: /// @@ -139,7 +140,10 @@ struct OpenAPIInfo { /// std::cout << std::endl; /// ``` /// -/// A frame is analysed once, on construction, and is immutable afterwards. +/// A frame is analysed once, on construction, and what it reports never +/// changes afterwards. Reading one is not thread safe even so, as it answers +/// out of caches it fills as it goes. A frame cannot be copied or moved, so it +/// is built where it is read. class SOURCEMETA_CORE_OPENAPI_EXPORT OpenAPIFrame { public: /// Frame an OpenAPI Description from a given document. That document must @@ -149,8 +153,9 @@ class SOURCEMETA_CORE_OPENAPI_EXPORT OpenAPIFrame { /// The base is the retrieval URI of the document. OpenAPI 3.1 offers a /// document no way of declaring an identity of its own, so under that /// revision this is the only way to give the description one. From 3.2 - /// onwards a document may declare `$self`, which takes precedence and is - /// resolved against this when relative + /// onwards a document may declare `$self`, which takes precedence once it is + /// absolute, resolving against this when relative and standing aside when + /// neither gives it a scheme /// /// Only the given document is read. A reference that leaves it is recorded /// and left there, and a frame holding one of those does not stand alone @@ -161,10 +166,31 @@ class SOURCEMETA_CORE_OPENAPI_EXPORT OpenAPIFrame { /// description may be written against is the caller's to state: pass /// sourcemeta::core::schema_walker and /// sourcemeta::core::schema_resolver for the dialects that are published, - /// and a resolver of your own for one that is not + /// and a resolver of your own for one that is not. The frame keeps the + /// resolver and asks it again when exporting, so whatever it reaches for has + /// to be there for as long as the frame is + /// + /// The places the description holds and the places its Schema Objects hold + /// are places of the one description, so they spend from the one allowance. + /// Bound it to throw sourcemeta::core::OpenAPIFrameLimitError rather than + /// register past it, which reports the allowance the caller set rather than + /// whatever was left of it + /// + /// The base must carry a scheme. One that does not is refused before the + /// document is read, which is why such a refusal names no place within it /// /// A document that does not conform to the specification is rejected here - /// rather than reported back + /// rather than reported back, by throwing sourcemeta::core::OpenAPIError. + /// What sits inside a Schema Object is held to JSON Schema instead, so one + /// naming a dialect nothing resolves throws + /// sourcemeta::core::SchemaResolutionError, one declaring an identifier or a + /// reference that is no URI throws sourcemeta::core::SchemaKeywordError, and + /// two colliding on an identifier or on an anchor throw + /// sourcemeta::core::SchemaFrameError and + /// sourcemeta::core::SchemaAnchorCollisionError respectively. One whose + /// dialect or base dialect cannot be settled at all throws + /// sourcemeta::core::SchemaUnknownDialectError or + /// sourcemeta::core::SchemaUnknownBaseDialectError OpenAPIFrame( const JSON &document, const SchemaWalker &walker, const SchemaResolver &resolver, std::string_view default_base = "", @@ -228,7 +254,8 @@ class SOURCEMETA_CORE_OPENAPI_EXPORT OpenAPIFrame { /// against, canonicalised, or the empty URI reference when nothing /// established one, which leaves those references relative. It is the /// `$self` the entry document declares, and the retrieval URI the caller - /// supplied when it declares none. For example: + /// supplied when it declares none or when what it declares cannot be made + /// absolute, in either case stripped of any fragment. For example: /// /// ```cpp /// #include @@ -251,7 +278,9 @@ class SOURCEMETA_CORE_OPENAPI_EXPORT OpenAPIFrame { /// Check whether everything this description references is inside what was /// framed, which counts what its Schema Objects reference as much as what - /// the shell around them does. For example: + /// the shell around them does. The dialect a Schema Object names is not one + /// of those, as a schema is under no obligation to carry the meta-schema it + /// is written against. For example: /// /// ```cpp /// #include @@ -299,7 +328,28 @@ class SOURCEMETA_CORE_OPENAPI_EXPORT OpenAPIFrame { [[nodiscard]] auto schemas() const noexcept -> const SchemaFrame &; /// Export the frame as JSON. This is the complete state of the frame, and - /// for now its only window + /// for now its only window. It asks the resolver the frame kept, so a + /// meta-schema that has gone out of reach since throws + /// sourcemeta::core::SchemaResolutionError here rather than at construction. + /// For example: + /// + /// ```cpp + /// #include + /// #include + /// #include + /// + /// const auto document{sourcemeta::core::parse_json(R"({ + /// "openapi": "3.1.1", + /// "info": { "title": "Example", "version": "1.0.0" }, + /// "paths": {} + /// })")}; + /// + /// const sourcemeta::core::OpenAPIFrame frame{ + /// document, sourcemeta::core::schema_walker, + /// sourcemeta::core::schema_resolver}; + /// + /// assert(frame.to_json().at("version").to_string() == "3.1"); + /// ``` [[nodiscard]] auto to_json() const -> JSON; private: @@ -397,10 +447,11 @@ struct OpenAPIBundleOptions { /// resolver are what reading inside a Schema Object takes, and the OpenAPI /// resolver is how the rest of the description is reached. No document the /// description spans may declare a revision of the OpenAPI Specification other -/// than the one the entry document declares, as what this produces is one -/// document that declares one, and a revision neither holds every field of -/// another nor reads what they share by the same rules. This overload mutates -/// the input document. For example: +/// than the one the entry document declares. The specification does not ask +/// for that. It is a choice this makes, as what this produces is one document +/// that declares one revision, and there is none to pick that can express both +/// what one revision holds and what another does. This overload mutates the +/// input document. For example: /// /// ```cpp /// #include @@ -451,7 +502,8 @@ auto openapi_bundle(JSON &document, const SchemaWalker &walker, /// Bundle an OpenAPI Description by embedding everything it references from /// another document into its own Components Object. No document the description /// spans may declare a revision of the OpenAPI Specification other than the one -/// the entry document declares. This overload returns a new document, without +/// the entry document declares, which is a choice this makes rather than one +/// the specification asks for. This overload returns a new document, without /// mutating the input. For example: /// /// ```cpp diff --git a/vendor/core/src/core/openapi/parameter.h b/vendor/core/src/core/openapi/parameter.h index 7b8c0dd34..db60f6dad 100644 --- a/vendor/core/src/core/openapi/parameter.h +++ b/vendor/core/src/core/openapi/parameter.h @@ -43,7 +43,7 @@ constexpr std::array OPENAPI_PARAMETER_SCHEMA_FIELDS{ {"name"sv, "in"sv, "description"sv, "required"sv, "deprecated"sv, "schema"sv, "style"sv, "explode"sv, "example"sv, "examples"sv}}; -// Section 4.12, of `allowReserved`: "This field only applies to `in` and +// 3.2.1 Section 4.12, of `allowReserved`: "This field only applies to `in` and // `style` values that automatically percent-encode (that is: `in: path`, // `in: query`, and `in: cookie` with `style: form`)". 3.1 held the same field // to `query` alone @@ -69,10 +69,31 @@ constexpr std::array {"name"sv, "in"sv, "description"sv, "required"sv, "deprecated"sv, "content"sv, "allowEmptyValue"sv, "example"sv, "examples"sv}}; -// Section 4.12 scopes `allowReserved` to the locations that percent-encode of -// their own accord. A query parameter has a field table of its own, so what is -// asked here is whether one of the other two locations is such a place, and a -// cookie parameter takes the `form` style when it declares none +// Section 4.8.12.2.1 holds `allowEmptyValue` among the fields that "MAY be used +// with either `content` or `schema`" and restricts it in its own Description +// cell alone: "This field is valid only for `query` parameters". So a +// parameter elsewhere writing it writes a field this Object defines rather +// than one it does not, which is a different thing to be turned down for +constexpr std::array + OPENAPI_PARAMETER_INAPPLICABLE_COMMON_FIELDS{{"allowEmptyValue"sv}}; + +// 3.2.1 Section 4.12.2.2 holds `allowReserved` among the fields for use with +// `schema` and restricts it the same way: "This field only applies to `in` +// and `style` values that automatically percent-encode". 3.1 restricts the +// same field to `query` alone. A `content` form reaches neither field table, +// so this stands for the `schema` form where both fields are written down +constexpr std::array + OPENAPI_PARAMETER_INAPPLICABLE_SCHEMA_FIELDS{ + {"allowEmptyValue"sv, "allowReserved"sv}}; + +constexpr auto OPENAPI_PARAMETER_INAPPLICABLE_MESSAGE{ + "The Parameter Object does not admit this field as declared"}; + +// 3.2.1 Section 4.12 scopes `allowReserved` to the locations that +// percent-encode of their own accord. A query parameter has a field table of +// its own, so what is asked here is whether one of the other two locations is +// such a place, and a cookie parameter takes the `form` style when it declares +// none inline auto openapi_parameter_admits_reserved(const JSON::StringView location, const JSON &value) -> bool { if (location == "path"sv) { @@ -144,8 +165,8 @@ inline auto openapi_check_parameter(const JSON &value, const Pointer &base, base, "The Parameter Object must declare a schema or a content"}; } - // Section 4.12, of `querystring`: its value "MUST be specified using the - // `content` field" + // 3.2.1 Section 4.12, of `querystring`: its value "MUST be specified using + // the `content` field" if (parameter_location == "querystring"sv && content == nullptr) { throw OpenAPIError{base, "A querystring Parameter Object must declare a content"}; @@ -162,7 +183,9 @@ inline auto openapi_check_parameter(const JSON &value, const Pointer &base, openapi_reject_unknown_fields( value, OPENAPI_PARAMETER_CONTENT_FIELDS_3_1, OPENAPI_PARAMETER_CONTENT_FIELDS_3_2, base, - "The Parameter Object does not define this field", walk); + "The Parameter Object does not define this field", walk, + OPENAPI_PARAMETER_INAPPLICABLE_COMMON_FIELDS, + OPENAPI_PARAMETER_INAPPLICABLE_MESSAGE); } } else if (in_query) { openapi_reject_unknown_fields( @@ -172,11 +195,15 @@ inline auto openapi_check_parameter(const JSON &value, const Pointer &base, openapi_parameter_admits_reserved(parameter_location, value)) { openapi_reject_unknown_fields( value, OPENAPI_PARAMETER_RESERVED_SCHEMA_FIELDS_3_2, base, - "The Parameter Object does not define this field"); + "The Parameter Object does not define this field", + OPENAPI_PARAMETER_INAPPLICABLE_COMMON_FIELDS, + OPENAPI_PARAMETER_INAPPLICABLE_MESSAGE); } else { openapi_reject_unknown_fields( value, OPENAPI_PARAMETER_SCHEMA_FIELDS, base, - "The Parameter Object does not define this field"); + "The Parameter Object does not define this field", + OPENAPI_PARAMETER_INAPPLICABLE_SCHEMA_FIELDS, + OPENAPI_PARAMETER_INAPPLICABLE_MESSAGE); } openapi_check_optional_string( @@ -208,12 +235,20 @@ inline auto openapi_check_parameter(const JSON &value, const Pointer &base, "A path Parameter Object must be required"}; } - // OpenAPI Specification 3.1.1, Section 4.8.12: "If `in` is `"path"`, the - // `name` field MUST correspond to a template expression occurring within - // the path field in the Paths Object", and a template expression is - // delimited by braces, so its name can hold neither - if (parameter_name.find('{') != JSON::StringView::npos || - parameter_name.find('}') != JSON::StringView::npos) { + // OpenAPI Specification 3.2.1, Section 4.12.2.1 has such a name "correspond + // to a single template expression occurring within the path field in the + // Paths Object", and 3.2.1 Section 4.8.2 writes out what one may hold: + // "template-expression-param-name = 1*( %x00-7A / %x7C / %x7E-10FFFF ) ; + // every Unicode character except { and }". No revision of 3.1 carries that + // grammar, and all 3.1.1 Section 3.5 says of the shape is "template + // expressions, delimited by curly braces", which leaves a brace within a + // name to whatever reads the path. Holding a 3.1 document to this would + // also leave it nothing to declare, as the expression of `/a/{x{y}` reads + // as `x{y` there and the correspondence below would then ask for the very + // name this turns down + if (walk.version == OpenAPIVersion::OPENAPI_3_2 && + (parameter_name.find('{') != JSON::StringView::npos || + parameter_name.find('}') != JSON::StringView::npos)) { throw OpenAPIError{openapi_child(base, "name"sv), "A path Parameter Object name must not hold braces"}; } @@ -257,8 +292,16 @@ inline auto openapi_check_parameter(const JSON &value, const Pointer &base, openapi_expect_schema(*schema, openapi_child(base, "schema"sv), "A Schema Object must be an object or a boolean", walk); - // OpenAPI Specification 3.1.1, Section 4.8.12 lists the styles each location - // admits, and the meta-schema enumerates them per location + // Section 4.8.12.3 gives the styles an `in` column, and both revisions fill + // it with the same four locations. 3.2.1 Section 4.12.3 goes on to close the + // table, "Combinations not represented in this table are not permitted", + // which 3.1 does not say. It does not need to: the column is the table, and + // Section 4.8.21 reads it as binding where it derives a Header Object's own + // restriction from it, "All traits that are affected by the location MUST be + // applicable to a location of `header` (for example, `style`) ... and + // `style`, if used, MUST be limited to `"simple"`". So each location admits + // what its column says in both revisions, and only the `cookie` style 3.2 + // adds is held back const auto *style{value.try_at("style", OPENAPI_HASH_STYLE)}; if (style != nullptr) { if (parameter_location == "path"sv) { @@ -278,10 +321,9 @@ inline auto openapi_check_parameter(const JSON &value, const Pointer &base, "The Parameter Object style must be a string", "The Parameter Object style is not one a query parameter admits"); } else if (walk.version == OpenAPIVersion::OPENAPI_3_2) { - // OpenAPI Specification 3.2.1, Section 4.12.3 adds a `cookie` style, - // "analogous to `form`, but following RFC6265 `Cookie` syntax rules", - // and the same table now states that "combinations not represented in - // this table are not permitted" + // 3.2.1 Section 4.12.3 adds a `cookie` style, "Analogous to `form`, but + // following [RFC6265] `Cookie` syntax rules", which no revision of 3.1 + // carries openapi_expect_enumeration( *style, base, "style"sv, {"form"sv, "cookie"sv}, "The Parameter Object style must be a string", @@ -344,9 +386,9 @@ inline auto openapi_check_parameters(const JSON &value, const Pointer &base, // These own their strings, as an identity read back through a reference // borrows from a map the walk keeps writing to std::set> seen; - // Section 4.12, of `querystring`: it "MUST NOT appear more than once, and - // MUST NOT appear in the same operation (or in the operation's path-item) as - // any `in: "query"` parameters". Both halves hold of a single list on its + // 3.2.1 Section 4.12, of `querystring`: it "MUST NOT appear more than once, + // and MUST NOT appear in the same operation (or in the operation's path-item) + // as any `in: "query"` parameters". Both halves hold of a single list on its // own, and what the two levels come to between them is settled where they // meet std::size_t querystrings{0}; diff --git a/vendor/core/src/core/openapi/paths.h b/vendor/core/src/core/openapi/paths.h index 3623598da..bb9af1c90 100644 --- a/vendor/core/src/core/openapi/paths.h +++ b/vendor/core/src/core/openapi/paths.h @@ -138,7 +138,15 @@ inline auto openapi_check_paths(const JSON &document, OpenAPIWalk &walk) // writes out a grammar and forbids repeating an expression: "Each // template expression MUST NOT appear more than once in a single path // template". Both are new in 3.2, so a path 3.1 accepts is still accepted - // when a document declares 3.1 + // when a document declares 3.1. + // + // The grammar is introduced as a definition, and neither of the two + // requirements beside it speaks of the key's shape, so what licenses + // turning a key down for its shape is the same clause the Server Object + // goes by: 3.2.1 Section 4.12.4, "All API URLs MUST successfully parse and + // percent-decode using [RFC3986] rules", a path being appended to a server + // URL to make one. The grammar is the whole of what the specification says + // a path template is, and it is read for nothing but the shape if (walk.version == OpenAPIVersion::OPENAPI_3_2) { if (!openapi_is_path_template(entry.first)) { throw OpenAPIError{location, diff --git a/vendor/core/src/core/openapi/response.h b/vendor/core/src/core/openapi/response.h index 32c892ac0..a6c704008 100644 --- a/vendor/core/src/core/openapi/response.h +++ b/vendor/core/src/core/openapi/response.h @@ -106,13 +106,19 @@ inline auto openapi_check_responses(const JSON &value, const Pointer &base, openapi_record(walk, base, OpenAPIObjectKind::Responses); openapi_expect_object(value, base, "The Responses Object must be an object"); - // The meta-schema bounds this map from below, and requires a `default` when - // no status code is named + // Section 4.8.16: "The Responses Object MUST contain at least one response + // code". Neither revision says which keys count as one, and the OpenAPI + // Initiative settled that twice over: the published meta-schema carries + // "either default, or at least one response code property must exist", and + // their own conformance corpus files a Responses Object holding nothing but + // a `default` under the passing cases of both revisions. So a `default` + // answers for a response here, and only an Object answering for none at all + // is turned down if (value.empty()) { throw OpenAPIError{base, "The Responses Object must not be empty"}; } - bool names_a_status_code{false}; + bool answers_for_a_response{false}; for (const auto &entry : value.as_object()) { if (entry.first.starts_with(OPENAPI_EXTENSION_PREFIX)) { continue; @@ -120,6 +126,7 @@ inline auto openapi_check_responses(const JSON &value, const Pointer &base, const auto location{openapi_child(base, entry.first)}; if (entry.first == "default"sv) { + answers_for_a_response = true; openapi_check_response_or_reference(entry.second, location, walk); continue; } @@ -130,14 +137,13 @@ inline auto openapi_check_responses(const JSON &value, const Pointer &base, "code or a status code range"}; } - names_a_status_code = true; + answers_for_a_response = true; openapi_check_response_or_reference(entry.second, location, walk); } - if (!names_a_status_code && - value.try_at("default", OPENAPI_HASH_DEFAULT) == nullptr) { + if (!answers_for_a_response) { throw OpenAPIError{ - base, "The Responses Object must declare a default or a status code"}; + base, "The Responses Object must declare a status code or a default"}; } } diff --git a/vendor/core/src/core/openapi/security.h b/vendor/core/src/core/openapi/security.h index bd44be9f9..a0cd59962 100644 --- a/vendor/core/src/core/openapi/security.h +++ b/vendor/core/src/core/openapi/security.h @@ -191,7 +191,7 @@ inline auto openapi_check_oauth_flow(const JSON &value, const Pointer &base, } // OpenAPI Specification 3.1.1, Section 4.8.29: "scopes | Map[string, string] - // | REQUIRED. The available scopes for the OAuth2 security scheme" + // | oauth2 | REQUIRED. The available scopes for the OAuth2 security scheme." const auto &scopes{ openapi_require(value, "scopes"sv, OPENAPI_HASH_SCOPES, base, "The OAuth Flow Object must declare its scopes")}; @@ -233,8 +233,8 @@ inline auto openapi_check_oauth_flows(const JSON &value, const Pointer &base, true, false, walk); } - // Section 4.28 adds the device authorization flow, whose required URLs are - // its own and the token URL + // 3.2.1 Section 4.28 adds the device authorization flow, whose required URLs + // are its own and the token URL const auto *device{ value.try_at("deviceAuthorization"sv, OPENAPI_HASH_DEVICE_AUTHORIZATION)}; if (device != nullptr) { @@ -355,7 +355,7 @@ inline auto openapi_check_security_scheme(const JSON &value, OPENAPI_SECURITY_SCHEME_OAUTH2_FIELDS_3_2, base, "The Security Scheme Object does not define this field", walk); - // Section 4.27: "oauth2MetadataUrl | string | oauth2" + // 3.2.1 Section 4.27: "oauth2MetadataUrl | string | oauth2" const auto *metadata{ value.try_at("oauth2MetadataUrl", OPENAPI_HASH_OAUTH2_METADATA_URL)}; if (metadata != nullptr) { diff --git a/vendor/core/src/core/openapi/server.h b/vendor/core/src/core/openapi/server.h index 3d065bb0e..1cd19fe59 100644 --- a/vendor/core/src/core/openapi/server.h +++ b/vendor/core/src/core/openapi/server.h @@ -149,8 +149,98 @@ inline auto openapi_check_server_variable(const JSON &value, // is, and a template that leaves that to a variable means no one thing that // resolving could preserve. That case is turned down rather than guessed at, // and is reported by handing back no value +// What a Server Object URL template names once every variable stands for what +// it is declared to. OpenAPI Specification 3.1.1, Section 4.8.6 makes a Server +// Variable Object's `default` "REQUIRED. The default value to use for +// substitution, which SHALL be sent if an alternate value is not supplied", so +// a template always names at least one concrete URL +inline auto openapi_substitute_server_variables(const JSON::StringView address, + const JSON &variables, + const JSON::StringView varied, + const JSON::StringView value) + -> std::optional { + JSON::String result; + JSON::StringView::size_type cursor{0}; + while (cursor < address.size()) { + const auto opening{address.find('{', cursor)}; + if (opening == JSON::StringView::npos) { + result.append(address.substr(cursor)); + break; + } + + const auto closing{address.find('}', opening)}; + if (closing == JSON::StringView::npos) { + return std::nullopt; + } + + result.append(address.substr(cursor, opening - cursor)); + const auto *variable{ + variables.try_at(address.substr(opening + 1, closing - opening - 1))}; + if (variable == nullptr || !variable->is_object()) { + return std::nullopt; + } + + const auto name{address.substr(opening + 1, closing - opening - 1)}; + if (!varied.empty() && name == varied) { + result.append(value); + cursor = closing + 1; + continue; + } + + const auto *fallback{variable->try_at("default")}; + if (fallback == nullptr || !fallback->is_string()) { + return std::nullopt; + } + + result.append(fallback->to_string()); + cursor = closing + 1; + } + + return result; +} + +// Whether a server URL template names an absolute URI whatever its variables +// stand for. Section 4.8.6 bounds that: a `default` is "REQUIRED. The default +// value to use for substitution, which SHALL be sent if an alternate value is +// not supplied", and where an `enum` is present "the value MUST exist in the +// enum's values", so between them they are the whole of what one may stand +// for. A single value that leaves the template relative leaves what it names +// for the document holding it to settle, which is the one thing moving it +// changes, so the default answering alone says too little +inline auto +openapi_is_absolute_server_url_template(const JSON::StringView address, + const JSON &variables) -> bool { + const auto baseline{ + openapi_substitute_server_variables(address, variables, {}, {})}; + if (!baseline.has_value() || !URI::is_uri(baseline.value())) { + return false; + } + + for (const auto &variable : variables.as_object()) { + const auto *choices{variable.second.try_at("enum")}; + if (choices == nullptr || !choices->is_array()) { + continue; + } + + for (const auto &choice : choices->as_array()) { + if (!choice.is_string()) { + return false; + } + + const auto candidate{openapi_substitute_server_variables( + address, variables, variable.first, choice.to_string())}; + if (!candidate.has_value() || !URI::is_uri(candidate.value())) { + return false; + } + } + } + + return true; +} + inline auto openapi_resolve_server_url(const JSON::StringView address, - const JSON::String &base) + const JSON::String &base, + const JSON *const variables) -> std::optional { const auto variable{address.find('{')}; // A template that declares no variable is a URI reference already @@ -165,8 +255,9 @@ inline auto openapi_resolve_server_url(const JSON::StringView address, const auto scheme{literals.find(':')}; const auto separator{literals.find('/')}; - // Section 5.2.2 resolves a reference that declares a scheme to itself, so - // whatever a variable stands for past that point cannot change the result + // RFC 3986 Section 5.2.2 resolves a reference that declares a scheme to + // itself, so whatever a variable stands for past that point cannot change the + // result if (scheme != JSON::StringView::npos && (separator == JSON::StringView::npos || scheme < separator)) { return JSON::String{address}; @@ -174,14 +265,25 @@ inline auto openapi_resolve_server_url(const JSON::StringView address, // Until a slash settles it, a variable may still stand for a colon and make // what comes before it a scheme, which leaves the kind of reference this is - // for the values of the variables to decide rather than the template + // for the values of the variables to decide rather than the template. They + // do decide it, as every one of them declares what it stands for by default, + // and a template that names an absolute URI that way names the same place + // wherever the Object holding it comes to sit if (separator == JSON::StringView::npos) { - return std::nullopt; + if (variables == nullptr || !variables->is_object()) { + return std::nullopt; + } + + if (!openapi_is_absolute_server_url_template(address, *variables)) { + return std::nullopt; + } + + return JSON::String{address}; } - // Section 5.2.4 removes dot segments from the path that merging produces, - // which is an operation on whole segments. Splitting at a segment boundary - // is what keeps it from reaching into what a variable stands for + // RFC 3986 Section 5.2.4 removes dot segments from the path that merging + // produces, which is an operation on whole segments. Splitting at a segment + // boundary is what keeps it from reaching into what a variable stands for const auto boundary{literals.find_last_of('/') + 1}; const auto resolved{openapi_resolve_uri(address.substr(0, boundary), base)}; if (!resolved.has_value()) { @@ -275,7 +377,21 @@ inline auto openapi_check_server(const JSON &value, const Pointer &base, // of 3.2 writes out a grammar for it and forbids repeating a variable: // "Each server variable MUST NOT appear more than once in the URL template". // Both are new in 3.2, so a URL 3.1 accepts is still accepted when a - // document declares 3.1 + // document declares 3.1. + // + // The grammar is introduced as a definition rather than as a requirement, so + // what licenses turning a URL down for its shape is 3.2.1 Section 4.12.4, + // "All API URLs MUST successfully parse and percent-decode using [RFC3986] + // rules", together with 3.2.1 Section 4 making the text "the only normative + // description of the format". The grammar is the whole of what either + // revision says a server URL template is, and nothing beyond its shape is + // read from it. + // + // That MUST is also 3.1.2 Section 4.8.12.4, which by Section 4.1 reaches + // every 3.1 document, so holding this to 3.2 alone leaves a 3.1 URL that no + // amount of parsing can rescue unreported. That is under-reporting rather + // than a wrong refusal, and closing it would turn down documents accepted + // until now, so it waits for a release that can carry it if (walk.version == OpenAPIVersion::OPENAPI_3_2) { if (!openapi_is_server_url_template(address)) { throw OpenAPIError{openapi_child(base, "url"sv), diff --git a/vendor/core/src/core/openapi/tag.h b/vendor/core/src/core/openapi/tag.h index 24895165e..b5240eb6e 100644 --- a/vendor/core/src/core/openapi/tag.h +++ b/vendor/core/src/core/openapi/tag.h @@ -64,7 +64,7 @@ inline auto openapi_check_tag(const JSON &value, const Pointer &base, openapi_check_optional_string(value, base, "kind"sv, OPENAPI_HASH_KIND, "The Tag Object kind must be a string"); - // Section 4.22: "parent | string | The `name` of a tag that this tag is + // 3.2.1 Section 4.22: "parent | string | The `name` of a tag that this tag is // nested under. The named tag MUST exist in the API description, and // circular references between parent and child tags MUST NOT be used". // Neither of those can be settled until every tag has been read diff --git a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h index e9b614bb6..8274b590d 100644 --- a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h +++ b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml.h @@ -12,8 +12,10 @@ #include // NOLINTEND(misc-include-cleaner) +#include // std::size_t #include // std::filesystem #include // std::basic_istream +#include // std::optional, std::nullopt #include // std::basic_ostream /// @defgroup yaml YAML @@ -170,6 +172,31 @@ auto read_yaml_or_json(const std::filesystem::path &path, JSON &output, SOURCEMETA_CORE_YAML_EXPORT auto parse_yaml(const JSON::String &input, YAMLRoundTrip &roundtrip) -> JSON; +/// @ingroup yaml +/// +/// Create a JSON document from a C++ standard input stream that represents a +/// YAML document, collecting round-trip metadata to reproduce the original +/// formatting. The stream is left just after the document that was read, so +/// that a stream holding several documents can be read one document at a +/// time. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// #include +/// +/// std::istringstream stream{"hello: world\n---\nsecond: document\n"}; +/// while (stream.peek() != std::char_traits::eof()) { +/// sourcemeta::core::YAMLRoundTrip roundtrip; +/// const sourcemeta::core::JSON document = +/// sourcemeta::core::parse_yaml(stream, roundtrip); +/// } +/// ``` +SOURCEMETA_CORE_YAML_EXPORT +auto parse_yaml(std::basic_istream &stream, + YAMLRoundTrip &roundtrip) -> JSON; + /// @ingroup yaml /// /// Parse a YAML string with round-trip metadata into an existing JSON value, @@ -181,6 +208,57 @@ SOURCEMETA_CORE_YAML_EXPORT auto parse_yaml(const JSON::String &input, YAMLRoundTrip &roundtrip, JSON &output, const JSON::ParseCallback &callback) -> void; +/// @ingroup yaml +/// +/// Parse a YAML document from a C++ standard input stream with round-trip +/// metadata into an existing JSON value, invoking the given callback during +/// parsing. The stream is left just after the document that was read, so that +/// a stream holding several documents can be read one document at a time. The +/// result is constructed directly into the given reference rather than +/// returned by value to ensure that references passed through the parse +/// callback remain valid after parsing completes. +SOURCEMETA_CORE_YAML_EXPORT +auto parse_yaml(std::basic_istream &stream, + YAMLRoundTrip &roundtrip, JSON &output, + const JSON::ParseCallback &callback) -> void; + +/// @ingroup yaml +/// +/// Read a JSON document from a file location that represents a YAML file, +/// collecting round-trip metadata to reproduce the original formatting. Unlike +/// the stream overload, the file must hold a single document, as a file that +/// carries more cannot be written back from one set of metadata. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// #include +/// +/// sourcemeta::core::YAMLRoundTrip roundtrip; +/// const sourcemeta::core::JSON document = +/// sourcemeta::core::read_yaml("test.yaml", roundtrip); +/// sourcemeta::core::stringify_yaml(document, std::cout, roundtrip); +/// ``` +/// +/// If parsing fails, sourcemeta::core::YAMLFileParseError will be thrown. +SOURCEMETA_CORE_YAML_EXPORT +auto read_yaml(const std::filesystem::path &path, YAMLRoundTrip &roundtrip) + -> JSON; + +/// @ingroup yaml +/// +/// Read a YAML file with round-trip metadata into an existing JSON value, +/// invoking the given callback during parsing. The file must hold a single +/// document. The result is constructed directly into the given reference +/// rather than returned by value to ensure that references passed through the +/// parse callback remain valid after parsing completes. +/// +/// If parsing fails, sourcemeta::core::YAMLFileParseError will be thrown. +SOURCEMETA_CORE_YAML_EXPORT +auto read_yaml(const std::filesystem::path &path, YAMLRoundTrip &roundtrip, + JSON &output, const JSON::ParseCallback &callback) -> void; + /// @ingroup yaml /// /// Stringify a JSON document as YAML, using round-trip metadata collected @@ -201,14 +279,23 @@ auto parse_yaml(const JSON::String &input, YAMLRoundTrip &roundtrip, /// sourcemeta::core::parse_yaml(input, roundtrip); /// sourcemeta::core::stringify_yaml(document, std::cout, roundtrip); /// ``` +/// +/// Each level of nesting is laid out with the width the document was written +/// with, unless an indentation is given, which overrides it. A width of zero +/// would run a nested collection into the one that holds it, so it is treated +/// as one. SOURCEMETA_CORE_YAML_EXPORT auto stringify_yaml(const JSON &document, std::basic_ostream &stream, - const YAMLRoundTrip &roundtrip) -> void; + const YAMLRoundTrip &roundtrip, + const std::optional indentation = std::nullopt) + -> void; /// @ingroup yaml /// -/// Stringify a JSON document as YAML. For example: +/// Stringify a JSON document as YAML, laying out each level of nesting with +/// the given number of spaces. A width of zero would run a nested collection +/// into the one that holds it, so it is treated as one. For example: /// /// ```cpp /// #include @@ -222,8 +309,8 @@ auto stringify_yaml(const JSON &document, /// ``` SOURCEMETA_CORE_YAML_EXPORT auto stringify_yaml(const JSON &document, - std::basic_ostream &stream) - -> void; + std::basic_ostream &stream, + const std::size_t indentation = 2) -> void; } // namespace sourcemeta::core diff --git a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml_roundtrip.h b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml_roundtrip.h index 9a655dfa1..874288dd8 100644 --- a/vendor/core/src/core/yaml/include/sourcemeta/core/yaml_roundtrip.h +++ b/vendor/core/src/core/yaml/include/sourcemeta/core/yaml_roundtrip.h @@ -84,6 +84,13 @@ class SOURCEMETA_CORE_YAML_EXPORT YAMLRoundTrip { std::optional content_value; /// The anchor name attached to the node std::optional anchor; + /// The tag attached to the node, as it was written + std::optional tag; + /// The type the tagged node held when the tag was recorded, so that a tag + /// is only reproduced while the document still holds that kind of value + std::optional tag_type; + /// Whether the tag precedes the anchor + bool tag_before_anchor{false}; /// The comments preceding the node std::vector comments_before; /// The comment on the same line as the node @@ -92,6 +99,16 @@ class SOURCEMETA_CORE_YAML_EXPORT YAMLRoundTrip { std::optional comment_on_indicator; /// Whether the flow collection uses compact formatting bool compact_flow{false}; + /// Whether the flow collection is padded with a space inside its delimiters + bool padded_flow{false}; + /// Whether a block sequence that is the value of a mapping key sits at the + /// same indentation as that key rather than one level in. + /// See https://yaml.org/spec/1.2.2/#821-block-sequences + bool unindented_sequence{false}; + /// How many items the sequence held when it was read, so that comments and + /// node properties recorded against a position are only reproduced while + /// that position still holds the item they were read from + std::optional sequence_size; }; /// The recorded formatting for each node by pointer @@ -102,6 +119,11 @@ class SOURCEMETA_CORE_YAML_EXPORT YAMLRoundTrip { std::unordered_map key_styles; /// The original quoted content for each mapping key std::unordered_map key_quoted_contents; + /// The directive and comment lines that precede the document start marker, + /// in the order they were written. A document that carries a directive + /// always begins with an explicit start marker. + /// See https://yaml.org/spec/1.2.2/#912-document-markers + std::vector document_prefix; /// Whether the document begins with an explicit start marker bool explicit_document_start{false}; /// Whether the document ends with an explicit end marker @@ -120,6 +142,11 @@ class SOURCEMETA_CORE_YAML_EXPORT YAMLRoundTrip { std::vector trailing_comments; /// The indentation width used when emitting the document std::size_t indent_width{2}; + /// Whether the document begins with a byte order mark + bool byte_order_mark{false}; + /// Whether the document separates its lines with a carriage return and a + /// line feed rather than a line feed alone + bool carriage_returns{false}; }; #if defined(_MSC_VER) diff --git a/vendor/core/src/core/yaml/lexer.h b/vendor/core/src/core/yaml/lexer.h index 049440ff7..f42fbafe6 100644 --- a/vendor/core/src/core/yaml/lexer.h +++ b/vendor/core/src/core/yaml/lexer.h @@ -74,6 +74,14 @@ class Lexer { this->validate_characters(); } + // A carriage return is a line break rather than comment content, so it never + // belongs to the text of a comment that a carriage return ends. + // See https://yaml.org/spec/1.2.2/#66-comments + static auto comment_text(const std::string_view raw) -> std::string { + return std::string{raw.ends_with('\r') ? raw.substr(0, raw.size() - 1) + : raw}; + } + // The number of leading bytes consumed by a stripped byte order mark, so a // caller reading from a stream can map a consumed count back to the original // input offset @@ -344,6 +352,26 @@ class Lexer { return this->position_; } + // The line break that closes a document suffix belongs to the document that + // is ending rather than to whatever follows it. + // See https://yaml.org/spec/1.2.2/#912-document-markers + auto skip_line_break() -> void { + if (this->position_ >= this->input_.size()) { + return; + } + + const auto current{this->input_[this->position_]}; + if (current != '\n' && current != '\r') { + return; + } + + this->advance(1); + if (current == '\r' && this->position_ < this->input_.size() && + this->input_[this->position_] == '\n') { + this->advance(1); + } + } + auto take_inline_comment() -> std::optional { auto result{std::move(this->inline_comment_buffer_)}; this->inline_comment_buffer_.reset(); @@ -443,7 +471,13 @@ class Lexer { if (this->position_ >= this->input_.size()) { break; } - if (this->input_[this->position_] == '\n') { + // A lone carriage return is a line break of its own, while the one that + // opens a carriage return and line feed pair leaves the counting to the + // line feed. See https://yaml.org/spec/1.2.2/#54-line-break-characters + const auto character{this->input_[this->position_]}; + if (character == '\n' || + (character == '\r' && (this->position_ + 1 >= this->input_.size() || + this->input_[this->position_ + 1] != '\n'))) { this->line_++; this->column_ = 1; } else { @@ -537,8 +571,8 @@ class Lexer { this->advance(1); } if (this->roundtrip_) { - std::string text{this->input_.substr( - comment_start, this->position_ - comment_start)}; + std::string text{comment_text(this->input_.substr( + comment_start, this->position_ - comment_start))}; if (comment_line == this->comment_reference_line_ && this->comment_reference_line_ > 0 && !this->inline_comment_buffer_.has_value()) { @@ -1090,59 +1124,20 @@ class Lexer { return codepoint_to_utf8(character); } - [[nodiscard]] auto calculate_parent_indentation( - const std::size_t indicator_position) const noexcept -> std::size_t { - std::size_t line_start{indicator_position}; - while (line_start > 0 && this->input_[line_start - 1] != '\n' && - this->input_[line_start - 1] != '\r') { - line_start--; - } - - std::size_t leading_spaces{0}; - std::size_t scan_position{line_start}; - while (scan_position < this->input_.size() && - this->input_[scan_position] == ' ') { - leading_spaces++; - scan_position++; - } - - bool in_sequence_entry{false}; - if (scan_position < this->input_.size() - 1 && - this->input_[scan_position] == '-' && - this->input_[scan_position + 1] == ' ') { - in_sequence_entry = true; - } - - bool is_mapping_value_same_line{false}; - for (std::size_t index = line_start; index < indicator_position; ++index) { - if (this->input_[index] == ':') { - is_mapping_value_same_line = true; - break; - } - } - - if (in_sequence_entry && is_mapping_value_same_line) { - return leading_spaces + 2; - } - - if (is_mapping_value_same_line) { - return leading_spaces; - } - - return 0; - } - auto detect_block_scalar_indent(const std::size_t explicit_indent, - const std::size_t indicator_position, const std::uint64_t start_line, const std::uint64_t start_column) -> std::size_t { std::size_t content_indent{0}; if (explicit_indent > 0) { - const auto parent_indent{ - this->calculate_parent_indentation(indicator_position)}; - content_indent = parent_indent + explicit_indent; + // The content indentation level of a block scalar is the indentation + // level of the node itself plus the indicator, and the node at the + // document root sits one level further out than the leftmost column. + // See https://yaml.org/spec/1.2.2/#8111-block-indentation-indicator + content_indent = this->block_indent_ == SIZE_MAX + ? explicit_indent - 1 + : this->block_indent_ + explicit_indent; } else { const auto saved_position{this->position_}; const auto saved_line{this->line_}; @@ -1196,7 +1191,6 @@ class Lexer { auto scan_block_scalar(const ScalarStyle style) -> Token { const auto start_line{this->line_}; const auto start_column{this->column_}; - const auto indicator_position{this->position_}; this->advance(1); @@ -1228,8 +1222,8 @@ class Lexer { this->advance(1); } if (this->roundtrip_) { - this->block_scalar_comment_ = std::string{this->input_.substr( - comment_start, this->position_ - comment_start)}; + this->block_scalar_comment_ = comment_text(this->input_.substr( + comment_start, this->position_ - comment_start)); } } else if (current == '\n' || current == '\r') { break; @@ -1260,7 +1254,7 @@ class Lexer { } const auto content_indent{this->detect_block_scalar_indent( - explicit_indent, indicator_position, start_line, start_column)}; + explicit_indent, start_line, start_column)}; std::size_t blank_line_count{0}; bool previous_was_more_indented{false}; diff --git a/vendor/core/src/core/yaml/parser.h b/vendor/core/src/core/yaml/parser.h index ffef25ffe..2894fc1da 100644 --- a/vendor/core/src/core/yaml/parser.h +++ b/vendor/core/src/core/yaml/parser.h @@ -9,7 +9,7 @@ #include #include -#include // std::max +#include // std::max, std::find_if #include // assert #include // std::uint64_t, std::int64_t #include // std::optional @@ -46,6 +46,7 @@ class Parser { : lexer_{lexer}, callback_{callback}, roundtrip_{roundtrip} {} auto parse() -> JSON { + bool empty_document{false}; std::optional token; if (!this->pending_tokens_.empty()) { @@ -72,7 +73,7 @@ class Parser { if (token->type == TokenType::DirectiveYAML || token->type == TokenType::DirectiveTag || token->type == TokenType::DirectiveReserved) { - this->process_directives(token.value()); + this->process_directives(token.value(), true); } if (token->type == TokenType::DocumentStart) { @@ -82,6 +83,7 @@ class Parser { this->roundtrip_->explicit_document_start = true; } this->document_start_line_ = token->line; + this->lexer_->skip_line_break(); const auto pos_before_next{this->lexer_->position()}; token = this->lexer_->next(); if (this->roundtrip_ != nullptr) { @@ -95,8 +97,17 @@ class Parser { if (token.has_value() && token->type == TokenType::DocumentStart) { this->pending_tokens_.push_back(token.value()); this->pending_token_position_ = pos_before_next; + return JSON{nullptr}; } - return JSON{nullptr}; + + if (this->roundtrip_ != nullptr) { + this->roundtrip_->post_start_comments = + this->lexer_->take_preceding_comments(); + } + + // A document with no node of its own still ends the way any other + // does, so what follows it is read the same way + empty_document = true; } } else if (!token.has_value() || token->type == TokenType::StreamEnd) [[unlikely]] { @@ -114,26 +125,33 @@ class Parser { return JSON{nullptr}; } - if (this->roundtrip_ != nullptr) { - auto comments{this->lexer_->take_preceding_comments()}; - this->lexer_->take_inline_comment(); - if (this->roundtrip_->explicit_document_start) { - this->roundtrip_->post_start_comments = std::move(comments); - } else { - this->roundtrip_->leading_comments = std::move(comments); + JSON result{nullptr}; + auto pos_before_token{this->lexer_->position()}; + + if (!empty_document) { + if (this->roundtrip_ != nullptr) { + auto comments{this->lexer_->take_preceding_comments()}; + this->lexer_->take_inline_comment(); + if (this->roundtrip_->explicit_document_start) { + this->roundtrip_->post_start_comments = std::move(comments); + } else { + this->roundtrip_->leading_comments = std::move(comments); + } } - } - auto result{this->parse_value(token.value(), JSON::ParseContext::Root, 0, - EMPTY_PROPERTY)}; + result = this->parse_value(token.value(), JSON::ParseContext::Root, 0, + EMPTY_PROPERTY); - auto pos_before_token{this->lexer_->position()}; - token = this->next_token(); - if (this->roundtrip_ != nullptr) { - auto root_inline{this->lexer_->take_inline_comment()}; - if (root_inline.has_value()) { - this->roundtrip_->styles[this->pointer_stack_].comment_inline = - std::move(root_inline); + this->attach_leading_comments_to_first_key(result); + + pos_before_token = this->lexer_->position(); + token = this->next_token(); + if (this->roundtrip_ != nullptr) { + auto root_inline{this->lexer_->take_inline_comment()}; + if (root_inline.has_value()) { + this->roundtrip_->styles[this->pointer_stack_].comment_inline = + std::move(root_inline); + } } } while (token.has_value() && token->type == TokenType::DocumentEnd) { @@ -143,6 +161,7 @@ class Parser { this->lexer_->take_preceding_comments(); this->roundtrip_->explicit_document_end = true; } + this->lexer_->skip_line_break(); pos_before_token = this->lexer_->position(); token = this->next_token(); if (this->roundtrip_ != nullptr) { @@ -153,7 +172,9 @@ class Parser { if (this->roundtrip_ != nullptr) { auto trailing{this->lexer_->take_preceding_comments()}; - if (!trailing.empty()) { + const bool ends_the_stream{!token.has_value() || + token->type == TokenType::StreamEnd}; + if (!trailing.empty() && ends_the_stream) { this->roundtrip_->trailing_comments = std::move(trailing); } } @@ -177,6 +198,20 @@ class Parser { return this->lexer_->position(); } + // Metadata collected for a round-trip describes the one document it was read + // from, so a stream that carries more than that cannot be written back + auto validate_single_document() -> void { + auto token{this->next_token()}; + while (token.has_value() && token->type == TokenType::DocumentEnd) { + token = this->next_token(); + } + + if (token.has_value() && token->type != TokenType::StreamEnd) [[unlikely]] { + throw YAMLParseError{token->line, token->column, + "Unexpected content after document"}; + } + } + auto validate_end_of_stream() -> void { auto token{this->next_token()}; // The preceding parse already consumed a document, so its end marker, if @@ -296,11 +331,59 @@ class Parser { return total; } - auto process_directives(Token &token) -> void { + // A comment block that runs straight into the first key of the document reads + // as belonging to that key, so it is recorded there and travels with the key + // if the document is later rearranged. A blank line in between instead marks + // the block as a header for the document as a whole + auto attach_leading_comments_to_first_key(const JSON &result) -> void { + if ((this->roundtrip_ == nullptr) || !result.is_object() || + result.empty()) { + return; + } + + auto &comments{this->roundtrip_->explicit_document_start + ? this->roundtrip_->post_start_comments + : this->roundtrip_->leading_comments}; + const auto blank{ + std::find_if(comments.crbegin(), comments.crend(), + [](const auto &comment) { return comment.empty(); })}; + if (blank == comments.crbegin()) { + return; + } + + const auto first{blank.base()}; + Pointer pointer{result.as_object().cbegin()->first}; + auto &attached{this->roundtrip_->styles[pointer].comments_before}; + attached.insert(attached.cbegin(), first, comments.cend()); + comments.erase(first, comments.cend()); + } + + // A flow collection may be written with a space just inside its delimiters, + // which the first token after the opening one gives away + auto record_flow_padding(const Token &start_token, + const std::optional &first) -> void { + if ((this->roundtrip_ == nullptr) || !first.has_value() || + first->line != start_token.line || + first->column <= start_token.column + 1) { + return; + } + + this->roundtrip_->styles[this->pointer_stack_].padded_flow = true; + } + + auto process_directives(Token &token, const bool record = false) -> void { bool seen_yaml_directive{false}; while (token.type == TokenType::DirectiveYAML || token.type == TokenType::DirectiveTag || token.type == TokenType::DirectiveReserved) { + if (record && this->roundtrip_ != nullptr) { + auto &prefix{this->roundtrip_->document_prefix}; + for (auto &comment : this->lexer_->take_preceding_comments()) { + prefix.push_back(std::move(comment)); + } + + prefix.emplace_back(token.value); + } if (token.type == TokenType::DirectiveYAML) { if (seen_yaml_directive) [[unlikely]] { throw YAMLParseError{token.line, token.column, @@ -535,6 +618,8 @@ class Parser { std::optional anchor_name; std::uint64_t anchor_line{0}; std::optional tag; + std::optional raw_tag; + bool tag_before_anchor{false}; std::size_t anchor_count{0}; std::optional anchor_inline_comment; Token current_token{token}; @@ -563,11 +648,24 @@ class Parser { anchor_count++; } else { tag = this->resolve_tag(current_token.value); + if (this->roundtrip_ != nullptr) { + raw_tag = std::string{current_token.value}; + tag_before_anchor = anchor_count == 0; + } } auto next{this->lexer_->next()}; if ((this->roundtrip_ != nullptr) && anchor_name.has_value()) { anchor_inline_comment = this->lexer_->take_inline_comment(); + } else if ((this->roundtrip_ != nullptr) && + context == JSON::ParseContext::Root && + this->document_start_line_ > 0 && + current_token.line == this->document_start_line_ && + !this->roundtrip_->document_start_comment.has_value()) { + // A comment that trails the node properties of the root node still + // sits on the document start marker line, which is where it is written + this->roundtrip_->document_start_comment = + this->lexer_->take_inline_comment(); } if (!next.has_value() || next->type == TokenType::StreamEnd || next->type == TokenType::DocumentEnd || @@ -588,6 +686,7 @@ class Parser { style.comment_inline = std::move(anchor_inline_comment); } } + this->record_tag(raw_tag, tag_before_anchor, empty_value); if ((this->roundtrip_ != nullptr) && context != JSON::ParseContext::Root) { this->pointer_stack_.pop_back(); @@ -602,23 +701,29 @@ class Parser { if (after.has_value() && after->type == TokenType::BlockMappingValue) { this->pending_tokens_.push_back(current_token); this->pending_tokens_.push_back(after.value()); + JSON empty_value{nullptr}; + if (tag.has_value() && tag.value() == "tag:yaml.org,2002:str") { + empty_value = JSON{std::string{}}; + } if (anchor_name.has_value()) { this->register_anchored_null(anchor_name.value(), token, context, index, property, anchor_inline_comment); } + this->record_tag(raw_tag, tag_before_anchor, empty_value); if ((this->roundtrip_ != nullptr) && context != JSON::ParseContext::Root) { this->pointer_stack_.pop_back(); } - return JSON{nullptr}; + return empty_value; } if (after.has_value()) { this->pending_tokens_.push_back(after.value()); } } - if (anchor_name.has_value() && context == JSON::ParseContext::Index && + if ((anchor_name.has_value() || tag.has_value()) && + context == JSON::ParseContext::Index && current_token.type == TokenType::BlockSequenceEntry) { const auto block_indent{this->lexer_->block_indent()}; const auto entry_indent{ @@ -627,13 +732,21 @@ class Parser { : 0UZ}; if (block_indent != SIZE_MAX && entry_indent <= block_indent) { this->pending_tokens_.push_back(current_token); - this->register_anchored_null(anchor_name.value(), token, context, - index, property, anchor_inline_comment); + JSON empty_value{nullptr}; + if (tag.has_value() && tag.value() == "tag:yaml.org,2002:str") { + empty_value = JSON{std::string{}}; + } + if (anchor_name.has_value()) { + this->register_anchored_null(anchor_name.value(), token, context, + index, property, + anchor_inline_comment); + } + this->record_tag(raw_tag, tag_before_anchor, empty_value); if ((this->roundtrip_ != nullptr) && context != JSON::ParseContext::Root) { this->pointer_stack_.pop_back(); } - return JSON{nullptr}; + return empty_value; } } } @@ -646,6 +759,7 @@ class Parser { empty_value = JSON{std::string{}}; } this->pending_tokens_.push_back(current_token); + this->record_tag(raw_tag, tag_before_anchor, empty_value); if ((this->roundtrip_ != nullptr) && context != JSON::ParseContext::Root) { this->pointer_stack_.pop_back(); @@ -812,6 +926,13 @@ class Parser { } } + this->record_tag(raw_tag, tag_before_anchor, result); + + if ((this->roundtrip_ != nullptr) && result.is_array()) { + this->roundtrip_->styles[this->pointer_stack_].sequence_size = + result.size(); + } + if ((this->roundtrip_ != nullptr) && context != JSON::ParseContext::Root) { this->pointer_stack_.pop_back(); } @@ -1090,6 +1211,7 @@ class Parser { bool found_compact_separator{false}; auto token{this->next_token()}; + this->record_flow_padding(start_token, token); while (token.has_value() && token->type != TokenType::MappingEnd) { if (token->type == TokenType::FlowEntry) { @@ -1224,6 +1346,7 @@ class Parser { bool found_compact_separator{false}; auto token{this->next_token()}; + this->record_flow_padding(start_token, token); std::size_t element_index{0}; while (token.has_value() && token->type != TokenType::SequenceEnd) { @@ -1345,6 +1468,11 @@ class Parser { const auto sequence_indent{ base_column > 0 ? static_cast(base_column - 1) : 0UZ}; this->detect_indent_width(key_column, base_column); + if ((this->roundtrip_ != nullptr) && + context == JSON::ParseContext::Property && key_column > 0 && + base_column == key_column) { + this->roundtrip_->styles[this->pointer_stack_].unindented_sequence = true; + } this->lexer_->set_block_indent(sequence_indent); this->record_preceding_comments_for_index(0); @@ -1699,6 +1827,37 @@ class Parser { return anchored.value; } + // A node that stands on a later line than the key it belongs to has to be + // indented past the mapping for that mapping to own it. A block sequence is + // the one exception, as it may sit at the very indentation of its key. + // See https://yaml.org/spec/1.2.2/#821-block-sequences + [[nodiscard]] auto starts_mapping_value(const Token &token, + const std::uint64_t key_line, + const std::uint64_t base_column) const + -> bool { + if (token.line == key_line) { + return true; + } + + return token.type == TokenType::BlockSequenceEntry + ? token.column >= base_column + : token.column > base_column; + } + + // A node property that decorates a block node rather than a key of the + // mapping it sits in has to be indented past that mapping, so one that opens + // a block sequence from the mapping's own indentation has nowhere to belong. + // See https://yaml.org/spec/1.2.2/#822-block-mappings + auto reject_misplaced_property(const Token &property, + const std::optional &node) const + -> void { + if (node.has_value() && node->type == TokenType::BlockSequenceEntry) + [[unlikely]] { + throw YAMLParseError{property.line, property.column, + "Node property at wrong indentation level"}; + } + } + auto next_token() -> std::optional { std::optional result; if (!this->pending_tokens_.empty()) { @@ -1754,7 +1913,7 @@ class Parser { next->type == TokenType::StreamEnd || next->type == TokenType::DocumentEnd) { if (next.has_value() && next->type == TokenType::Scalar && - (next->line == key_line || next->column != base_column)) { + this->starts_mapping_value(next.value(), key_line, base_column)) { this->record_inline_comment_for_key(key, next->line != key_line); auto value{this->parse_value(next.value(), JSON::ParseContext::Property, 0, key, key_line, key_column)}; @@ -1779,18 +1938,22 @@ class Parser { next->type == TokenType::BlockSequenceEntry || next->type == TokenType::Anchor || next->type == TokenType::Tag || next->type == TokenType::Alias) { - if (next->type == TokenType::BlockSequenceEntry && next->line == key_line) - [[unlikely]] { - throw YAMLParseError{ - next->line, next->column, - "Block sequence entry on same line as mapping key"}; - } - this->record_inline_comment_for_key(key, next->line != key_line); - auto value{this->parse_value(next.value(), JSON::ParseContext::Property, - 0, key, key_line, key_column)}; - result.assign(key, std::move(value)); - next = this->next_token(); - this->record_inline_comment_for_key(key); + if (!this->starts_mapping_value(next.value(), key_line, base_column)) { + result.assign(key, JSON{nullptr}); + } else { + if (next->type == TokenType::BlockSequenceEntry && + next->line == key_line) [[unlikely]] { + throw YAMLParseError{ + next->line, next->column, + "Block sequence entry on same line as mapping key"}; + } + this->record_inline_comment_for_key(key, next->line != key_line); + auto value{this->parse_value(next.value(), JSON::ParseContext::Property, + 0, key, key_line, key_column)}; + result.assign(key, std::move(value)); + next = this->next_token(); + this->record_inline_comment_for_key(key); + } } else { result.assign(key, JSON{nullptr}); } @@ -1901,16 +2064,27 @@ class Parser { auto effective_column{next->column}; std::optional subsequent_key_tag; + // A node property introduces the key it decorates, so a mapping that + // does not reach that key must leave the property alone as well + if ((next->type == TokenType::Anchor || next->type == TokenType::Tag) && + effective_column != base_column) { + break; + } + if (next->type == TokenType::Anchor) { + const auto property_token{next.value()}; next = this->next_token(); + this->reject_misplaced_property(property_token, next); if (!next.has_value() || next->type != TokenType::Scalar) { continue; } } if (next->type == TokenType::Tag) { + const auto property_token{next.value()}; subsequent_key_tag = this->resolve_tag(next->value); next = this->next_token(); + this->reject_misplaced_property(property_token, next); if (!next.has_value() || next->type != TokenType::Scalar) { continue; } @@ -1948,7 +2122,8 @@ class Parser { next = this->next_token(); if (!next.has_value() || next->type == TokenType::Scalar) { - if (next.has_value()) { + if (next.has_value() && + this->starts_mapping_value(next.value(), key_line, base_column)) { auto value{this->parse_value(next.value(), JSON::ParseContext::Property, 0, key, key_line, key_column)}; @@ -1962,12 +2137,15 @@ class Parser { next->type == TokenType::DocumentStart) { result.assign(key, JSON{nullptr}); break; - } else { + } else if (this->starts_mapping_value(next.value(), key_line, + base_column)) { auto value{this->parse_value(next.value(), JSON::ParseContext::Property, 0, key, key_line, key_column)}; result.assign(key, std::move(value)); next = this->next_token(); + } else { + result.assign(key, JSON{nullptr}); } continue; } @@ -2006,7 +2184,7 @@ class Parser { if (!next.has_value() || next->type == TokenType::Scalar) { if (next.has_value() && - (next->line == key_line || next->column != base_column)) { + this->starts_mapping_value(next.value(), key_line, base_column)) { this->record_inline_comment_for_key(key, next->line != key_line); auto after{this->next_token()}; if (after.has_value()) { @@ -2028,12 +2206,15 @@ class Parser { next->type == TokenType::DocumentStart) { result.assign(key, JSON{nullptr}); break; - } else { + } else if (this->starts_mapping_value(next.value(), key_line, + base_column)) { this->record_inline_comment_for_key(key, next->line != key_line); auto value{this->parse_value(next.value(), JSON::ParseContext::Property, 0, key, key_line, key_column)}; result.assign(key, std::move(value)); next = this->next_token(); + } else { + result.assign(key, JSON{nullptr}); } } @@ -2164,6 +2345,18 @@ class Parser { this->roundtrip_->styles[this->pointer_stack_].collection = style; } + auto record_tag(const std::optional &raw_tag, + const bool tag_before_anchor, const JSON &value) -> void { + if ((this->roundtrip_ == nullptr) || !raw_tag.has_value()) { + return; + } + + auto &node_style{this->roundtrip_->styles[this->pointer_stack_]}; + node_style.tag = raw_tag.value(); + node_style.tag_type = value.type(); + node_style.tag_before_anchor = tag_before_anchor; + } + auto record_scalar_style(const Token &token, const JSON &value) -> void { if (this->roundtrip_ == nullptr) { return; diff --git a/vendor/core/src/core/yaml/stringify.h b/vendor/core/src/core/yaml/stringify.h index 2f70421ef..d866aa075 100644 --- a/vendor/core/src/core/yaml/stringify.h +++ b/vendor/core/src/core/yaml/stringify.h @@ -6,33 +6,51 @@ #include #include -#include // std::array -#include // assert -#include // std::to_chars -#include // std::modf -#include // std::size_t -#include // std::basic_ostream -#include // std::string -#include // std::string_view -#include // std::pair -#include // std::vector +#include // std::max +#include // std::array +#include // assert +#include // std::to_chars +#include // std::modf +#include // std::size_t +#include // std::optional +#include // std::basic_ostream +#include // std::string +#include // std::string_view +#include // std::unordered_map +#include // std::pair +#include // std::vector namespace sourcemeta::core::yaml { using OutputStream = std::basic_ostream; static constexpr std::size_t INDENT_WIDTH{2}; +static constexpr std::size_t ONE_COLUMN{1}; static constexpr std::array HEX_DIGITS{{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}}; -inline auto write_indent(OutputStream &stream, const std::size_t indent, - const std::size_t width = INDENT_WIDTH) -> void { - for (std::size_t index{0}; index < indent * width; ++index) { +// A processor is free to write line breaks with whatever convention suits the +// document. See https://yaml.org/spec/1.2.2/#54-line-break-characters +inline auto write_break(OutputStream &stream, const YAMLRoundTrip *roundtrip) + -> void { + if ((roundtrip != nullptr) && roundtrip->carriage_returns) { + stream.put('\r'); + } + + stream.put('\n'); +} + +inline auto write_indent(OutputStream &stream, const std::size_t columns) + -> void { + for (std::size_t index{0}; index < columns; ++index) { stream.put(' '); } } +// The width of the "- " indicator that opens a block sequence entry +static constexpr std::size_t SEQUENCE_INDICATOR_WIDTH{2}; + inline auto looks_like_number(const std::string &value) -> bool { std::size_t start{0}; if (value[0] == '-' || value[0] == '+') { @@ -214,49 +232,48 @@ inline auto write_string(OutputStream &stream, const std::string &value) } inline auto write_block_scalar( - OutputStream &stream, const std::string &value, const std::size_t indent, - const YAMLRoundTrip::ScalarStyle style, + OutputStream &stream, const std::string &value, + const std::size_t content_columns, const YAMLRoundTrip::ScalarStyle style, const YAMLRoundTrip::Chomping chomping, const std::optional &header_comment = std::nullopt, - const std::size_t indent_width = INDENT_WIDTH, - const std::size_t explicit_indent = 0, - const bool indent_before_chomping = false) -> void { + const std::size_t indicator = 0, const bool indent_before_chomping = false, + const YAMLRoundTrip *roundtrip = nullptr) -> void { stream.put(style == YAMLRoundTrip::ScalarStyle::Literal ? '|' : '>'); - if (indent_before_chomping && explicit_indent > 0) { - stream.put(static_cast('0' + explicit_indent)); + if (indent_before_chomping && indicator > 0) { + stream.put(static_cast('0' + indicator)); } if (chomping == YAMLRoundTrip::Chomping::Strip) { stream.put('-'); } else if (chomping == YAMLRoundTrip::Chomping::Keep) { stream.put('+'); } - if (!indent_before_chomping && explicit_indent > 0) { - stream.put(static_cast('0' + explicit_indent)); + if (!indent_before_chomping && indicator > 0) { + stream.put(static_cast('0' + indicator)); } if (header_comment.has_value()) { stream.put(' '); const auto &comment{header_comment.value()}; stream.write(comment.data(), static_cast(comment.size())); } - stream.put('\n'); + write_break(stream, roundtrip); std::size_t position{0}; while (position < value.size()) { auto line_end{value.find('\n', position)}; if (line_end == std::string::npos) { - write_indent(stream, indent, indent_width); + write_indent(stream, content_columns); stream.write(value.data() + position, static_cast(value.size() - position)); - stream.put('\n'); + write_break(stream, roundtrip); break; } if (line_end > position) { - write_indent(stream, indent, indent_width); + write_indent(stream, content_columns); } stream.write(value.data() + position, static_cast(line_end - position)); - stream.put('\n'); + write_break(stream, roundtrip); position = line_end + 1; } } @@ -277,6 +294,85 @@ inline auto matches_recorded_value(const YAMLRoundTrip::NodeStyle &style, same_value(style.content_value.value(), value); } +// The content indentation level of a block scalar that carries no indicator is +// read off its first non-empty line, so detection fails when that line begins +// with a space, and when the content holds no non-empty line at all. +// See https://yaml.org/spec/1.2.2/#8111-block-indentation-indicator +inline auto block_detection_fails(const std::string &value) -> bool { + std::size_t position{0}; + while (position < value.size()) { + const auto line_end{value.find('\n', position)}; + const auto length{line_end == std::string::npos ? value.size() - position + : line_end - position}; + if (length > 0) { + return value[position] == ' '; + } + + if (line_end == std::string::npos) { + break; + } + + position = line_end + 1; + } + + return !value.empty(); +} + +// Every line a block scalar writes is closed with a line break, so the chomping +// indicator is what decides which of the trailing breaks of the value survive +// being read back. See +// https://yaml.org/spec/1.2.2/#8112-block-chomping-indicator +inline auto required_chomping(const std::string &value) + -> YAMLRoundTrip::Chomping { + const auto body{value.find_last_not_of('\n')}; + if (body == std::string::npos) { + return value.empty() ? YAMLRoundTrip::Chomping::Clip + : YAMLRoundTrip::Chomping::Keep; + } + + const auto breaks{value.size() - body - 1}; + if (breaks == 0) { + return YAMLRoundTrip::Chomping::Strip; + } + + return breaks == 1 ? YAMLRoundTrip::Chomping::Clip + : YAMLRoundTrip::Chomping::Keep; +} + +// Clipping and keeping both leave a single trailing line break in place, so a +// recorded indicator that only differs in that way still says the same thing +// and is worth writing back as it was +inline auto block_chomping(const YAMLRoundTrip::NodeStyle &style, + const std::string &value) + -> YAMLRoundTrip::Chomping { + const auto required{required_chomping(value)}; + if (!style.chomping.has_value() || style.chomping.value() == required) { + return required; + } + + return (required == YAMLRoundTrip::Chomping::Clip && !value.empty() && + style.chomping.value() == YAMLRoundTrip::Chomping::Keep) + ? YAMLRoundTrip::Chomping::Keep + : required; +} + +// The folded style joins the lines of its content, so it can only stand for a +// value that has no line break of its own to lose +inline auto folding_preserves(const std::string &value) -> bool { + const auto body{value.find_last_not_of('\n')}; + return body == std::string::npos || value.find('\n') == std::string::npos || + value.find('\n') > body; +} + +// The text a block scalar writes, which is the original one for as long as the +// document still holds the value it was read from +inline auto block_scalar_content(const YAMLRoundTrip::NodeStyle &style, + const JSON &value) -> const std::string & { + return style.block_content.has_value() && matches_recorded_value(style, value) + ? style.block_content.value() + : value.to_string(); +} + inline auto find_anchor(const AnchorValues &anchors, const std::string_view name) -> const JSON * { for (auto iterator{anchors.crbegin()}; iterator != anchors.crend(); @@ -320,6 +416,66 @@ inline auto write_anchor(OutputStream &stream, const std::string &name, anchors.emplace_back(name, &value); } +// A tag names the kind of value its node holds, so it may only be emitted +// while the document still holds that kind of value +inline auto matching_tag(const YAMLRoundTrip::NodeStyle *style, + const JSON &value) -> const std::string * { + if ((style == nullptr) || !style->tag.has_value() || + !style->tag_type.has_value() || style->tag_type.value() != value.type()) { + return nullptr; + } + + return &style->tag.value(); +} + +// Comments and node properties are read off a position in a sequence, so they +// only stand for what is written there while the sequence still holds the same +// items it was read from +inline auto keeps_item_annotations(const YAMLRoundTrip::NodeStyle *style, + const JSON &value) -> bool { + return (style == nullptr) || !style->sequence_size.has_value() || + style->sequence_size.value() == value.size(); +} + +inline auto has_node_properties(const YAMLRoundTrip::NodeStyle *style, + const JSON &value) -> bool { + return (matching_tag(style, value) != nullptr) || + ((style != nullptr) && style->anchor.has_value()); +} + +// Writes the tag and anchor that decorate a node, in the order they were +// written, and reports whether anything was written at all, as a node property +// has to be separated from the node it decorates +inline auto write_node_properties(OutputStream &stream, const JSON &value, + const YAMLRoundTrip::NodeStyle *style, + AnchorValues &anchors) -> bool { + const auto *tag{matching_tag(style, value)}; + const bool anchor{(style != nullptr) && style->anchor.has_value()}; + if ((tag == nullptr) && !anchor) { + return false; + } + + if ((tag != nullptr) && style->tag_before_anchor) { + stream.write(tag->data(), static_cast(tag->size())); + if (anchor) { + stream.put(' '); + } + } + + if (anchor) { + write_anchor(stream, style->anchor.value(), value, anchors); + } + + if ((tag != nullptr) && !style->tag_before_anchor) { + if (anchor) { + stream.put(' '); + } + stream.write(tag->data(), static_cast(tag->size())); + } + + return true; +} + inline auto write_string_with_style(OutputStream &stream, const JSON &value, const YAMLRoundTrip *roundtrip, const Pointer &pointer) -> void { @@ -508,32 +664,53 @@ inline auto is_implicit_null(const JSON &value, const YAMLRoundTrip *roundtrip, return !match->second.scalar.has_value(); } -inline auto write_flow_anchor(OutputStream &stream, const JSON &value, - const YAMLRoundTrip *roundtrip, - AnchorValues &anchors, const Pointer &pointer) +inline auto write_flow_properties(OutputStream &stream, const JSON &value, + const YAMLRoundTrip *roundtrip, + AnchorValues &anchors, const Pointer &pointer) -> void { if (roundtrip == nullptr) { return; } const auto match{roundtrip->styles.find(pointer)}; - if (match != roundtrip->styles.end() && match->second.anchor.has_value()) { - write_anchor(stream, match->second.anchor.value(), value, anchors); + if (match != roundtrip->styles.end() && + write_node_properties(stream, value, &match->second, anchors)) { stream.put(' '); } } +// The same as writing the properties of a flow node, but for a node that is +// written with no value at all, so nothing follows to be separated from +inline auto write_flow_node_properties(OutputStream &stream, const JSON &value, + const YAMLRoundTrip *roundtrip, + AnchorValues &anchors, + const Pointer &pointer) -> void { + if (roundtrip == nullptr) { + return; + } + + const auto match{roundtrip->styles.find(pointer)}; + if (match != roundtrip->styles.end()) { + write_node_properties(stream, value, &match->second, anchors); + } +} + inline auto write_flow_mapping(OutputStream &stream, const JSON &value, const YAMLRoundTrip *roundtrip, AnchorValues &anchors, Pointer &pointer) -> void { bool compact{false}; + bool padded{false}; if (roundtrip != nullptr) { const auto match{roundtrip->styles.find(pointer)}; if (match != roundtrip->styles.end()) { compact = match->second.compact_flow; + padded = match->second.padded_flow; } } stream.put('{'); + if (padded) { + stream.put(' '); + } bool first{true}; for (const auto &entry : value.as_object()) { if (!first) { @@ -547,12 +724,18 @@ inline auto write_flow_mapping(OutputStream &stream, const JSON &value, pointer.push_back(entry.first); write_key_string(stream, entry.first, roundtrip, pointer); stream.write(": ", 2); - if (!is_implicit_null(entry.second, roundtrip, pointer)) { - write_flow_anchor(stream, entry.second, roundtrip, anchors, pointer); + if (is_implicit_null(entry.second, roundtrip, pointer)) { + write_flow_node_properties(stream, entry.second, roundtrip, anchors, + pointer); + } else { + write_flow_properties(stream, entry.second, roundtrip, anchors, pointer); write_inline_value(stream, entry.second, roundtrip, anchors, pointer); } pointer.pop_back(); } + if (padded) { + stream.put(' '); + } stream.put('}'); } @@ -561,13 +744,20 @@ inline auto write_flow_sequence(OutputStream &stream, const JSON &value, AnchorValues &anchors, Pointer &pointer) -> void { bool compact{false}; + bool padded{false}; + bool annotations{true}; if (roundtrip != nullptr) { const auto match{roundtrip->styles.find(pointer)}; if (match != roundtrip->styles.end()) { compact = match->second.compact_flow; + padded = match->second.padded_flow; + annotations = keeps_item_annotations(&match->second, value); } } stream.put('['); + if (padded) { + stream.put(' '); + } bool first{true}; std::size_t item_index{0}; for (const auto &item : value.as_array()) { @@ -580,21 +770,28 @@ inline auto write_flow_sequence(OutputStream &stream, const JSON &value, } first = false; pointer.push_back(item_index); - write_flow_anchor(stream, item, roundtrip, anchors, pointer); + if (annotations) { + write_flow_properties(stream, item, roundtrip, anchors, pointer); + } write_inline_value(stream, item, roundtrip, anchors, pointer); pointer.pop_back(); item_index++; } + if (padded) { + stream.put(' '); + } stream.put(']'); } inline auto write_block_mapping(OutputStream &stream, const JSON &value, - std::size_t indent, bool skip_first_indent, + std::size_t columns, std::size_t width, + bool skip_first_indent, const YAMLRoundTrip *roundtrip, AnchorValues &anchors, Pointer &pointer) -> void; inline auto write_block_sequence(OutputStream &stream, const JSON &value, - std::size_t indent, bool skip_first_indent, + std::size_t columns, std::size_t width, + bool skip_first_indent, const YAMLRoundTrip *roundtrip, AnchorValues &anchors, Pointer &pointer) -> void; @@ -609,9 +806,16 @@ inline auto emit_inline_comment(OutputStream &stream, } inline auto write_node(OutputStream &stream, const JSON &value, - const std::size_t indent, const bool skip_first_indent, + const std::size_t columns, const std::size_t width, + const std::size_t block_indicator, + const bool skip_first_indent, const YAMLRoundTrip *roundtrip, AnchorValues &anchors, - Pointer &pointer) -> void { + Pointer &pointer, const bool skip_properties = false, + const bool annotations = true) -> void { + // Only the document root sits at the leftmost column, and a block scalar + // there has no column of its own, so its content is pushed one nesting level + // in to leave room for whatever follows it + const auto block_columns{columns == 0 ? width : columns}; const YAMLRoundTrip::NodeStyle *node_style{nullptr}; if (roundtrip != nullptr) { const auto style_match{roundtrip->styles.find(pointer)}; @@ -620,96 +824,118 @@ inline auto write_node(OutputStream &stream, const JSON &value, } } + const YAMLRoundTrip::NodeStyle *annotation_style{annotations ? node_style + : nullptr}; + const auto *alias{matching_alias(value, roundtrip, anchors, pointer)}; if (alias != nullptr) { write_alias(stream, *alias); - emit_inline_comment(stream, node_style); - stream.put('\n'); + emit_inline_comment(stream, annotation_style); + write_break(stream, roundtrip); return; } - bool has_anchor{false}; - if ((node_style != nullptr) && node_style->anchor.has_value()) { - write_anchor(stream, node_style->anchor.value(), value, anchors); - has_anchor = true; - } + const bool has_properties{ + skip_properties + ? false + : write_node_properties(stream, value, annotation_style, anchors)}; const bool flow{ (node_style != nullptr) && node_style->collection.has_value() && node_style->collection.value() == YAMLRoundTrip::CollectionStyle::Flow}; + // An indicator is a single digit, so content that needs one but sits too far + // in has to give up on block style and be quoted instead + const bool block_style{ + (node_style != nullptr) && value.is_string() && + node_style->scalar.has_value() && + (node_style->scalar.value() == YAMLRoundTrip::ScalarStyle::Literal || + node_style->scalar.value() == YAMLRoundTrip::ScalarStyle::Folded) && + ((block_indicator >= 1 && block_indicator <= 9) || + !block_detection_fails(block_scalar_content(*node_style, value)))}; + if (value.is_object() && !value.empty()) { if (flow) { - if (has_anchor) { + if (has_properties) { stream.put(' '); } write_flow_mapping(stream, value, roundtrip, anchors, pointer); - emit_inline_comment(stream, node_style); - stream.put('\n'); + emit_inline_comment(stream, annotation_style); + write_break(stream, roundtrip); } else { - if (has_anchor) { - emit_inline_comment(stream, node_style); - stream.put('\n'); + if (has_properties) { + emit_inline_comment(stream, annotation_style); + write_break(stream, roundtrip); } - write_block_mapping(stream, value, indent, - has_anchor ? false : skip_first_indent, roundtrip, + write_block_mapping(stream, value, columns, width, + has_properties ? false : skip_first_indent, roundtrip, anchors, pointer); } } else if (value.is_array() && !value.empty()) { if (flow) { - if (has_anchor) { + if (has_properties) { stream.put(' '); } write_flow_sequence(stream, value, roundtrip, anchors, pointer); - emit_inline_comment(stream, node_style); - stream.put('\n'); + emit_inline_comment(stream, annotation_style); + write_break(stream, roundtrip); } else { - if (has_anchor) { - emit_inline_comment(stream, node_style); - stream.put('\n'); + if (has_properties) { + emit_inline_comment(stream, annotation_style); + write_break(stream, roundtrip); } - write_block_sequence(stream, value, indent, - has_anchor ? false : skip_first_indent, roundtrip, - anchors, pointer); - } - } else if ((node_style != nullptr) && value.is_string() && - node_style->scalar.has_value() && - (node_style->scalar.value() == - YAMLRoundTrip::ScalarStyle::Literal || - node_style->scalar.value() == - YAMLRoundTrip::ScalarStyle::Folded)) { - if (has_anchor) { + // A block sequence may sit at the indentation of the mapping key it + // belongs to rather than one level further in + const auto sequence_columns{(node_style != nullptr) && + node_style->unindented_sequence && + columns >= block_indicator + ? columns - block_indicator + : columns}; + write_block_sequence(stream, value, sequence_columns, width, + has_properties ? false : skip_first_indent, + roundtrip, anchors, pointer); + } + } else if (block_style) { + if (has_properties) { stream.put(' '); } - const auto chomping{ - node_style->chomping.value_or(YAMLRoundTrip::Chomping::Clip)}; - const auto &content{node_style->block_content.has_value() && - matches_recorded_value(*node_style, value) - ? node_style->block_content.value() - : value.to_string()}; - write_block_scalar(stream, content, indent, node_style->scalar.value(), - chomping, node_style->comment_inline, - roundtrip->indent_width, node_style->explicit_indent, - node_style->indent_before_chomping); + const auto &content{block_scalar_content(*node_style, value)}; + const auto &text{value.to_string()}; + // The recorded style only reproduces the text it was read from, so once the + // document holds something else, a style that would lose a line break in + // the process gives way to one that keeps every line as it is + const bool original{node_style->block_content.has_value() && + matches_recorded_value(*node_style, value)}; + const auto style{original || folding_preserves(text) + ? node_style->scalar.value() + : YAMLRoundTrip::ScalarStyle::Literal}; + const std::optional header_comment{ + (annotation_style != nullptr) ? annotation_style->comment_inline + : std::nullopt}; + const bool indicated{block_detection_fails(content) || + node_style->explicit_indent > 0}; + write_block_scalar(stream, content, block_columns, style, + block_chomping(*node_style, text), header_comment, + indicated ? block_indicator : 0, + node_style->indent_before_chomping, roundtrip); } else { - if (has_anchor) { + if (has_properties) { stream.put(' '); } write_inline_value(stream, value, roundtrip, anchors, pointer); - emit_inline_comment(stream, node_style); - stream.put('\n'); + emit_inline_comment(stream, annotation_style); + write_break(stream, roundtrip); } } inline auto write_block_mapping(OutputStream &stream, const JSON &value, - const std::size_t indent, + const std::size_t columns, + const std::size_t width, const bool skip_first_indent, const YAMLRoundTrip *roundtrip, AnchorValues &anchors, Pointer &pointer) -> void { assert(value.is_object() && !value.empty()); - const auto width{(roundtrip != nullptr) ? roundtrip->indent_width - : INDENT_WIDTH}; bool first{true}; for (const auto &entry : value.as_object()) { pointer.push_back(entry.first); @@ -729,16 +955,16 @@ inline auto write_block_mapping(OutputStream &stream, const JSON &value, if ((entry_style != nullptr) && !entry_style->comments_before.empty()) { for (const auto &comment : entry_style->comments_before) { if (comment.empty()) { - stream.put('\n'); + write_break(stream, roundtrip); } else { - write_indent(stream, indent, width); + write_indent(stream, columns); stream.write(comment.data(), static_cast(comment.size())); - stream.put('\n'); + write_break(stream, roundtrip); } } } - write_indent(stream, indent, width); + write_indent(stream, columns); } first = false; @@ -749,13 +975,12 @@ inline auto write_block_mapping(OutputStream &stream, const JSON &value, (roundtrip != nullptr) && entry.second.is_null() && !entry_is_alias && ((entry_style == nullptr) || !entry_style->scalar.has_value())}; if (implicit_null) { - if ((entry_style != nullptr) && entry_style->anchor.has_value()) { + if (has_node_properties(entry_style, entry.second)) { stream.put(' '); - write_anchor(stream, entry_style->anchor.value(), entry.second, - anchors); + write_node_properties(stream, entry.second, entry_style, anchors); } emit_inline_comment(stream, entry_style); - stream.put('\n'); + write_break(stream, roundtrip); } else { bool has_indicator_comment{false}; if ((entry_style != nullptr) && @@ -765,13 +990,12 @@ inline auto write_block_mapping(OutputStream &stream, const JSON &value, const auto &comment{entry_style->comment_on_indicator.value()}; stream.write(comment.data(), static_cast(comment.size())); - stream.put('\n'); - write_indent(stream, indent + 1, width); + write_break(stream, roundtrip); + write_indent(stream, columns + width); } if (!has_indicator_comment) { - const bool has_prefix{ - entry_is_alias || - ((entry_style != nullptr) && entry_style->anchor.has_value())}; + const bool has_prefix{entry_is_alias || + has_node_properties(entry_style, entry.second)}; const bool entry_flow{(entry_style != nullptr) && entry_style->collection.has_value() && entry_style->collection.value() == @@ -781,12 +1005,12 @@ inline auto write_block_mapping(OutputStream &stream, const JSON &value, !entry.second.empty() && !entry_flow && !has_prefix}; if (nested) { emit_inline_comment(stream, entry_style); - stream.put('\n'); + write_break(stream, roundtrip); } else { stream.put(' '); } } - write_node(stream, entry.second, indent + 1, + write_node(stream, entry.second, columns + width, width, width, has_indicator_comment ? true : false, roundtrip, anchors, pointer); } @@ -796,21 +1020,29 @@ inline auto write_block_mapping(OutputStream &stream, const JSON &value, } inline auto write_block_sequence(OutputStream &stream, const JSON &value, - const std::size_t indent, + const std::size_t columns, + const std::size_t width, const bool skip_first_indent, const YAMLRoundTrip *roundtrip, AnchorValues &anchors, Pointer &pointer) -> void { assert(value.is_array() && !value.empty()); - const auto width{(roundtrip != nullptr) ? roundtrip->indent_width - : INDENT_WIDTH}; + const YAMLRoundTrip::NodeStyle *sequence_style{nullptr}; + if (roundtrip != nullptr) { + const auto style_match{roundtrip->styles.find(pointer)}; + if (style_match != roundtrip->styles.end()) { + sequence_style = &style_match->second; + } + } + + const bool annotations{keeps_item_annotations(sequence_style, value)}; bool first{true}; std::size_t item_index{0}; for (const auto &item : value.as_array()) { pointer.push_back(item_index); const YAMLRoundTrip::NodeStyle *item_style{nullptr}; - if (roundtrip != nullptr) { + if (annotations && (roundtrip != nullptr)) { const auto style_match{roundtrip->styles.find(pointer)}; if (style_match != roundtrip->styles.end()) { item_style = &style_match->second; @@ -824,16 +1056,16 @@ inline auto write_block_sequence(OutputStream &stream, const JSON &value, if ((item_style != nullptr) && !item_style->comments_before.empty()) { for (const auto &comment : item_style->comments_before) { if (comment.empty()) { - stream.put('\n'); + write_break(stream, roundtrip); } else { - write_indent(stream, indent, width); + write_indent(stream, columns); stream.write(comment.data(), static_cast(comment.size())); - stream.put('\n'); + write_break(stream, roundtrip); } } } - write_indent(stream, indent, width); + write_indent(stream, columns); } first = false; @@ -843,9 +1075,9 @@ inline auto write_block_sequence(OutputStream &stream, const JSON &value, if (implicit_null) { stream.put('-'); if (item_style != nullptr) { - if (item_style->anchor.has_value()) { + if (has_node_properties(item_style, item)) { stream.put(' '); - write_anchor(stream, item_style->anchor.value(), item, anchors); + write_node_properties(stream, item, item_style, anchors); } if (item_style->comment_on_indicator.has_value() && !item_style->comment_on_indicator.value().empty()) { @@ -856,7 +1088,7 @@ inline auto write_block_sequence(OutputStream &stream, const JSON &value, } } emit_inline_comment(stream, item_style); - stream.put('\n'); + write_break(stream, roundtrip); } else { bool has_indicator{false}; if ((item_style != nullptr) && @@ -870,13 +1102,15 @@ inline auto write_block_sequence(OutputStream &stream, const JSON &value, stream.write(comment.data(), static_cast(comment.size())); } - stream.put('\n'); - write_indent(stream, indent + 1, width); + write_break(stream, roundtrip); + write_indent(stream, columns + SEQUENCE_INDICATOR_WIDTH); } if (!has_indicator) { stream.write("- ", 2); } - write_node(stream, item, indent + 1, true, roundtrip, anchors, pointer); + write_node(stream, item, columns + SEQUENCE_INDICATOR_WIDTH, width, + SEQUENCE_INDICATOR_WIDTH, true, roundtrip, anchors, pointer, + false, annotations); } pointer.pop_back(); @@ -884,44 +1118,165 @@ inline auto write_block_sequence(OutputStream &stream, const JSON &value, } } +// An anchor only names something when an alias that refers to it is written +// later, as an alias may not stand before the anchor it names. A caller that +// rearranges the document can move an anchor past its aliases, which expands +// them and leaves the anchor naming nothing. +// See https://yaml.org/spec/1.2.2/#71-alias-nodes +struct AnchorDefinition { + Pointer pointer; + std::string name; + std::size_t position; +}; + +inline auto scan_anchor_uses( + const JSON &value, const YAMLRoundTrip &roundtrip, Pointer &pointer, + std::size_t &position, std::vector &definitions, + std::unordered_map &aliases) -> void { + const auto alias{roundtrip.aliases.find(pointer)}; + if (alias != roundtrip.aliases.cend()) { + aliases[alias->second] = position; + } else { + const auto style{roundtrip.styles.find(pointer)}; + if (style != roundtrip.styles.cend() && style->second.anchor.has_value()) { + definitions.emplace_back(pointer, style->second.anchor.value(), position); + } + } + + position += 1; + + if (value.is_object()) { + for (const auto &entry : value.as_object()) { + pointer.push_back(entry.first); + scan_anchor_uses(entry.second, roundtrip, pointer, position, definitions, + aliases); + pointer.pop_back(); + } + } else if (value.is_array()) { + std::size_t index{0}; + for (const auto &item : value.as_array()) { + pointer.push_back(index); + scan_anchor_uses(item, roundtrip, pointer, position, definitions, + aliases); + pointer.pop_back(); + index += 1; + } + } +} + +// An anchor that never had an alias is markup the document was written with, so +// only one whose aliases have all moved ahead of it is dropped +inline auto collect_dead_anchors(const JSON &document, + const YAMLRoundTrip &roundtrip) + -> std::vector { + std::vector dead; + if (roundtrip.aliases.empty()) { + return dead; + } + + Pointer pointer; + std::size_t position{0}; + std::vector definitions; + std::unordered_map aliases; + scan_anchor_uses(document, roundtrip, pointer, position, definitions, + aliases); + + for (const auto &definition : definitions) { + const auto match{aliases.find(definition.name)}; + if (match != aliases.cend() && match->second < definition.position) { + dead.push_back(definition.pointer); + } + } + + return dead; +} + template