diff --git a/src/command_fmt.cc b/src/command_fmt.cc index 02dc82bd4..9c7d325dc 100644 --- a/src/command_fmt.cc +++ b/src/command_fmt.cc @@ -249,6 +249,12 @@ auto sourcemeta::jsonschema::fmt(const sourcemeta::core::Options &options) entry.resolution_base}; } + if (entry.multidocument) { + throw MultiDocumentInputError{ + "This command does not support input with multiple documents", + entry.resolution_base}; + } + if (!entry.second.is_object() && !entry.second.is_boolean()) { throw NotSchemaError{entry.resolution_base}; } diff --git a/src/command_lint.cc b/src/command_lint.cc index 933172b5b..8efc47dde 100644 --- a/src/command_lint.cc +++ b/src/command_lint.cc @@ -491,6 +491,13 @@ auto sourcemeta::jsonschema::lint(const sourcemeta::core::Options &options) entry.resolution_base}; } + if (entry.multidocument) { + throw MultiDocumentInputError{ + "The --fix option is not supported for input with multiple " + "documents", + entry.resolution_base}; + } + if (!entry.second.is_object() && !entry.second.is_boolean()) { throw NotSchemaError{entry.resolution_base}; } diff --git a/src/error.h b/src/error.h index 0eb53d68e..a2ba9468e 100644 --- a/src/error.h +++ b/src/error.h @@ -170,6 +170,20 @@ class YAMLInputError : public std::runtime_error { std::filesystem::path path_; }; +class MultiDocumentInputError : public std::runtime_error { +public: + MultiDocumentInputError(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 UnsupportedOpenAPIFormatError : public std::runtime_error { public: UnsupportedOpenAPIFormatError() @@ -1162,6 +1176,10 @@ inline auto try_catch(const sourcemeta::core::Options &options, 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); + return EXIT_NOT_SUPPORTED; } catch ( const sourcemeta::core::FileError &error) { const auto is_json{options.contains("json")}; diff --git a/src/input.h b/src/input.h index e553b7e5d..f0d763aac 100644 --- a/src/input.h +++ b/src/input.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include "configuration.h" @@ -25,6 +26,7 @@ #include // std::set #include // std::ostringstream, std::istringstream #include // std::string +#include // std::string_view #include // std::unordered_set #include // std::vector @@ -146,6 +148,12 @@ struct ParsedJSON { bool yaml{false}; }; +struct MultiDocEntry { + sourcemeta::core::JSON document; + sourcemeta::core::PointerPositionTracker positions; + std::shared_ptr> property_storage; +}; + inline auto make_position_callback(sourcemeta::core::PointerPositionTracker &tracker, std::shared_ptr> &storage) @@ -162,6 +170,65 @@ make_position_callback(sourcemeta::core::PointerPositionTracker &tracker, }; } +// RFC 8259 Section 2: "ws = *( %x20 / %x09 / %x0A / %x0D )", which is narrower +// than the ASCII whitespace that sourcemeta::core::trim strips by default +inline auto is_json_whitespace(const char character) noexcept -> bool { + return character == ' ' || character == '\t' || character == '\n' || + character == '\r'; +} + +// Whether the buffer the stream reads from carries nothing but JSON whitespace +// past the point the parser stopped at +inline auto at_end_of_stream(const std::string &input, std::istream &stream) + -> bool { + const auto consumed{stream.tellg()}; + if (consumed < 0) { + return false; + } + + const auto offset{static_cast(consumed)}; + return offset >= input.size() || + sourcemeta::core::strip_left(std::string_view{input}.substr(offset), + is_json_whitespace) + .empty(); +} + +// Parse every YAML document the stream holds, keeping line numbers running +// across the documents that follow the first +inline auto read_yaml_documents(std::istream &stream) + -> std::vector { + std::vector documents; + std::uint64_t line_offset{0}; + std::uint64_t max_line{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]( + 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); + 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; + } + + return documents; +} + inline auto read_file(const std::filesystem::path &path) -> ParsedJSON { const auto extension{path.extension()}; sourcemeta::core::PointerPositionTracker positions; @@ -204,38 +271,211 @@ inline auto read_file(const std::filesystem::path &path) -> ParsedJSON { } } -// Read stdin into a buffer and try JSON first, then YAML +// 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) + -> std::vector { + std::istringstream stream{input}; + std::vector documents; + + try { + documents = read_yaml_documents(stream); + } catch (...) { + throw sourcemeta::core::JSONFileParseError(stdin_path(), json_error); + } + + if (documents.empty()) { + throw sourcemeta::core::JSONFileParseError(stdin_path(), json_error); + } + + std::vector result; + result.reserve(documents.size()); + for (auto &entry : documents) { + result.push_back({.document = std::move(entry.document), + .positions = std::move(entry.positions), + .property_storage = std::move(entry.property_storage), + .yaml = true}); + } + + return result; +} + +// 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) + -> std::vector { + std::istringstream json_stream{input}; + sourcemeta::core::PointerPositionTracker positions; + auto property_storage = std::make_shared>(); + sourcemeta::core::JSON document{sourcemeta::core::JSON{nullptr}}; + auto callback = make_position_callback(positions, property_storage); + + try { + sourcemeta::core::parse_json(json_stream, document, callback); + } catch (const sourcemeta::core::JSONParseError &json_error) { + return read_stdin_yaml(input, json_error); + } + + if (at_end_of_stream(input, json_stream)) { + std::vector result; + result.push_back({.document = std::move(document), + .positions = std::move(positions), + .property_storage = std::move(property_storage)}); + return result; + } + + // Standard input that carries more than one JSON document is JSONL, unless + // what follows the first document only reads as YAML, as a YAML stream may + // open with a document that is JSON and separate the rest with markers + std::vector result; + std::istringstream jsonl_stream{input}; + try { + for (const auto &entry : sourcemeta::core::JSONL{jsonl_stream}) { + // TODO: Get real positions for JSONL + sourcemeta::core::PointerPositionTracker jsonl_positions; + result.push_back({.document = entry, + .positions = std::move(jsonl_positions), + .property_storage = {}}); + } + } catch (const sourcemeta::core::JSONParseError &error) { + return read_stdin_yaml(input, error); + } + + return result; +} + +// Read the single document standard input is expected to hold inline auto read_from_stdin(std::string *raw_input = nullptr) -> ParsedJSON { const auto input{sourcemeta::core::read_stdin()}; if (raw_input != nullptr) { *raw_input = input; } - try { - std::istringstream json_stream{input}; - sourcemeta::core::PointerPositionTracker positions; - auto property_storage = std::make_shared>(); - sourcemeta::core::JSON document{sourcemeta::core::JSON{nullptr}}; - auto callback = make_position_callback(positions, property_storage); - sourcemeta::core::parse_json(json_stream, document, callback); - return {.document = std::move(document), - .positions = std::move(positions), - .property_storage = std::move(property_storage)}; - } catch (const sourcemeta::core::JSONParseError &json_error) { + auto documents{read_stdin_documents(input)}; + assert(!documents.empty()); + if (documents.size() > 1) { + throw MultiDocumentInputError{"This command does not support reading " + "multiple documents from standard input", + stdin_path()}; + } + + return std::move(documents.front()); +} + +inline auto +handle_input_file(const std::filesystem::path &canonical, + std::vector &result, + const sourcemeta::core::Options &options) -> void { + const auto canonical_string{canonical.generic_string()}; + if (canonical_string.ends_with(".jsonl.gz")) { + LOG_VERBOSE(options) << "Interpreting input as GZIP-compressed JSONL: " + << canonical_string << "\n"; + std::ifstream stream{sourcemeta::core::canonical(canonical), + std::ios::binary}; + stream.exceptions(std::ifstream::badbit); + std::size_t index{0}; + try { + for (const auto &document : sourcemeta::core::JSONL{ + stream, sourcemeta::core::JSONL::Mode::GZIP}) { + // TODO: Get real positions for JSONL + sourcemeta::core::PointerPositionTracker positions; + result.push_back({.first = canonical_string, + .resolution_base = canonical, + .second = document, + .positions = std::move(positions), + .index = index, + .multidocument = true, + .property_storage = {}}); + index += 1; + } + } catch (const sourcemeta::core::GZIPError &error) { + throw sourcemeta::core::FileError( + canonical, error.what()); + } catch (const sourcemeta::core::JSONParseError &error) { + throw sourcemeta::core::JSONFileParseError(canonical, error); + } + + if (index == 0) { + LOG_WARNING() << "The JSONL file is empty\n"; + } + } else if (canonical.extension() == ".jsonl") { + LOG_VERBOSE(options) << "Interpreting input as JSONL: " << canonical_string + << "\n"; + auto stream{sourcemeta::core::read_file(canonical)}; + std::size_t index{0}; + try { + for (const auto &document : sourcemeta::core::JSONL{stream}) { + // TODO: Get real positions for JSONL + sourcemeta::core::PointerPositionTracker positions; + result.push_back({.first = canonical_string, + .resolution_base = canonical, + .second = document, + .positions = std::move(positions), + .index = index, + .multidocument = true, + .property_storage = {}}); + index += 1; + } + } catch (const sourcemeta::core::JSONParseError &error) { + throw sourcemeta::core::JSONFileParseError(canonical, error); + } + + if (index == 0) { + LOG_WARNING() << "The JSONL file is empty\n"; + } + } else if (canonical.extension() == ".yaml" || + canonical.extension() == ".yml") { + if (std::filesystem::is_empty(canonical)) { + return; + } + auto stream{sourcemeta::core::read_file(canonical)}; + std::vector documents; try { - std::istringstream yaml_stream{input}; - sourcemeta::core::PointerPositionTracker positions; - auto property_storage = std::make_shared>(); - sourcemeta::core::JSON document{sourcemeta::core::JSON{nullptr}}; - auto callback = make_position_callback(positions, property_storage); - sourcemeta::core::parse_yaml(yaml_stream, document, callback); - return {.document = std::move(document), - .positions = std::move(positions), - .property_storage = std::move(property_storage), - .yaml = true}; - } catch (...) { - throw sourcemeta::core::JSONFileParseError(stdin_path(), json_error); + documents = read_yaml_documents(stream); + } catch (const sourcemeta::core::YAMLParseError &error) { + throw sourcemeta::core::YAMLFileParseError{canonical, error}; + } + + if (documents.size() > 1) { + LOG_VERBOSE(options) << "Interpreting input as YAML multi-document: " + << 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)}); + index += 1; + } + } else if (documents.size() == 1) { + result.push_back( + {.first = canonical_string, + .resolution_base = canonical, + .second = std::move(documents.front().document), + .positions = std::move(documents.front().positions), + .yaml = true, + .property_storage = std::move(documents.front().property_storage)}); } + } else { + if (std::filesystem::is_regular_file(canonical) && + std::filesystem::is_empty(canonical)) { + return; + } + // TODO: Print a verbose message for what is getting parsed + auto parsed{read_file(canonical)}; + result.push_back({.first = canonical_string, + .resolution_base = canonical, + .second = std::move(parsed.document), + .positions = std::move(parsed.positions), + .yaml = parsed.yaml, + .property_storage = std::move(parsed.property_storage)}); } } @@ -246,15 +486,33 @@ handle_json_entry(const std::filesystem::path &entry_path, std::vector &result, const sourcemeta::core::Options &options) -> void { if (entry_path == "-") { - auto parsed{read_from_stdin()}; + auto documents{read_stdin_documents(sourcemeta::core::read_stdin())}; + assert(!documents.empty()); const auto path{stdin_path()}; - result.push_back({.first = std::string{STDIN_DEFAULT_ID}, - .resolution_base = path, - .second = std::move(parsed.document), - .positions = std::move(parsed.positions), - .yaml = parsed.yaml, - .from_stdin = true, - .property_storage = std::move(parsed.property_storage)}); + const auto multidocument{documents.size() > 1}; + + if (multidocument) { + LOG_VERBOSE(options) + << (documents.front().yaml + ? "Interpreting standard input as YAML multi-document\n" + : "Interpreting standard input as JSONL\n"); + } + + std::size_t index{0}; + for (auto &document : documents) { + result.push_back( + {.first = std::string{STDIN_DEFAULT_ID}, + .resolution_base = path, + .second = std::move(document.document), + .positions = std::move(document.positions), + .index = index, + .multidocument = multidocument, + .yaml = document.yaml, + .from_stdin = true, + .property_storage = std::move(document.property_storage)}); + index += 1; + } + return; } @@ -278,15 +536,7 @@ handle_json_entry(const std::filesystem::path &entry_path, continue; } - // TODO: Print a verbose message for what is getting parsed - auto parsed{read_file(canonical)}; - result.push_back( - {.first = canonical.generic_string(), - .resolution_base = std::move(canonical), - .second = std::move(parsed.document), - .positions = std::move(parsed.positions), - .yaml = parsed.yaml, - .property_storage = std::move(parsed.property_storage)}); + handle_input_file(canonical, result, options); } } } else { @@ -296,149 +546,7 @@ handle_json_entry(const std::filesystem::path &entry_path, return sourcemeta::core::is_under_path(canonical, prefix); })) { - const auto canonical_string{canonical.generic_string()}; - if (canonical_string.ends_with(".jsonl.gz")) { - LOG_VERBOSE(options) << "Interpreting input as GZIP-compressed JSONL: " - << canonical_string << "\n"; - std::ifstream stream{sourcemeta::core::canonical(canonical), - std::ios::binary}; - stream.exceptions(std::ifstream::badbit); - std::size_t index{0}; - try { - for (const auto &document : sourcemeta::core::JSONL{ - stream, sourcemeta::core::JSONL::Mode::GZIP}) { - // TODO: Get real positions for JSONL - sourcemeta::core::PointerPositionTracker positions; - result.push_back({.first = canonical.generic_string(), - .resolution_base = canonical, - .second = document, - .positions = std::move(positions), - .index = index, - .multidocument = true, - .property_storage = {}}); - index += 1; - } - } catch (const sourcemeta::core::GZIPError &error) { - throw sourcemeta::core::FileError( - canonical, error.what()); - } catch (const sourcemeta::core::JSONParseError &error) { - throw sourcemeta::core::JSONFileParseError(canonical, error); - } - - if (index == 0) { - LOG_WARNING() << "The JSONL file is empty\n"; - } - } else if (canonical.extension() == ".jsonl") { - LOG_VERBOSE(options) - << "Interpreting input as JSONL: " << canonical.generic_string() - << "\n"; - auto stream{sourcemeta::core::read_file(canonical)}; - std::size_t index{0}; - try { - for (const auto &document : sourcemeta::core::JSONL{stream}) { - // TODO: Get real positions for JSONL - sourcemeta::core::PointerPositionTracker positions; - result.push_back({.first = canonical.generic_string(), - .resolution_base = canonical, - .second = document, - .positions = std::move(positions), - .index = index, - .multidocument = true, - .property_storage = {}}); - index += 1; - } - } catch (const sourcemeta::core::JSONParseError &error) { - throw sourcemeta::core::JSONFileParseError(canonical, error); - } - - if (index == 0) { - LOG_WARNING() << "The JSONL file is empty\n"; - } - } else if (canonical.extension() == ".yaml" || - canonical.extension() == ".yml") { - if (std::filesystem::is_empty(canonical)) { - return; - } - auto stream{sourcemeta::core::read_file(canonical)}; - struct MultiDocEntry { - sourcemeta::core::JSON document; - sourcemeta::core::PointerPositionTracker positions; - std::shared_ptr> property_storage; - }; - std::vector documents; - std::uint64_t line_offset{0}; - std::uint64_t max_line{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](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}}; - try { - sourcemeta::core::parse_yaml(stream, document, callback); - } catch (const sourcemeta::core::YAMLParseError &error) { - throw sourcemeta::core::YAMLFileParseError{canonical, error}; - } - 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; - } - - if (documents.size() > 1) { - LOG_VERBOSE(options) << "Interpreting input as YAML multi-document: " - << canonical.generic_string() << "\n"; - std::size_t index{0}; - for (auto &entry : documents) { - result.push_back( - {.first = canonical.generic_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)}); - index += 1; - } - } else if (documents.size() == 1) { - result.push_back({.first = canonical.generic_string(), - .resolution_base = canonical, - .second = std::move(documents.front().document), - .positions = std::move(documents.front().positions), - .yaml = true, - .property_storage = - std::move(documents.front().property_storage)}); - } - } else { - if (std::filesystem::is_regular_file(canonical) && - std::filesystem::is_empty(canonical)) { - return; - } - // TODO: Print a verbose message for what is getting parsed - auto parsed{read_file(canonical)}; - result.push_back( - {.first = canonical.generic_string(), - .resolution_base = canonical, - .second = std::move(parsed.document), - .positions = std::move(parsed.positions), - .yaml = parsed.yaml, - .property_storage = std::move(parsed.property_storage)}); - } + handle_input_file(canonical, result, options); } } } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0a7693188..9df44d392 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -88,6 +88,10 @@ add_jsonschema_test(format/pass_default_dialect_config_relative) add_jsonschema_test(format/pass_bignum_real) add_jsonschema_test(format/pass_check_single_json) add_jsonschema_test(format/fail_check_single_json) +add_jsonschema_test(format/fail_jsonl) +add_jsonschema_test(format/fail_jsonl_json) +add_jsonschema_test(format/fail_json_multidocument_stdin) +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) @@ -277,6 +281,11 @@ add_jsonschema_test(validate/fail_many_verbose) add_jsonschema_test(validate/fail_many_continue_verbose) add_jsonschema_test(validate/fail_yaml) add_jsonschema_test(validate/pass_yaml_multi) +add_jsonschema_test(validate/pass_yaml_multi_directory) +add_jsonschema_test(validate/pass_yaml_multi_stdin) +add_jsonschema_test(validate/pass_yaml_multi_stdin_json_first) +add_jsonschema_test(validate/pass_jsonl_directory) +add_jsonschema_test(validate/pass_jsonl_stdin) add_jsonschema_test(validate/pass_yaml_multi_verbose) add_jsonschema_test(validate/pass_yaml_multi_json) add_jsonschema_test(validate/fail_yaml_multi_one) @@ -685,6 +694,7 @@ add_jsonschema_test(bundle/pass_config_ignore) add_jsonschema_test(bundle/pass_ref_in_bundled_resolves_against_id) add_jsonschema_test(bundle/pass_resolve_deduplicate_embedded) add_jsonschema_test(bundle/pass_stdin) +add_jsonschema_test(bundle/fail_stdin_multidocument) add_jsonschema_test(bundle/fail_stdin_not_schema) add_jsonschema_test(bundle/fail_stdin_invalid_json) add_jsonschema_test(bundle/fail_invalid_header) @@ -1027,6 +1037,9 @@ add_jsonschema_test(lint/fail_lint_config_exclude_invalid_entry) add_jsonschema_test(lint/pass_stdin_lint) add_jsonschema_test(lint/pass_stdin_lint_verbose) add_jsonschema_test(lint/pass_stdin_fix) +add_jsonschema_test(lint/fail_lint_fix_jsonl) +add_jsonschema_test(lint/fail_lint_stdin_multidocument) +add_jsonschema_test(lint/fail_lint_fix_stdin_multidocument) add_jsonschema_test(lint/pass_stdin_fix_format) add_jsonschema_test(lint/fail_stdin_lint) add_jsonschema_test(lint/fail_stdin_duplicate) diff --git a/test/bundle/fail_stdin_multidocument.clitest b/test/bundle/fail_stdin_multidocument.clitest new file mode 100644 index 000000000..2495d444d --- /dev/null +++ b/test/bundle/fail_stdin_multidocument.clitest @@ -0,0 +1,30 @@ +WRITE stdin_input 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 bundle - STDIN stdin_input IN . INTO result_0.txt EXPECTING 3 + +WRITE expected_0.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_0.txt AGAINST expected_0.txt + +// Not supported +RUN bundle - --json STDIN stdin_input IN . INTO result_1.txt EXPECTING 3 + +WRITE expected_1.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_1.txt AGAINST expected_1.txt diff --git a/test/format/fail_json_multidocument_stdin.clitest b/test/format/fail_json_multidocument_stdin.clitest new file mode 100644 index 000000000..d9156adb8 --- /dev/null +++ b/test/format/fail_json_multidocument_stdin.clitest @@ -0,0 +1,26 @@ +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/fail_jsonl.clitest b/test/format/fail_jsonl.clitest new file mode 100644 index 000000000..924786887 --- /dev/null +++ b/test/format/fail_jsonl.clitest @@ -0,0 +1,23 @@ +WRITE schemas.jsonl 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 schemas.jsonl 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]/schemas.jsonl +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 schemas.jsonl AGAINST expected_file_0.txt diff --git a/test/format/fail_jsonl_json.clitest b/test/format/fail_jsonl_json.clitest new file mode 100644 index 000000000..54c98c235 --- /dev/null +++ b/test/format/fail_jsonl_json.clitest @@ -0,0 +1,18 @@ +WRITE schemas.jsonl 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 schemas.jsonl --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]/schemas.jsonl" +1> } +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/test/format/fail_stdin_non_json_whitespace.clitest b/test/format/fail_stdin_non_json_whitespace.clitest new file mode 100644 index 000000000..b720f5562 --- /dev/null +++ b/test/format/fail_stdin_non_json_whitespace.clitest @@ -0,0 +1,29 @@ +WRITE stdin_input UNTIL EOF +{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string" } +EOF + +// Other input error +RUN fmt - STDIN stdin_input IN . INTO result.txt EXPECTING 6 + +WRITE expected.txt UNTIL EOF +2> error: Failed to parse the JSON document +2> at line 1 +2> at column 80 +2> at file path tag:sourcemeta.com,2026:jsonschema/stdin +EOF + +COMPARE result.txt AGAINST expected.txt + +// Other input error +RUN fmt - --json STDIN stdin_input IN . INTO result_json.txt EXPECTING 6 + +WRITE expected_json.txt UNTIL EOF +1> { +1> "error": "Failed to parse the JSON document", +1> "line": 1, +1> "column": 80, +1> "filePath": "tag:sourcemeta.com,2026:jsonschema/stdin" +1> } +EOF + +COMPARE result_json.txt AGAINST expected_json.txt diff --git a/test/lint/fail_lint_fix_jsonl.clitest b/test/lint/fail_lint_fix_jsonl.clitest new file mode 100644 index 000000000..e68222664 --- /dev/null +++ b/test/lint/fail_lint_fix_jsonl.clitest @@ -0,0 +1,37 @@ +WRITE schemas.jsonl 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 lint schemas.jsonl --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 input with multiple documents +2> at file path [CWD]/schemas.jsonl +EOF + +COMPARE result_0.txt AGAINST expected_0.txt + +// Not supported +RUN lint schemas.jsonl --fix --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 input with multiple documents", +1> "filePath": "[CWD]/schemas.jsonl" +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 schemas.jsonl AGAINST expected_file_0.txt diff --git a/test/lint/fail_lint_fix_stdin_multidocument.clitest b/test/lint/fail_lint_fix_stdin_multidocument.clitest new file mode 100644 index 000000000..95a0b3e44 --- /dev/null +++ b/test/lint/fail_lint_fix_stdin_multidocument.clitest @@ -0,0 +1,26 @@ +WRITE stdin_input UNTIL EOF +{ "$schema": "http://json-schema.org/draft-06/schema#", "title": "A", "description": "d", "examples": [ "foo" ], "type": "string", "const": "foo" } +{ "$schema": "http://json-schema.org/draft-06/schema#", "title": "B", "description": "d", "examples": [ "bar" ], "type": "string", "const": "bar" } +EOF + +// Not supported +RUN lint - --fix STDIN stdin_input IN . INTO result_0.txt EXPECTING 3 + +WRITE expected_0.txt UNTIL EOF +2> error: The --fix option is not supported for input with multiple documents +2> at file path tag:sourcemeta.com,2026:jsonschema/stdin +EOF + +COMPARE result_0.txt AGAINST expected_0.txt + +// Not supported +RUN lint - --fix --json STDIN stdin_input IN . INTO result_1.txt EXPECTING 3 + +WRITE expected_1.txt UNTIL EOF +1> { +1> "error": "The --fix option is not supported for input with multiple documents", +1> "filePath": "tag:sourcemeta.com,2026:jsonschema/stdin" +1> } +EOF + +COMPARE result_1.txt AGAINST expected_1.txt diff --git a/test/lint/fail_lint_stdin_multidocument.clitest b/test/lint/fail_lint_stdin_multidocument.clitest new file mode 100644 index 000000000..d92302fd5 --- /dev/null +++ b/test/lint/fail_lint_stdin_multidocument.clitest @@ -0,0 +1,82 @@ +WRITE stdin_input UNTIL EOF +--- +$schema: http://json-schema.org/draft-06/schema# +title: A +description: d +examples: [ foo ] +type: string +const: foo +--- +$schema: http://json-schema.org/draft-06/schema# +title: B +description: d +examples: [ bar ] +type: string +const: bar +EOF + +// Validation failure +RUN lint - STDIN stdin_input IN . INTO result_0.txt EXPECTING 2 + +WRITE expected_0.txt UNTIL EOF +1> tag:sourcemeta.com,2026:jsonschema/stdin:7:1: +1> Setting `type` alongside `const` is considered an anti-pattern, as the constant already implies its respective type (const_with_type) +1> at location "/const" +1> tag:sourcemeta.com,2026:jsonschema/stdin:6:1: +1> Setting `type` alongside `const` is considered an anti-pattern, as the constant already implies its respective type (const_with_type) +1> at location "/type" +1> tag:sourcemeta.com,2026:jsonschema/stdin:14:1: +1> Setting `type` alongside `const` is considered an anti-pattern, as the constant already implies its respective type (const_with_type) +1> at location "/const" +1> tag:sourcemeta.com,2026:jsonschema/stdin:13:1: +1> Setting `type` alongside `const` is considered an anti-pattern, as the constant already implies its respective type (const_with_type) +1> at location "/type" +EOF + +COMPARE result_0.txt AGAINST expected_0.txt + +// Validation failure +RUN lint - --json STDIN stdin_input IN . INTO result_1.txt EXPECTING 2 + +WRITE expected_1.txt UNTIL EOF +1> { +1> "valid": false, +1> "health": 0, +1> "errors": [ +1> { +1> "path": "tag:sourcemeta.com,2026:jsonschema/stdin", +1> "id": "const_with_type", +1> "message": "Setting `type` alongside `const` is considered an anti-pattern, as the constant already implies its respective type", +1> "description": null, +1> "schemaLocation": [ "type" ], +1> "position": [ 6, 1, 6, 12 ] +1> }, +1> { +1> "path": "tag:sourcemeta.com,2026:jsonschema/stdin", +1> "id": "const_with_type", +1> "message": "Setting `type` alongside `const` is considered an anti-pattern, as the constant already implies its respective type", +1> "description": null, +1> "schemaLocation": [ "const" ], +1> "position": [ 7, 1, 7, 10 ] +1> }, +1> { +1> "path": "tag:sourcemeta.com,2026:jsonschema/stdin", +1> "id": "const_with_type", +1> "message": "Setting `type` alongside `const` is considered an anti-pattern, as the constant already implies its respective type", +1> "description": null, +1> "schemaLocation": [ "type" ], +1> "position": [ 13, 1, 13, 12 ] +1> }, +1> { +1> "path": "tag:sourcemeta.com,2026:jsonschema/stdin", +1> "id": "const_with_type", +1> "message": "Setting `type` alongside `const` is considered an anti-pattern, as the constant already implies its respective type", +1> "description": null, +1> "schemaLocation": [ "const" ], +1> "position": [ 14, 1, 14, 10 ] +1> } +1> ] +1> } +EOF + +COMPARE result_1.txt AGAINST expected_1.txt diff --git a/test/validate/pass_jsonl_directory.clitest b/test/validate/pass_jsonl_directory.clitest new file mode 100644 index 000000000..8ef2dc1eb --- /dev/null +++ b/test/validate/pass_jsonl_directory.clitest @@ -0,0 +1,27 @@ +MAKE DIRECTORY instances + +WRITE schema.json UNTIL EOF +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Test", + "description": "Test schema", + "properties": { + "name": { + "type": "string" + } + } +} +EOF + +WRITE instances/dataset.jsonl UNTIL EOF +{ "name": "Charlie" } +{ "name": "Diana" } +EOF + +RUN validate schema.json instances --extension jsonl STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_0.txt UNTIL EOF +2> 2 validated, 2 passed, 0 failed +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/validate/pass_jsonl_stdin.clitest b/test/validate/pass_jsonl_stdin.clitest new file mode 100644 index 000000000..4d1192e19 --- /dev/null +++ b/test/validate/pass_jsonl_stdin.clitest @@ -0,0 +1,29 @@ +WRITE schema.json UNTIL EOF +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Test", + "description": "Test schema", + "properties": { + "foo": { + "type": "string" + } + } +} +EOF + +WRITE stdin_input UNTIL EOF +{ "foo": "Charlie" } +{ "foo": "Diana" } +EOF + +RUN validate schema.json - --verbose STDIN stdin_input IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_0.txt UNTIL EOF +2> Interpreting standard input as JSONL +2> ok: tag:sourcemeta.com,2026:jsonschema/stdin (entry #1) +2> ok: tag:sourcemeta.com,2026:jsonschema/stdin (entry #2) +2> +2> 2 validated, 2 passed, 0 failed +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/validate/pass_yaml_multi_directory.clitest b/test/validate/pass_yaml_multi_directory.clitest new file mode 100644 index 000000000..3cca956b5 --- /dev/null +++ b/test/validate/pass_yaml_multi_directory.clitest @@ -0,0 +1,31 @@ +MAKE DIRECTORY instances + +WRITE schema.json UNTIL EOF +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Test", + "description": "Test schema", + "properties": { + "foo": { + "type": "string" + } + } +} +EOF + +WRITE instances/instance.yaml UNTIL EOF +--- +foo: first +--- +foo: second +--- +foo: third +EOF + +RUN validate schema.json instances STDIN /dev/null IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_0.txt UNTIL EOF +2> 3 validated, 3 passed, 0 failed +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/validate/pass_yaml_multi_stdin.clitest b/test/validate/pass_yaml_multi_stdin.clitest new file mode 100644 index 000000000..dc91ff558 --- /dev/null +++ b/test/validate/pass_yaml_multi_stdin.clitest @@ -0,0 +1,34 @@ +WRITE schema.json UNTIL EOF +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Test", + "description": "Test schema", + "properties": { + "foo": { + "type": "string" + } + } +} +EOF + +WRITE stdin_input UNTIL EOF +--- +foo: first +--- +foo: second +--- +foo: third +EOF + +RUN validate schema.json - --verbose STDIN stdin_input IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_0.txt UNTIL EOF +2> Interpreting standard input as YAML multi-document +2> ok: tag:sourcemeta.com,2026:jsonschema/stdin (entry #1) +2> ok: tag:sourcemeta.com,2026:jsonschema/stdin (entry #2) +2> ok: tag:sourcemeta.com,2026:jsonschema/stdin (entry #3) +2> +2> 3 validated, 3 passed, 0 failed +EOF + +COMPARE result_0.txt AGAINST expected_0.txt diff --git a/test/validate/pass_yaml_multi_stdin_json_first.clitest b/test/validate/pass_yaml_multi_stdin_json_first.clitest new file mode 100644 index 000000000..c160b02dd --- /dev/null +++ b/test/validate/pass_yaml_multi_stdin_json_first.clitest @@ -0,0 +1,30 @@ +WRITE schema.json UNTIL EOF +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Test", + "description": "Test schema", + "properties": { + "foo": { + "type": "string" + } + } +} +EOF + +WRITE stdin_input UNTIL EOF +{ "foo": "first" } +--- +foo: second +EOF + +RUN validate schema.json - --verbose STDIN stdin_input IN . INTO result_0.txt EXPECTING 0 + +WRITE expected_0.txt UNTIL EOF +2> Interpreting standard input as YAML multi-document +2> ok: tag:sourcemeta.com,2026:jsonschema/stdin (entry #1) +2> ok: tag:sourcemeta.com,2026:jsonschema/stdin (entry #2) +2> +2> 2 validated, 2 passed, 0 failed +EOF + +COMPARE result_0.txt AGAINST expected_0.txt