Repository navigation
Support formatting jsonschema.json
#917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,11 +3,13 @@ | |||||||
| #include <sourcemeta/core/json.h> | ||||||||
| #include <sourcemeta/core/jsonschema.h> | ||||||||
|
|
||||||||
| #include <iostream> // std::cerr, std::cout | ||||||||
| #include <sstream> // std::ostringstream | ||||||||
| #include <string> // std::string | ||||||||
| #include <string_view> // std::string_view | ||||||||
| #include <utility> // std::move, std::unreachable | ||||||||
| #include <filesystem> // std::filesystem | ||||||||
| #include <iostream> // std::cerr, std::cout | ||||||||
| #include <sstream> // std::ostringstream | ||||||||
| #include <string> // std::string | ||||||||
| #include <string_view> // std::string_view | ||||||||
| #include <system_error> // std::error_code | ||||||||
| #include <utility> // std::move, std::unreachable | ||||||||
|
|
||||||||
| #include "command.h" | ||||||||
| #include "error.h" | ||||||||
|
|
@@ -152,6 +154,28 @@ auto formatted_copy(const sourcemeta::core::JSON &document, | |||||||
| return copy; | ||||||||
| } | ||||||||
|
|
||||||||
| // A configuration file read through a path of its own, such as a symbolic link | ||||||||
| // or a spelling that the filesystem matches without regard to case, is still | ||||||||
| // the very file the lookup settled on. What tells the two apart is therefore | ||||||||
| // the identity of the file rather than the path that happens to name it | ||||||||
| auto same_file(const std::filesystem::path &left, | ||||||||
| const std::filesystem::path &right) -> bool { | ||||||||
| if (left == right) { | ||||||||
| return true; | ||||||||
| } | ||||||||
|
|
||||||||
| std::error_code error; | ||||||||
| const auto result{std::filesystem::equivalent(left, right, error)}; | ||||||||
| return !error && result; | ||||||||
| } | ||||||||
|
|
||||||||
| auto configuration_copy(const sourcemeta::core::JSON &document) | ||||||||
| -> sourcemeta::core::JSON { | ||||||||
| auto copy{document}; | ||||||||
| sourcemeta::jsonschema::format_configuration(copy); | ||||||||
| return copy; | ||||||||
| } | ||||||||
|
|
||||||||
| } // namespace | ||||||||
|
|
||||||||
| auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) | ||||||||
|
|
@@ -281,13 +305,22 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) | |||||||
| entry.resolution_base}; | ||||||||
| } | ||||||||
|
|
||||||||
| if (!entry.second.is_object() && !entry.second.is_boolean()) { | ||||||||
| throw NotSchemaError{entry.resolution_base}; | ||||||||
| } | ||||||||
| const auto configuration_path{ | ||||||||
| find_configuration(options, entry.resolution_base)}; | ||||||||
| // The configuration file in effect for its own location is the one this | ||||||||
| // goes on to order by the configuration format rather than by any dialect, | ||||||||
| // so none of what tells a schema from the rest has a say over it | ||||||||
| const auto is_configuration{ | ||||||||
| configuration_path.has_value() && | ||||||||
| same_file(configuration_path.value(), entry.resolution_base)}; | ||||||||
|
|
||||||||
| if (!is_configuration) { | ||||||||
| if (!entry.second.is_object() && !entry.second.is_boolean()) { | ||||||||
| throw NotSchemaError{entry.resolution_base}; | ||||||||
| } | ||||||||
|
|
||||||||
| reject_unsupported_openapi(entry.second, entry.resolution_base); | ||||||||
| const auto is_openapi{ | ||||||||
| sourcemeta::core::openapi_version(entry.second).has_value()}; | ||||||||
| reject_unsupported_openapi(entry.second, entry.resolution_base); | ||||||||
| } | ||||||||
|
|
||||||||
| if (options.contains("check")) { | ||||||||
| LOG_VERBOSE(options) << "Checking: " << entry.first << "\n"; | ||||||||
|
|
@@ -296,30 +329,43 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) | |||||||
| } | ||||||||
|
|
||||||||
| try { | ||||||||
| const auto configuration_path{ | ||||||||
| find_configuration(options, entry.resolution_base)}; | ||||||||
| const auto &configuration{read_configuration(options, configuration_path, | ||||||||
| entry.resolution_base)}; | ||||||||
| const auto dialect{default_dialect(options, configuration)}; | ||||||||
| const auto is_test_document = | ||||||||
| dialect.empty() && looks_like_test_document(entry.second); | ||||||||
| const auto effective_dialect = | ||||||||
| is_test_document ? TEST_DOCUMENT_DEFAULT_DIALECT : dialect; | ||||||||
| if (is_test_document) { | ||||||||
| std::cerr << "Interpreting as a test file: " << entry.first << "\n"; | ||||||||
| } | ||||||||
| const auto &custom_resolver{resolver(options, options.contains("http"), | ||||||||
| effective_dialect, configuration)}; | ||||||||
|
|
||||||||
| std::ostringstream expected; | ||||||||
| if (options.contains("keep-ordering")) { | ||||||||
| sourcemeta::jsonschema::write_schema(entry.second, expected, | ||||||||
| indentation, entry.roundtrip); | ||||||||
| if (is_configuration) { | ||||||||
| std::cerr << "Interpreting as a configuration file: " << entry.first | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This message prints unconditionally, so a passing Prompt for AI agents
Suggested change
|
||||||||
| << "\n"; | ||||||||
| if (options.contains("keep-ordering")) { | ||||||||
| sourcemeta::jsonschema::write_schema(entry.second, expected, | ||||||||
| indentation, entry.roundtrip); | ||||||||
| } else { | ||||||||
| sourcemeta::jsonschema::write_schema(configuration_copy(entry.second), | ||||||||
| expected, indentation, | ||||||||
| entry.roundtrip); | ||||||||
| } | ||||||||
| } else { | ||||||||
| sourcemeta::jsonschema::write_schema( | ||||||||
| formatted_copy(entry.second, custom_resolver, effective_dialect, | ||||||||
| openapi_default_id(entry), is_openapi), | ||||||||
| expected, indentation, entry.roundtrip); | ||||||||
| const auto &configuration{read_configuration( | ||||||||
| options, configuration_path, entry.resolution_base)}; | ||||||||
| const auto dialect{default_dialect(options, configuration)}; | ||||||||
| const auto is_test_document = | ||||||||
| dialect.empty() && looks_like_test_document(entry.second); | ||||||||
| const auto effective_dialect = | ||||||||
| is_test_document ? TEST_DOCUMENT_DEFAULT_DIALECT : dialect; | ||||||||
| if (is_test_document) { | ||||||||
| std::cerr << "Interpreting as a test file: " << entry.first << "\n"; | ||||||||
| } | ||||||||
| const auto &custom_resolver{resolver(options, options.contains("http"), | ||||||||
| effective_dialect, configuration)}; | ||||||||
| const auto is_openapi{ | ||||||||
| sourcemeta::core::openapi_version(entry.second).has_value()}; | ||||||||
|
|
||||||||
| if (options.contains("keep-ordering")) { | ||||||||
| sourcemeta::jsonschema::write_schema(entry.second, expected, | ||||||||
| indentation, entry.roundtrip); | ||||||||
| } else { | ||||||||
| sourcemeta::jsonschema::write_schema( | ||||||||
| formatted_copy(entry.second, custom_resolver, effective_dialect, | ||||||||
| openapi_default_id(entry), is_openapi), | ||||||||
| expected, indentation, entry.roundtrip); | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| const auto current{ | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,14 +18,19 @@ | |
| #include "error.h" | ||
| #include "input.h" | ||
|
|
||
| #include <algorithm> // std::max, std::ranges::all_of | ||
| #include <cctype> // std::isdigit | ||
| #include <algorithm> // std::max, std::ranges::all_of, std::ranges::find, std::sort | ||
| #include <array> // std::array, std::to_array | ||
| #include <cctype> // std::isdigit | ||
| #include <cstddef> // std::size_t | ||
| #include <cstdint> // std::uint16_t | ||
| #include <filesystem> // std::filesystem::path | ||
| #include <iterator> // std::ranges::distance | ||
| #include <limits> // std::numeric_limits | ||
| #include <memory> // std::make_shared | ||
| #include <optional> // std::optional | ||
| #include <ostream> // std::ostream | ||
| #include <set> // std::set | ||
| #include <span> // std::span | ||
| #include <stdexcept> // std::out_of_range | ||
| #include <string> // std::string, std::stoull | ||
| #include <string_view> // std::string_view | ||
|
|
@@ -143,6 +148,115 @@ inline auto looks_like_test_document(const sourcemeta::core::JSON &document) | |
| document.defines("tests") && document.at("tests").is_array(); | ||
| } | ||
|
|
||
| // The order in which properties are meant to appear within a configuration | ||
| // file, where the position of an entry is the rank of the property it names. | ||
| // This is the order upstream spells when it serializes a configuration of its | ||
| // own, so a configuration this writes is one upstream would have written | ||
| constexpr auto CONFIGURATION_PROPERTIES{std::to_array<std::string_view>( | ||
| {"title", "description", "email", "github", "website", "path", "baseUri", | ||
| "defaultDialect", "extension", "resolve", "dependencies", "ignore", | ||
| "lint"})}; | ||
|
|
||
| constexpr auto CONFIGURATION_LINT_PROPERTIES{ | ||
| std::to_array<std::string_view>({"rules", "exclude"})}; | ||
|
|
||
| inline auto | ||
| configuration_property_rank(const std::span<const std::string_view> properties, | ||
| const sourcemeta::core::JSON::String &property) | ||
| -> std::uint16_t { | ||
| constexpr auto UNRECOGNISED{std::numeric_limits<std::uint16_t>::max()}; | ||
| const auto match{std::ranges::find(properties, property)}; | ||
| if (match == properties.end()) { | ||
| return UNRECOGNISED; | ||
| } | ||
|
|
||
| return static_cast<std::uint16_t>( | ||
| std::ranges::distance(properties.begin(), match)); | ||
| } | ||
|
|
||
| // A property the configuration format does not name goes last, and goes in | ||
| // alphabetical order among the rest of its kind, so that what this writes does | ||
| // not depend on the order the properties were read in | ||
| inline auto configuration_property_compare( | ||
| const std::span<const std::string_view> properties, | ||
| const sourcemeta::core::JSON::String &left, | ||
| const sourcemeta::core::JSON::String &right) -> bool { | ||
| const auto left_rank{configuration_property_rank(properties, left)}; | ||
| const auto right_rank{configuration_property_rank(properties, right)}; | ||
| if (left_rank == right_rank) { | ||
| return left < right; | ||
| } | ||
|
|
||
| return left_rank < right_rank; | ||
| } | ||
|
|
||
| // A configuration property whose value stands for a set rather than a sequence, | ||
| // where the order the entries were written in carries no meaning of its own | ||
| inline auto sort_configuration_set(sourcemeta::core::JSON &value) -> void { | ||
| if (!value.is_array()) { | ||
| return; | ||
| } | ||
|
|
||
| std::sort(value.as_array().begin(), value.as_array().end()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Formatting a configuration file no longer validates it. Previously Prompt for AI agents |
||
| } | ||
|
|
||
| inline auto sort_configuration_map(sourcemeta::core::JSON &value) -> void { | ||
| if (!value.is_object()) { | ||
| return; | ||
| } | ||
|
|
||
| value.reorder( | ||
| [](const auto &left, const auto &right) { return left < right; }); | ||
| } | ||
|
|
||
| // A configuration file is not a schema, so what orders it is the configuration | ||
| // format rather than any dialect. Every property the file spells is kept as it | ||
| // was written, as the only thing at stake here is the order they come in | ||
| inline auto format_configuration(sourcemeta::core::JSON &configuration) | ||
| -> void { | ||
| if (!configuration.is_object()) { | ||
| return; | ||
| } | ||
|
|
||
| auto *extension{configuration.try_at("extension")}; | ||
| if (extension != nullptr) { | ||
| sort_configuration_set(*extension); | ||
| } | ||
|
|
||
| auto *ignore{configuration.try_at("ignore")}; | ||
| if (ignore != nullptr) { | ||
| sort_configuration_set(*ignore); | ||
| } | ||
|
|
||
| auto *resolve{configuration.try_at("resolve")}; | ||
| if (resolve != nullptr) { | ||
| sort_configuration_map(*resolve); | ||
| } | ||
|
|
||
| auto *dependencies{configuration.try_at("dependencies")}; | ||
| if (dependencies != nullptr) { | ||
| sort_configuration_map(*dependencies); | ||
| } | ||
|
|
||
| auto *lint{configuration.try_at("lint")}; | ||
| if (lint != nullptr && lint->is_object()) { | ||
| auto *exclude{lint->try_at("exclude")}; | ||
| if (exclude != nullptr) { | ||
| sort_configuration_set(*exclude); | ||
| } | ||
|
|
||
| lint->reorder([](const auto &left, const auto &right) { | ||
| return configuration_property_compare(CONFIGURATION_LINT_PROPERTIES, left, | ||
| right); | ||
| }); | ||
| } | ||
|
|
||
| configuration.reorder([](const auto &left, const auto &right) { | ||
| return configuration_property_compare(CONFIGURATION_PROPERTIES, left, | ||
| right); | ||
| }); | ||
| } | ||
|
|
||
| // Whether a document holds an OpenAPI Description at all, whichever revision it | ||
| // declares. The `openapi` field is what the specification identifies one by, | ||
| // and one we cannot read is a description all the same, so this is what tells a | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| WRITE jsonschema.json UNTIL EOF | ||
| { | ||
| "baseUri": "https://example.com", | ||
| "title": "My project" | ||
| } | ||
| EOF | ||
|
|
||
| COPY jsonschema.json TO original.json | ||
|
|
||
| // Validation failure | ||
| RUN fmt jsonschema.json --check STDIN /dev/null IN . INTO result.txt EXPECTING 2 | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
|
|
||
| REPLACE $CWD WITH '[CWD]' IN result.txt | ||
|
|
||
| WRITE expected.txt UNTIL EOF | ||
| 2> Interpreting as a configuration file: [CWD]/jsonschema.json | ||
| 2> fail: [CWD]/jsonschema.json | ||
| 2> --- current | ||
| 2> +++ expected | ||
| 2> @@ -1,4 +1,4 @@ | ||
| 2> { | ||
| 2> - "baseUri": "https://example.com", | ||
| 2> - "title": "My project" | ||
| 2> + "title": "My project", | ||
| 2> + "baseUri": "https://example.com" | ||
| 2> } | ||
| 2> | ||
| 2> Run the `fmt` command without `--check/-c` to fix the formatting | ||
| EOF | ||
|
|
||
| COMPARE result.txt AGAINST expected.txt | ||
|
|
||
| COMPARE jsonschema.json AGAINST original.json | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| WRITE jsonschema.json UNTIL EOF | ||
| { | ||
| "baseUri": "https://example.com", | ||
| "title": "My project" | ||
| } | ||
| EOF | ||
|
|
||
| COPY jsonschema.json TO original.json | ||
|
|
||
| // Validation failure | ||
| RUN fmt jsonschema.json --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]/jsonschema.json", | ||
| 1> "diff": [ | ||
| 1> { | ||
| 1> "type": "equal", | ||
| 1> "lines": [ "{" ] | ||
| 1> }, | ||
| 1> { | ||
| 1> "type": "delete", | ||
| 1> "lines": [ | ||
| 1> " \"baseUri\": \"https://example.com\",", | ||
| 1> " \"title\": \"My project\"" | ||
| 1> ] | ||
| 1> }, | ||
| 1> { | ||
| 1> "type": "insert", | ||
| 1> "lines": [ | ||
| 1> " \"title\": \"My project\",", | ||
| 1> " \"baseUri\": \"https://example.com\"" | ||
| 1> ] | ||
| 1> }, | ||
| 1> { | ||
| 1> "type": "equal", | ||
| 1> "lines": [ "}" ] | ||
| 1> } | ||
| 1> ] | ||
| 1> } | ||
| 1> ] | ||
| 1> } | ||
| 2> Interpreting as a configuration file: [CWD]/jsonschema.json | ||
| EOF | ||
|
|
||
| COMPARE result.txt AGAINST expected.txt | ||
|
|
||
| COMPARE jsonschema.json AGAINST original.json |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you check the new branch at
src/command_fmt.cc:316with"ignore": ["./jsonschema.json"], a pattern already used intest/format/pass_cwd_config_no_path.clitest?for_each_jsonfilters the explicitly requested configuration out atsrc/input.h:624–629before this branch runs, sofmt jsonschema.jsonleaves it unchanged andfmt jsonschema.json --checkreports success even when it is unformatted.Severity: medium
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.