From dd9c536cc071bb1f55da61a03f79eac0d07943f6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sat, 10 Oct 2026 13:02:20 -0300 Subject: [PATCH 1/3] [WIP] Support formatting `jsonschema.json` See: https://github.com/sourcemeta/jsonschema/issues/916 Signed-off-by: Juan Cruz Viotti --- docs/format.markdown | 6 + src/command_fmt.cc | 85 ++++++++----- src/utils.h | 118 +++++++++++++++++- test/CMakeLists.txt | 12 ++ .../fail_check_configuration_file.clitest | 29 +++++ ...fail_check_configuration_file_json.clitest | 49 ++++++++ .../pass_check_configuration_file.clitest | 25 ++++ test/format/pass_configuration_file.clitest | 25 ++++ ...ass_configuration_file_custom_name.clitest | 25 ++++ ...ss_configuration_file_dependencies.clitest | 35 ++++++ ...s_configuration_file_keep_ordering.clitest | 25 ++++ .../pass_configuration_file_nested.clitest | 41 ++++++ .../pass_configuration_file_resolve.clitest | 39 ++++++ .../pass_configuration_file_sets.clitest | 35 ++++++ ...figuration_file_unknown_properties.clitest | 27 ++++ ...configuration_file_without_dialect.clitest | 26 ++++ 16 files changed, 572 insertions(+), 30 deletions(-) create mode 100644 test/format/fail_check_configuration_file.clitest create mode 100644 test/format/fail_check_configuration_file_json.clitest create mode 100644 test/format/pass_check_configuration_file.clitest create mode 100644 test/format/pass_configuration_file.clitest create mode 100644 test/format/pass_configuration_file_custom_name.clitest create mode 100644 test/format/pass_configuration_file_dependencies.clitest create mode 100644 test/format/pass_configuration_file_keep_ordering.clitest create mode 100644 test/format/pass_configuration_file_nested.clitest create mode 100644 test/format/pass_configuration_file_resolve.clitest create mode 100644 test/format/pass_configuration_file_sets.clitest create mode 100644 test/format/pass_configuration_file_unknown_properties.clitest create mode 100644 test/format/pass_configuration_file_without_dialect.clitest diff --git a/docs/format.markdown b/docs/format.markdown index 8da5f26bc..cdecf7d4f 100644 --- a/docs/format.markdown +++ b/docs/format.markdown @@ -113,3 +113,9 @@ jsonschema fmt path/to/my/schema.json --check ```sh jsonschema fmt path/to/my/openapi.json ``` + +### Format the configuration file in-place + +```sh +jsonschema fmt jsonschema.json +``` diff --git a/src/command_fmt.cc b/src/command_fmt.cc index 362476198..51a9d5318 100644 --- a/src/command_fmt.cc +++ b/src/command_fmt.cc @@ -152,6 +152,13 @@ auto formatted_copy(const sourcemeta::core::JSON &document, return copy; } +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 +288,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() && + 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 +312,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 + << "\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{ diff --git a/src/utils.h b/src/utils.h index c6a87741c..b5e6e475f 100644 --- a/src/utils.h +++ b/src/utils.h @@ -18,14 +18,19 @@ #include "error.h" #include "input.h" -#include // std::max, std::ranges::all_of -#include // std::isdigit +#include // std::max, std::ranges::all_of, std::ranges::find, std::sort +#include // std::array, std::to_array +#include // std::isdigit #include // std::size_t +#include // std::uint16_t #include // std::filesystem::path +#include // std::ranges::distance +#include // std::numeric_limits #include // std::make_shared #include // std::optional #include // std::ostream #include // std::set +#include // std::span #include // std::out_of_range #include // std::string, std::stoull #include // 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( + {"title", "description", "email", "github", "website", "path", "baseUri", + "defaultDialect", "extension", "resolve", "dependencies", "ignore", + "lint"})}; + +constexpr auto CONFIGURATION_LINT_PROPERTIES{ + std::to_array({"rules", "exclude"})}; + +inline auto +configuration_property_rank(const std::span properties, + const sourcemeta::core::JSON::String &property) + -> std::uint16_t { + constexpr auto UNRECOGNISED{std::numeric_limits::max()}; + const auto match{std::ranges::find(properties, property)}; + if (match == properties.end()) { + return UNRECOGNISED; + } + + return static_cast( + 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 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()); +} + +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 diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 583ee81a7..7870ed9c9 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -145,6 +145,18 @@ add_jsonschema_test(format/pass_test_document_with_comment) add_jsonschema_test(format/pass_test_document_explicit_dialect) add_jsonschema_test(format/pass_test_document_directory) add_jsonschema_test(format/pass_test_document_schema_keyword) +add_jsonschema_test(format/pass_configuration_file) +add_jsonschema_test(format/pass_check_configuration_file) +add_jsonschema_test(format/fail_check_configuration_file) +add_jsonschema_test(format/fail_check_configuration_file_json) +add_jsonschema_test(format/pass_configuration_file_keep_ordering) +add_jsonschema_test(format/pass_configuration_file_sets) +add_jsonschema_test(format/pass_configuration_file_resolve) +add_jsonschema_test(format/pass_configuration_file_unknown_properties) +add_jsonschema_test(format/pass_configuration_file_dependencies) +add_jsonschema_test(format/pass_configuration_file_without_dialect) +add_jsonschema_test(format/pass_configuration_file_custom_name) +add_jsonschema_test(format/pass_configuration_file_nested) add_jsonschema_test(format/pass_bundled_metaschema) add_jsonschema_test(format/pass_check_color_always_verbose) add_jsonschema_test(format/pass_check_color_never_verbose) diff --git a/test/format/fail_check_configuration_file.clitest b/test/format/fail_check_configuration_file.clitest new file mode 100644 index 000000000..f484a92c6 --- /dev/null +++ b/test/format/fail_check_configuration_file.clitest @@ -0,0 +1,29 @@ +WRITE jsonschema.json UNTIL EOF +{ + "baseUri": "https://example.com", + "title": "My project" +} +EOF + +// Validation failure +RUN fmt jsonschema.json --check STDIN /dev/null IN . INTO result.txt EXPECTING 2 + +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 diff --git a/test/format/fail_check_configuration_file_json.clitest b/test/format/fail_check_configuration_file_json.clitest new file mode 100644 index 000000000..0ea932adc --- /dev/null +++ b/test/format/fail_check_configuration_file_json.clitest @@ -0,0 +1,49 @@ +WRITE jsonschema.json UNTIL EOF +{ + "baseUri": "https://example.com", + "title": "My project" +} +EOF + +// 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 diff --git a/test/format/pass_check_configuration_file.clitest b/test/format/pass_check_configuration_file.clitest new file mode 100644 index 000000000..4ee81062f --- /dev/null +++ b/test/format/pass_check_configuration_file.clitest @@ -0,0 +1,25 @@ +WRITE jsonschema.json UNTIL EOF +{ + "title": "My project", + "baseUri": "https://example.com" +} +EOF + +RUN fmt jsonschema.json --check STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "My project", + "baseUri": "https://example.com" +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_0.txt diff --git a/test/format/pass_configuration_file.clitest b/test/format/pass_configuration_file.clitest new file mode 100644 index 000000000..4a0d8ea15 --- /dev/null +++ b/test/format/pass_configuration_file.clitest @@ -0,0 +1,25 @@ +WRITE jsonschema.json UNTIL EOF +{ + "baseUri": "https://example.com", + "title": "My project" +} +EOF + +RUN fmt jsonschema.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "My project", + "baseUri": "https://example.com" +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_0.txt diff --git a/test/format/pass_configuration_file_custom_name.clitest b/test/format/pass_configuration_file_custom_name.clitest new file mode 100644 index 000000000..dd91af425 --- /dev/null +++ b/test/format/pass_configuration_file_custom_name.clitest @@ -0,0 +1,25 @@ +WRITE my-config.json UNTIL EOF +{ + "baseUri": "https://example.com", + "title": "My project" +} +EOF + +RUN fmt my-config.json --configuration my-config.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/my-config.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "My project", + "baseUri": "https://example.com" +} +EOF + +COMPARE my-config.json AGAINST expected_file_0.txt diff --git a/test/format/pass_configuration_file_dependencies.clitest b/test/format/pass_configuration_file_dependencies.clitest new file mode 100644 index 000000000..2d55b5274 --- /dev/null +++ b/test/format/pass_configuration_file_dependencies.clitest @@ -0,0 +1,35 @@ +WRITE jsonschema.json UNTIL EOF +{ + "title": "My project", + "lint": { + "exclude": [ "apple" ] + }, + "dependencies": { + "https://example.com/remote": "./vendor/remote.json" + } +} +EOF + +RUN fmt jsonschema.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "My project", + "dependencies": { + "https://example.com/remote": "./vendor/remote.json" + }, + "lint": { + "exclude": [ "apple" ] + } +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_0.txt diff --git a/test/format/pass_configuration_file_keep_ordering.clitest b/test/format/pass_configuration_file_keep_ordering.clitest new file mode 100644 index 000000000..654470fbd --- /dev/null +++ b/test/format/pass_configuration_file_keep_ordering.clitest @@ -0,0 +1,25 @@ +WRITE jsonschema.json UNTIL EOF +{ + "baseUri": "https://example.com", + "title": "My project" +} +EOF + +RUN fmt jsonschema.json --keep-ordering STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "baseUri": "https://example.com", + "title": "My project" +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_0.txt diff --git a/test/format/pass_configuration_file_nested.clitest b/test/format/pass_configuration_file_nested.clitest new file mode 100644 index 000000000..4da6f965b --- /dev/null +++ b/test/format/pass_configuration_file_nested.clitest @@ -0,0 +1,41 @@ +MAKE DIRECTORY nested + +WRITE jsonschema.json UNTIL EOF +{ + "title": "The root project" +} +EOF + +WRITE nested/jsonschema.json UNTIL EOF +{ + "baseUri": "https://example.com", + "title": "The nested project" +} +EOF + +RUN fmt nested/jsonschema.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/nested/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "The nested project", + "baseUri": "https://example.com" +} +EOF + +COMPARE nested/jsonschema.json AGAINST expected_file_0.txt + +WRITE expected_file_1.txt UNTIL EOF +{ + "title": "The root project" +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_1.txt diff --git a/test/format/pass_configuration_file_resolve.clitest b/test/format/pass_configuration_file_resolve.clitest new file mode 100644 index 000000000..c165a101b --- /dev/null +++ b/test/format/pass_configuration_file_resolve.clitest @@ -0,0 +1,39 @@ +WRITE first.json UNTIL EOF +{ "$schema": "https://json-schema.org/draft/2020-12/schema" } +EOF + +WRITE second.json UNTIL EOF +{ "$schema": "https://json-schema.org/draft/2020-12/schema" } +EOF + +WRITE jsonschema.json UNTIL EOF +{ + "resolve": { + "https://example.com/second": "./second.json", + "https://example.com/first": "./first.json" + }, + "title": "My project" +} +EOF + +RUN fmt jsonschema.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "My project", + "resolve": { + "https://example.com/first": "./first.json", + "https://example.com/second": "./second.json" + } +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_0.txt diff --git a/test/format/pass_configuration_file_sets.clitest b/test/format/pass_configuration_file_sets.clitest new file mode 100644 index 000000000..232083237 --- /dev/null +++ b/test/format/pass_configuration_file_sets.clitest @@ -0,0 +1,35 @@ +WRITE jsonschema.json UNTIL EOF +{ + "lint": { + "exclude": [ "zebra", "apple" ], + "rules": [ "./second.json", "./first.json" ] + }, + "ignore": [ "./second", "./first" ], + "extension": [ ".yml", ".json" ], + "title": "My project" +} +EOF + +RUN fmt jsonschema.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "My project", + "extension": [ ".json", ".yml" ], + "ignore": [ "./first", "./second" ], + "lint": { + "rules": [ "./second.json", "./first.json" ], + "exclude": [ "apple", "zebra" ] + } +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_0.txt diff --git a/test/format/pass_configuration_file_unknown_properties.clitest b/test/format/pass_configuration_file_unknown_properties.clitest new file mode 100644 index 000000000..de3432514 --- /dev/null +++ b/test/format/pass_configuration_file_unknown_properties.clitest @@ -0,0 +1,27 @@ +WRITE jsonschema.json UNTIL EOF +{ + "zebra": true, + "title": "My project", + "apple": false +} +EOF + +RUN fmt jsonschema.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "My project", + "apple": false, + "zebra": true +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_0.txt diff --git a/test/format/pass_configuration_file_without_dialect.clitest b/test/format/pass_configuration_file_without_dialect.clitest new file mode 100644 index 000000000..3d125de54 --- /dev/null +++ b/test/format/pass_configuration_file_without_dialect.clitest @@ -0,0 +1,26 @@ +WRITE jsonschema.json UNTIL EOF +{ + "baseUri": "https://example.com", + "title": "My project" +} +EOF + +RUN fmt STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> warning: Recursively processing every file in [CWD] as the configuration file does not set an explicit path +2> Interpreting as a configuration file: [CWD]/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "My project", + "baseUri": "https://example.com" +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_0.txt From 9bf1d5e70aef174fbbf344ebac0ee920de48d49d Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sat, 10 Oct 2026 13:06:04 -0300 Subject: [PATCH 2/3] More Signed-off-by: Juan Cruz Viotti --- test/CMakeLists.txt | 1 + ...ass_configuration_file_unformatted.clitest | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 test/format/pass_configuration_file_unformatted.clitest diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7870ed9c9..00b6d45d2 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -146,6 +146,7 @@ add_jsonschema_test(format/pass_test_document_explicit_dialect) add_jsonschema_test(format/pass_test_document_directory) add_jsonschema_test(format/pass_test_document_schema_keyword) add_jsonschema_test(format/pass_configuration_file) +add_jsonschema_test(format/pass_configuration_file_unformatted) add_jsonschema_test(format/pass_check_configuration_file) add_jsonschema_test(format/fail_check_configuration_file) add_jsonschema_test(format/fail_check_configuration_file_json) diff --git a/test/format/pass_configuration_file_unformatted.clitest b/test/format/pass_configuration_file_unformatted.clitest new file mode 100644 index 000000000..91fd0a26a --- /dev/null +++ b/test/format/pass_configuration_file_unformatted.clitest @@ -0,0 +1,34 @@ +WRITE jsonschema.json UNTIL EOF +{ "lint":{"exclude":["zebra","apple"],"rules":["./rule.json"]}, + "extension":[".yml",".json"],"title":"My project", + "baseUri": "https://example.com", + "dependencies": {"https://example.com/b":"./b.json","https://example.com/a":"./a.json"} } +EOF + +RUN fmt jsonschema.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +REPLACE $CWD WITH '[CWD]' IN result.txt + +WRITE expected.txt UNTIL EOF +2> Interpreting as a configuration file: [CWD]/jsonschema.json +EOF + +COMPARE result.txt AGAINST expected.txt + +WRITE expected_file_0.txt UNTIL EOF +{ + "title": "My project", + "baseUri": "https://example.com", + "extension": [ ".json", ".yml" ], + "dependencies": { + "https://example.com/a": "./a.json", + "https://example.com/b": "./b.json" + }, + "lint": { + "rules": [ "./rule.json" ], + "exclude": [ "apple", "zebra" ] + } +} +EOF + +COMPARE jsonschema.json AGAINST expected_file_0.txt From 08e3568ac36751175cb16bd400590228075f3d2c Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sat, 10 Oct 2026 14:05:29 -0300 Subject: [PATCH 3/3] Fix Signed-off-by: Juan Cruz Viotti --- src/command_fmt.cc | 33 ++++++++++++----- test/CMakeLists.txt | 1 + .../fail_check_configuration_file.clitest | 4 +++ ...fail_check_configuration_file_json.clitest | 4 +++ .../format/pass_configuration_file_symlink.sh | 35 +++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) create mode 100755 test/format/pass_configuration_file_symlink.sh diff --git a/src/command_fmt.cc b/src/command_fmt.cc index 51a9d5318..bb4eb1318 100644 --- a/src/command_fmt.cc +++ b/src/command_fmt.cc @@ -3,11 +3,13 @@ #include #include -#include // std::cerr, std::cout -#include // std::ostringstream -#include // std::string -#include // std::string_view -#include // std::move, std::unreachable +#include // std::filesystem +#include // std::cerr, std::cout +#include // std::ostringstream +#include // std::string +#include // std::string_view +#include // std::error_code +#include // std::move, std::unreachable #include "command.h" #include "error.h" @@ -152,6 +154,21 @@ 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}; @@ -293,9 +310,9 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) // 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() && - configuration_path.value() == - entry.resolution_base}; + 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()) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 00b6d45d2..c92f8ba93 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -158,6 +158,7 @@ add_jsonschema_test(format/pass_configuration_file_dependencies) add_jsonschema_test(format/pass_configuration_file_without_dialect) add_jsonschema_test(format/pass_configuration_file_custom_name) add_jsonschema_test(format/pass_configuration_file_nested) +add_jsonschema_test_unix(format/pass_configuration_file_symlink) add_jsonschema_test(format/pass_bundled_metaschema) add_jsonschema_test(format/pass_check_color_always_verbose) add_jsonschema_test(format/pass_check_color_never_verbose) diff --git a/test/format/fail_check_configuration_file.clitest b/test/format/fail_check_configuration_file.clitest index f484a92c6..5a9a04939 100644 --- a/test/format/fail_check_configuration_file.clitest +++ b/test/format/fail_check_configuration_file.clitest @@ -5,6 +5,8 @@ WRITE jsonschema.json UNTIL EOF } EOF +COPY jsonschema.json TO original.json + // Validation failure RUN fmt jsonschema.json --check STDIN /dev/null IN . INTO result.txt EXPECTING 2 @@ -27,3 +29,5 @@ WRITE expected.txt UNTIL EOF EOF COMPARE result.txt AGAINST expected.txt + +COMPARE jsonschema.json AGAINST original.json diff --git a/test/format/fail_check_configuration_file_json.clitest b/test/format/fail_check_configuration_file_json.clitest index 0ea932adc..f44764cd1 100644 --- a/test/format/fail_check_configuration_file_json.clitest +++ b/test/format/fail_check_configuration_file_json.clitest @@ -5,6 +5,8 @@ WRITE jsonschema.json UNTIL EOF } EOF +COPY jsonschema.json TO original.json + // Validation failure RUN fmt jsonschema.json --check --json STDIN /dev/null IN . INTO result.txt EXPECTING 2 @@ -47,3 +49,5 @@ WRITE expected.txt UNTIL EOF EOF COMPARE result.txt AGAINST expected.txt + +COMPARE jsonschema.json AGAINST original.json diff --git a/test/format/pass_configuration_file_symlink.sh b/test/format/pass_configuration_file_symlink.sh new file mode 100755 index 000000000..599b5f752 --- /dev/null +++ b/test/format/pass_configuration_file_symlink.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/config.json" +{ + "baseUri": "https://example.com", + "title": "My project" +} +EOF + +ln -s config.json "$TMP/jsonschema.json" + +cd "$TMP" +"$1" fmt jsonschema.json > "$TMP/output.txt" 2>&1 + +cat << EOF > "$TMP/expected.txt" +Interpreting as a configuration file: $(realpath "$TMP")/config.json +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" + +cat << 'EOF' > "$TMP/expected_file.txt" +{ + "title": "My project", + "baseUri": "https://example.com" +} +EOF + +diff "$TMP/config.json" "$TMP/expected_file.txt"