From 0dca5656de73d8d91064f053c10cb880b222057e Mon Sep 17 00:00:00 2001 From: ykiko Date: Fri, 24 Apr 2026 14:14:24 +0800 Subject: [PATCH 1/3] feat(codec): add yaml-cpp codec wrapper with serializer and deserializer Wraps yaml-cpp (0.9.0) as a proper codec module following the TOML pattern: - Serializer builds YAML::Node trees via streaming interface - Deserializer reads from YAML::Node with node_slot to avoid yaml-cpp's destructive operator= semantics on shared nodes - Convenience API: parse/to_string/from_yaml/to_yaml - CMake integration with KOTA_CODEC_ENABLE_YAML option - 12 roundtrip tests covering structs, vectors, scalars, optionals, errors Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 6 + include/kota/codec/yaml/deserializer.h | 537 +++++++++++++++++++++++++ include/kota/codec/yaml/error.h | 35 ++ include/kota/codec/yaml/serializer.h | 211 ++++++++++ include/kota/codec/yaml/yaml.h | 75 ++++ src/codec/CMakeLists.txt | 38 ++ tests/CMakeLists.txt | 11 + tests/unit/codec/yaml/yaml_tests.cpp | 176 ++++++++ 8 files changed, 1089 insertions(+) create mode 100644 include/kota/codec/yaml/deserializer.h create mode 100644 include/kota/codec/yaml/error.h create mode 100644 include/kota/codec/yaml/serializer.h create mode 100644 include/kota/codec/yaml/yaml.h create mode 100644 tests/unit/codec/yaml/yaml_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 60359ef8..d7d1f7c2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,7 @@ option(KOTA_ENABLE_ZEST "Build zest test framework target" OFF) option(KOTA_CODEC_ENABLE_SIMDJSON "Enable simdjson dependency for kota::codec" OFF) option(KOTA_CODEC_ENABLE_FLATBUFFERS "Enable flatbuffers dependency for kota::codec" OFF) option(KOTA_CODEC_ENABLE_TOML "Enable tomlplusplus dependency for kota::codec" OFF) +option(KOTA_CODEC_ENABLE_YAML "Enable yaml-cpp dependency for kota::codec" OFF) option(KOTA_BUILD_ALL_TESTS "Enable all optional test suites for CI" OFF) option(KOTA_ENABLE_EXCEPTIONS "Build kotatsu targets with exception support" ON) option(KOTA_ENABLE_RTTI "Build kotatsu targets with RTTI support" ON) @@ -284,6 +285,11 @@ if(KOTA_BUILD_ALL_TESTS) message(STATUS "KOTA_BUILD_ALL_TESTS=ON: enabling KOTA_CODEC_ENABLE_TOML") set(KOTA_CODEC_ENABLE_TOML ON CACHE BOOL "Enable tomlplusplus dependency for kota::codec" FORCE) endif() + + if(NOT KOTA_CODEC_ENABLE_YAML) + message(STATUS "KOTA_BUILD_ALL_TESTS=ON: enabling KOTA_CODEC_ENABLE_YAML") + set(KOTA_CODEC_ENABLE_YAML ON CACHE BOOL "Enable yaml-cpp dependency for kota::codec" FORCE) + endif() endif() if(KOTA_ENABLE_TEST) diff --git a/include/kota/codec/yaml/deserializer.h b/include/kota/codec/yaml/deserializer.h new file mode 100644 index 00000000..1a5c2cfe --- /dev/null +++ b/include/kota/codec/yaml/deserializer.h @@ -0,0 +1,537 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "kota/support/expected_try.h" +#include "kota/codec/detail/backend.h" +#include "kota/codec/detail/codec.h" +#include "kota/codec/detail/common.h" +#include "kota/codec/detail/config.h" +#include "kota/codec/detail/narrow.h" +#include "kota/codec/yaml/error.h" + +#if __has_include() +#include "yaml-cpp/yaml.h" +#else +#error "yaml-cpp/yaml.h not found. Enable KOTA_CODEC_ENABLE_YAML or add yaml-cpp include paths." +#endif + +namespace kota::codec::yaml { + +// YAML::Node::operator= calls set_data() which mutates the underlying shared +// data instead of rebinding. This helper always copy-constructs a fresh Node +// via optional::emplace to avoid corrupting aliased nodes. +class node_slot { +public: + void set(const YAML::Node& n) { + slot.emplace(n); + } + + void clear() { + slot.reset(); + } + + [[nodiscard]] bool has_value() const noexcept { + return slot.has_value(); + } + + YAML::Node& get() { + return *slot; + } + + const YAML::Node& get() const { + return *slot; + } + +private: + std::optional slot; +}; + +template +class Deserializer { +public: + using config_type = Config; + using error_type = yaml::error; + + constexpr static auto backend_kind_v = backend_kind::streaming; + constexpr static auto field_mode_v = field_mode::by_name; + + template + using result_t = std::expected; + + using status_t = result_t; + + explicit Deserializer(const YAML::Node& root) : root_node(root) {} + + [[nodiscard]] bool valid() const noexcept { + return is_valid; + } + + [[nodiscard]] error_type error() const noexcept { + return last_error; + } + + status_t finish() { + if(!is_valid) { + return std::unexpected(last_error); + } + if(!root_consumed) { + return mark_invalid(error_kind::invalid_state); + } + return {}; + } + + result_t deserialize_none() { + auto node = peek_node(); + if(!node) { + return std::unexpected(node.error()); + } + + const bool is_none = !node->IsDefined() || node->IsNull(); + if(is_none && !current_value.has_value()) { + root_consumed = true; + } + return is_none; + } + + template + status_t deserialize_variant(std::variant& value) { + auto kind = peek_node_kind(); + if(!kind) { + return std::unexpected(kind.error()); + } + + auto source = consume_node(); + if(!source) { + return std::unexpected(source.error()); + } + + auto result = codec::try_variant_dispatch(*source, + map_to_type_hint(*kind), + value, + error_type::type_mismatch); + if(!result) { + return mark_invalid(result.error()); + } + return {}; + } + + status_t deserialize_bool(bool& value) { + return read_scalar(value, [](const YAML::Node& node) -> result_t { + try { + return node.as(); + } catch(...) { + return std::unexpected(error_kind::type_mismatch); + } + }); + } + + template + status_t deserialize_int(T& value) { + std::int64_t parsed = 0; + auto status = read_scalar(parsed, [](const YAML::Node& node) -> result_t { + try { + return node.as(); + } catch(...) { + return std::unexpected(error_kind::type_mismatch); + } + }); + if(!status) { + return std::unexpected(status.error()); + } + + auto narrowed = codec::detail::narrow_int(parsed, error_kind::number_out_of_range); + if(!narrowed) { + return mark_invalid(narrowed.error()); + } + + value = *narrowed; + return {}; + } + + template + status_t deserialize_uint(T& value) { + std::int64_t parsed = 0; + auto status = read_scalar(parsed, [](const YAML::Node& node) -> result_t { + try { + return node.as(); + } catch(...) { + return std::unexpected(error_kind::type_mismatch); + } + }); + if(!status) { + return std::unexpected(status.error()); + } + + if(parsed < 0) { + return mark_invalid(error_kind::number_out_of_range); + } + + const auto unsigned_value = static_cast(parsed); + auto narrowed = + codec::detail::narrow_uint(unsigned_value, error_kind::number_out_of_range); + if(!narrowed) { + return mark_invalid(narrowed.error()); + } + + value = *narrowed; + return {}; + } + + template + status_t deserialize_float(T& value) { + double parsed = 0.0; + auto status = read_scalar(parsed, [](const YAML::Node& node) -> result_t { + try { + return node.as(); + } catch(...) { + return std::unexpected(error_kind::type_mismatch); + } + }); + if(!status) { + return std::unexpected(status.error()); + } + + auto narrowed = codec::detail::narrow_float(parsed, error_kind::number_out_of_range); + if(!narrowed) { + return mark_invalid(narrowed.error()); + } + + value = *narrowed; + return {}; + } + + status_t deserialize_char(char& value) { + std::string text; + auto status = read_scalar(text, [](const YAML::Node& node) -> result_t { + try { + return node.as(); + } catch(...) { + return std::unexpected(error_kind::type_mismatch); + } + }); + if(!status) { + return std::unexpected(status.error()); + } + + auto narrowed = + codec::detail::narrow_char(std::string_view(text), error_kind::type_mismatch); + if(!narrowed) { + return mark_invalid(narrowed.error()); + } + + value = *narrowed; + return {}; + } + + status_t deserialize_str(std::string& value) { + return read_scalar(value, [](const YAML::Node& node) -> result_t { + try { + return node.as(); + } catch(...) { + return std::unexpected(error_kind::type_mismatch); + } + }); + } + + status_t deserialize_bytes(std::vector& value) { + KOTA_EXPECTED_TRY(begin_array()); + value.clear(); + while(true) { + KOTA_EXPECTED_TRY_V(auto has_next, next_element()); + if(!has_next) { + break; + } + std::uint64_t byte_val = 0; + KOTA_EXPECTED_TRY(deserialize_uint(byte_val)); + if(byte_val > 255U) { + return mark_invalid(error_kind::number_out_of_range); + } + value.push_back(static_cast(static_cast(byte_val))); + } + return end_array(); + } + + result_t capture_dom_value() { + auto node = consume_node(); + if(!node) { + return std::unexpected(node.error()); + } + return YAML::Clone(*node); + } + + status_t begin_object() { + auto node = consume_node(); + if(!node) { + return std::unexpected(node.error()); + } + if(!node->IsMap()) { + return mark_invalid(error_kind::type_mismatch); + } + + deser_frame frame; + frame.node.set(*node); + frame.iter = node->begin(); + frame.end_iter = node->end(); + deser_stack.push_back(std::move(frame)); + return {}; + } + + result_t> next_field() { + if(!is_valid || deser_stack.empty()) { + return mark_invalid(error_kind::invalid_state); + } + auto& frame = deser_stack.back(); + + if(frame.pending_valid) { + ++frame.iter; + frame.pending_valid = false; + } + + if(frame.iter == frame.end_iter) { + current_value.clear(); + return std::optional(std::nullopt); + } + + frame.pending_key = frame.iter->first.template as(); + frame.pending_valid = true; + current_value.set(frame.iter->second); + return std::optional(frame.pending_key); + } + + status_t skip_field_value() { + if(!is_valid || deser_stack.empty()) { + return mark_invalid(error_kind::invalid_state); + } + auto& frame = deser_stack.back(); + ++frame.iter; + frame.pending_valid = false; + current_value.clear(); + return {}; + } + + status_t end_object() { + if(!is_valid || deser_stack.empty()) { + return mark_invalid(error_kind::invalid_state); + } + deser_stack.pop_back(); + current_value.clear(); + return {}; + } + + status_t begin_array() { + auto node = consume_node(); + if(!node) { + return std::unexpected(node.error()); + } + if(!node->IsSequence()) { + return mark_invalid(error_kind::type_mismatch); + } + array_frame frame; + frame.node.set(*node); + array_stack.push_back(std::move(frame)); + return {}; + } + + result_t next_element() { + if(!is_valid || array_stack.empty()) { + return mark_invalid(error_kind::invalid_state); + } + auto& frame = array_stack.back(); + if(frame.index >= frame.node.get().size()) { + current_value.clear(); + return false; + } + current_value.set(frame.node.get()[frame.index]); + ++frame.index; + return true; + } + + status_t end_array() { + if(!is_valid || array_stack.empty()) { + return mark_invalid(error_kind::invalid_state); + } + array_stack.pop_back(); + current_value.clear(); + return {}; + } + +private: + enum class node_kind : std::uint8_t { + none, + boolean, + integer, + floating, + string, + sequence, + map, + unknown, + }; + + template + status_t read_scalar(T& out, Reader&& reader) { + auto node = consume_node(); + if(!node) { + return std::unexpected(node.error()); + } + if(!node->IsDefined() || node->IsNull()) { + return mark_invalid(error_kind::type_mismatch); + } + + auto parsed = std::forward(reader)(*node); + if(!parsed) { + return mark_invalid(parsed.error()); + } + + out = std::move(*parsed); + return {}; + } + + result_t peek_node_kind() { + auto node = peek_node(); + if(!node) { + return std::unexpected(node.error()); + } + return classify_node(*node); + } + + static auto classify_node(const YAML::Node& node) -> node_kind { + if(!node.IsDefined() || node.IsNull()) { + return node_kind::none; + } + if(node.IsMap()) { + return node_kind::map; + } + if(node.IsSequence()) { + return node_kind::sequence; + } + if(node.IsScalar()) { + auto tag = node.Tag(); + if(tag == "?") { + auto sv = node.Scalar(); + if(sv == "true" || sv == "false") { + return node_kind::boolean; + } + bool has_dot = false; + bool is_number = !sv.empty(); + for(std::size_t i = 0; i < sv.size(); ++i) { + char c = sv[i]; + if(i == 0 && (c == '-' || c == '+')) { + continue; + } + if(c == '.' || c == 'e' || c == 'E') { + has_dot = true; + continue; + } + if(c < '0' || c > '9') { + is_number = false; + break; + } + } + if(is_number && !sv.empty()) { + return has_dot ? node_kind::floating : node_kind::integer; + } + } + return node_kind::string; + } + return node_kind::unknown; + } + + static codec::type_hint map_to_type_hint(node_kind kind) { + switch(kind) { + case node_kind::none: return codec::type_hint::null_like; + case node_kind::boolean: return codec::type_hint::boolean; + case node_kind::integer: return codec::type_hint::integer; + case node_kind::floating: return codec::type_hint::floating; + case node_kind::string: return codec::type_hint::string; + case node_kind::sequence: return codec::type_hint::array; + case node_kind::map: return codec::type_hint::object; + default: return codec::type_hint::any; + } + } + + result_t access_node(bool consume) { + if(!is_valid) { + return std::unexpected(last_error); + } + if(current_value.has_value()) { + return current_value.get(); + } + if(root_consumed) { + return mark_invalid(error_kind::invalid_state); + } + if(consume) { + root_consumed = true; + } + return root_node; + } + + result_t peek_node() { + return access_node(false); + } + + result_t consume_node() { + return access_node(true); + } + + std::unexpected mark_invalid(error_type err = error_type::invalid_state) { + is_valid = false; + if(last_error == error_type::invalid_state || err != error_type::invalid_state) { + last_error = err; + } + return std::unexpected(last_error); + } + +private: + struct deser_frame { + node_slot node; + YAML::const_iterator iter{}; + YAML::const_iterator end_iter{}; + std::string pending_key; + bool pending_valid = false; + }; + + struct array_frame { + node_slot node; + std::size_t index = 0; + }; + + bool is_valid = true; + bool root_consumed = false; + error_type last_error = error_type::invalid_state; + YAML::Node root_node; + node_slot current_value; + std::vector deser_stack; + std::vector array_stack; +}; + +template +auto from_yaml(const YAML::Node& node, T& value) -> std::expected { + Deserializer deserializer(node); + + KOTA_EXPECTED_TRY(codec::deserialize(deserializer, value)); + KOTA_EXPECTED_TRY(deserializer.finish()); + return {}; +} + +template + requires std::default_initializable +auto from_yaml(const YAML::Node& node) -> std::expected { + T value{}; + KOTA_EXPECTED_TRY(from_yaml(node, value)); + return value; +} + +static_assert(codec::deserializer_like>); + +} // namespace kota::codec::yaml diff --git a/include/kota/codec/yaml/error.h b/include/kota/codec/yaml/error.h new file mode 100644 index 00000000..1cca4973 --- /dev/null +++ b/include/kota/codec/yaml/error.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +#include "kota/codec/detail/error.h" + +namespace kota::codec::yaml { + +enum class error_kind : std::uint16_t { + ok = 0, + invalid_state, + parse_error, + type_mismatch, + number_out_of_range, + unsupported_type, + unknown, +}; + +constexpr auto error_message(error_kind error) noexcept -> std::string_view { + switch(error) { + case error_kind::ok: return "success"; + case error_kind::invalid_state: return "invalid state"; + case error_kind::parse_error: return "parse error"; + case error_kind::type_mismatch: return "type mismatch"; + case error_kind::number_out_of_range: return "number out of range"; + case error_kind::unsupported_type: return "unsupported type"; + case error_kind::unknown: + default: return "unknown yaml error"; + } +} + +using error = kota::codec::serde_error; + +} // namespace kota::codec::yaml diff --git a/include/kota/codec/yaml/serializer.h b/include/kota/codec/yaml/serializer.h new file mode 100644 index 00000000..111d88ad --- /dev/null +++ b/include/kota/codec/yaml/serializer.h @@ -0,0 +1,211 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "kota/support/expected_try.h" +#include "kota/codec/detail/backend.h" +#include "kota/codec/detail/codec.h" +#include "kota/codec/detail/config.h" +#include "kota/codec/yaml/error.h" + +#if __has_include() +#include "yaml-cpp/yaml.h" +#else +#error "yaml-cpp/yaml.h not found. Enable KOTA_CODEC_ENABLE_YAML or add yaml-cpp include paths." +#endif + +namespace kota::codec::yaml { + +template +class Serializer { +public: + using config_type = Config; + using value_type = void; + using error_type = error_kind; + + constexpr static auto backend_kind_v = backend_kind::streaming; + constexpr static auto field_mode_v = field_mode::by_name; + + template + using result_t = std::expected; + + using status_t = result_t; + + status_t serialize_null() { + return insert_value(YAML::Node(YAML::NodeType::Null)); + } + + template + status_t serialize_some(const T& value) { + return codec::serialize(*this, value); + } + + template + status_t serialize_variant(const std::variant& value) { + return std::visit( + [&](const auto& item) -> status_t { return codec::serialize(*this, item); }, value); + } + + status_t serialize_bool(bool value) { + return insert_value(YAML::Node(value)); + } + + status_t serialize_int(std::int64_t value) { + return insert_value(YAML::Node(value)); + } + + status_t serialize_uint(std::uint64_t value) { + return insert_value(YAML::Node(value)); + } + + status_t serialize_float(double value) { + return insert_value(YAML::Node(value)); + } + + status_t serialize_char(char value) { + return insert_value(YAML::Node(std::string(1, value))); + } + + status_t serialize_str(std::string_view value) { + return insert_value(YAML::Node(std::string(value))); + } + + status_t serialize_bytes(std::span value) { + KOTA_EXPECTED_TRY(begin_array(value.size())); + for(const auto byte: value) { + KOTA_EXPECTED_TRY(insert_value( + YAML::Node(static_cast(std::to_integer(byte))))); + } + return end_array(); + } + + status_t begin_object(std::size_t /*count*/) { + ser_frame frame; + frame.node = YAML::Node(YAML::NodeType::Map); + ser_stack.push_back(std::move(frame)); + return {}; + } + + status_t field(std::string_view name) { + ser_stack.back().pending_key = std::string(name); + return {}; + } + + status_t end_object() { + auto frame = std::move(ser_stack.back()); + ser_stack.pop_back(); + + if(ser_stack.empty()) { + root_ = frame.node; + return {}; + } + + auto& parent = ser_stack.back(); + if(parent.is_array) { + parent.node.push_back(frame.node); + } else { + parent.node[parent.pending_key] = frame.node; + parent.pending_key.clear(); + } + return {}; + } + + template + status_t serialize_field(std::string_view name, F&& writer) { + KOTA_EXPECTED_TRY(field(name)); + return std::forward(writer)(); + } + + template + status_t serialize_element(F&& writer) { + return std::forward(writer)(); + } + + status_t begin_array(std::optional /*count*/) { + ser_frame frame; + frame.node = YAML::Node(YAML::NodeType::Sequence); + frame.is_array = true; + ser_stack.push_back(std::move(frame)); + return {}; + } + + status_t end_array() { + auto frame = std::move(ser_stack.back()); + ser_stack.pop_back(); + + if(ser_stack.empty()) { + root_ = frame.node; + return {}; + } + + auto& parent = ser_stack.back(); + if(parent.is_array) { + parent.node.push_back(frame.node); + } else { + parent.node[parent.pending_key] = frame.node; + parent.pending_key.clear(); + } + return {}; + } + + template + auto dom(const T& value) -> result_t { + root_.reset(); + ser_stack.clear(); + auto status = codec::serialize(*this, value); + if(!status) { + root_.reset(); + ser_stack.clear(); + return std::unexpected(status.error()); + } + return std::move(root_); + } + +private: + status_t insert_value(YAML::Node value_node) { + if(ser_stack.empty()) { + root_ = std::move(value_node); + return {}; + } + auto& frame = ser_stack.back(); + if(frame.is_array) { + frame.node.push_back(std::move(value_node)); + } else { + frame.node[frame.pending_key] = std::move(value_node); + frame.pending_key.clear(); + } + return {}; + } + + struct ser_frame { + YAML::Node node; + std::string pending_key; + bool is_array = false; + }; + + YAML::Node root_; + std::vector ser_stack; +}; + +template +auto to_yaml(const T& value) -> std::expected { + Serializer serializer; + auto result = serializer.dom(value); + if(!result) { + return std::unexpected(result.error()); + } + return std::move(*result); +} + +static_assert(codec::serializer_like>); + +} // namespace kota::codec::yaml diff --git a/include/kota/codec/yaml/yaml.h b/include/kota/codec/yaml/yaml.h new file mode 100644 index 00000000..6e35730a --- /dev/null +++ b/include/kota/codec/yaml/yaml.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "kota/codec/yaml/deserializer.h" +#include "kota/codec/yaml/error.h" +#include "kota/codec/yaml/serializer.h" + +namespace kota::codec::yaml { + +inline auto parse_node(std::string_view text) -> std::expected { + try { + return YAML::Load(std::string(text)); + } catch(const YAML::ParserException&) { + return std::unexpected(error_kind::parse_error); + } +} + +template +auto parse(std::string_view text, T& value) -> std::expected { + auto node = parse_node(text); + if(!node) { + return std::unexpected(node.error()); + } + return from_yaml(*node, value); +} + +template + requires std::default_initializable +auto parse(std::string_view text) -> std::expected { + auto node = parse_node(text); + if(!node) { + return std::unexpected(node.error()); + } + return from_yaml(*node); +} + +template +auto to_string(const T& value) -> std::expected { + auto node = to_yaml(value); + if(!node) { + return std::unexpected(node.error()); + } + + YAML::Emitter emitter; + emitter << *node; + return std::string(emitter.c_str()); +} + +} // namespace kota::codec::yaml + +namespace kota::codec { + +template +struct deserialize_traits, YAML::Node> { + using error_type = yaml::Deserializer::error_type; + + static auto deserialize(yaml::Deserializer& deserializer, YAML::Node& value) + -> std::expected { + auto captured = deserializer.capture_dom_value(); + if(!captured) { + return std::unexpected(captured.error()); + } + value = std::move(*captured); + return {}; + } +}; + +} // namespace kota::codec diff --git a/src/codec/CMakeLists.txt b/src/codec/CMakeLists.txt index 2b7c106c..bcd828b0 100644 --- a/src/codec/CMakeLists.txt +++ b/src/codec/CMakeLists.txt @@ -124,6 +124,44 @@ if(KOTA_CODEC_ENABLE_FLATBUFFERS) ) endif() +if(KOTA_CODEC_ENABLE_YAML) + set(YAML_CPP_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(YAML_CPP_BUILD_TOOLS OFF CACHE BOOL "" FORCE) + set(YAML_CPP_BUILD_CONTRIB OFF CACHE BOOL "" FORCE) + set(YAML_CPP_INSTALL OFF CACHE BOOL "" FORCE) + set(YAML_CPP_FORMAT_SOURCE OFF CACHE BOOL "" FORCE) + + kota_add_git_dependency( + yaml-cpp + GIT_REPOSITORY https://github.com/jbeder/yaml-cpp.git + GIT_TAG yaml-cpp-0.9.0 + OPTIONS + YAML_CPP_BUILD_TESTS OFF + YAML_CPP_BUILD_TOOLS OFF + YAML_CPP_BUILD_CONTRIB OFF + YAML_CPP_INSTALL OFF + YAML_CPP_FORMAT_SOURCE OFF + ) + + add_library(kota_codec_yaml INTERFACE) + add_library(kota::codec::yaml ALIAS kota_codec_yaml) + + target_include_directories(kota_codec_yaml INTERFACE + "${PROJECT_SOURCE_DIR}/include" + ) + + target_link_libraries(kota_codec_yaml INTERFACE + kota::meta + yaml-cpp::yaml-cpp + ) + + kota_apply_project_options(kota_codec_yaml) + + target_link_libraries(kota_codec INTERFACE + kota::codec::yaml + ) +endif() + if(KOTA_CODEC_ENABLE_TOML) set(TOMLPLUSPLUS_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(TOMLPLUSPLUS_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e08eb008..01e4f5dc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -92,6 +92,17 @@ else() message(STATUS "KOTA_CODEC_ENABLE_TOML=OFF: skipping tests/unit/codec/toml/*") endif() +if(KOTA_CODEC_ENABLE_YAML) + set(TEST_CODEC_YAML_SOURCES ${TEST_SOURCES}) + list(FILTER TEST_CODEC_YAML_SOURCES INCLUDE REGEX "^tests/unit/codec/yaml/.*\\.cpp$") + list(TRANSFORM TEST_CODEC_YAML_SOURCES PREPEND "${PROJECT_SOURCE_DIR}/") + target_sources(unit_tests PRIVATE + ${TEST_CODEC_YAML_SOURCES} + ) +else() + message(STATUS "KOTA_CODEC_ENABLE_YAML=OFF: skipping tests/unit/codec/yaml/*") +endif() + set(TEST_CODEC_BINCODE_SOURCES ${TEST_SOURCES}) list(FILTER TEST_CODEC_BINCODE_SOURCES INCLUDE REGEX "^tests/unit/codec/bincode/.*\\.cpp$") list(TRANSFORM TEST_CODEC_BINCODE_SOURCES PREPEND "${PROJECT_SOURCE_DIR}/") diff --git a/tests/unit/codec/yaml/yaml_tests.cpp b/tests/unit/codec/yaml/yaml_tests.cpp new file mode 100644 index 00000000..3af94bce --- /dev/null +++ b/tests/unit/codec/yaml/yaml_tests.cpp @@ -0,0 +1,176 @@ +#if __has_include() + +#include +#include +#include + +#include "fixtures/schema/common.h" +#include "kota/zest/zest.h" +#include "kota/codec/yaml/yaml.h" + +namespace kota::codec { + +namespace { + +using yaml::from_yaml; +using yaml::parse; +using yaml::to_string; +using yaml::to_yaml; + +using meta::fixtures::Person; +using meta::fixtures::PersonWithScores; +using meta::fixtures::Point2i; + +TEST_SUITE(serde_yaml) { + +TEST_CASE(struct_roundtrip_with_dom) { + const Person input{.name = "Alice", .age = 30, .addr = {.city = "Tokyo", .zip = 100}}; + + auto dom = to_yaml(input); + ASSERT_TRUE(dom.has_value()); + ASSERT_TRUE(dom->IsMap()); + + Person output{}; + auto status = from_yaml(*dom, output); + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(output.name, input.name); + EXPECT_EQ(output.age, input.age); + EXPECT_EQ(output.addr.city, input.addr.city); + EXPECT_EQ(output.addr.zip, input.addr.zip); +} + +TEST_CASE(parse_and_to_string_roundtrip) { + constexpr std::string_view input = R"( +name: Bob +age: 25 +addr: + city: Osaka + zip: 530 +)"; + + auto parsed = parse(input); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->name, "Bob"); + EXPECT_EQ(parsed->age, 25); + EXPECT_EQ(parsed->addr.city, "Osaka"); + EXPECT_EQ(parsed->addr.zip, 530); + + auto encoded = to_string(*parsed); + ASSERT_TRUE(encoded.has_value()); + + auto reparsed = parse(*encoded); + ASSERT_TRUE(reparsed.has_value()); + EXPECT_EQ(reparsed->name, parsed->name); + EXPECT_EQ(reparsed->age, parsed->age); + EXPECT_EQ(reparsed->addr.city, parsed->addr.city); + EXPECT_EQ(reparsed->addr.zip, parsed->addr.zip); +} + +TEST_CASE(vector_roundtrip) { + std::vector input = {{1, 2}, {3, 4}, {5, 6}}; + + auto dom = to_yaml(input); + ASSERT_TRUE(dom.has_value()); + ASSERT_TRUE(dom->IsSequence()); + EXPECT_EQ(dom->size(), 3u); + + auto output = from_yaml>(*dom); + ASSERT_TRUE(output.has_value()); + EXPECT_EQ(output->size(), 3u); + EXPECT_EQ((*output)[0].x, 1); + EXPECT_EQ((*output)[0].y, 2); + EXPECT_EQ((*output)[2].x, 5); + EXPECT_EQ((*output)[2].y, 6); +} + +TEST_CASE(struct_with_vector) { + const PersonWithScores input{.id = 7, .name = "charlie", .scores = {10, 20, 30}, .active = true}; + + auto dom = to_yaml(input); + ASSERT_TRUE(dom.has_value()); + + auto output = from_yaml(*dom); + ASSERT_TRUE(output.has_value()); + EXPECT_EQ(output->id, input.id); + EXPECT_EQ(output->name, input.name); + EXPECT_EQ(output->scores, input.scores); + EXPECT_EQ(output->active, input.active); +} + +TEST_CASE(parse_error) { + auto result = parse("{{{{invalid yaml"); + EXPECT_FALSE(result.has_value()); +} + +TEST_CASE(type_mismatch) { + auto node = YAML::Load("[1, 2, 3]"); + Person p{}; + auto result = from_yaml(node, p); + EXPECT_FALSE(result.has_value()); +} + +TEST_CASE(optional_present) { + std::optional input = 42; + auto dom = to_yaml(input); + ASSERT_TRUE(dom.has_value()); + + auto output = from_yaml>(*dom); + ASSERT_TRUE(output.has_value()); + ASSERT_TRUE(output->has_value()); + EXPECT_EQ(**output, 42); +} + +TEST_CASE(optional_absent) { + std::optional input = std::nullopt; + auto dom = to_yaml(input); + ASSERT_TRUE(dom.has_value()); + + auto output = from_yaml>(*dom); + ASSERT_TRUE(output.has_value()); + EXPECT_FALSE(output->has_value()); +} + +TEST_CASE(scalar_string) { + std::string input = "hello world"; + auto dom = to_yaml(input); + ASSERT_TRUE(dom.has_value()); + + auto output = from_yaml(*dom); + ASSERT_TRUE(output.has_value()); + EXPECT_EQ(*output, "hello world"); +} + +TEST_CASE(scalar_bool) { + auto dom = to_yaml(true); + ASSERT_TRUE(dom.has_value()); + + auto output = from_yaml(*dom); + ASSERT_TRUE(output.has_value()); + EXPECT_EQ(*output, true); +} + +TEST_CASE(scalar_double) { + auto dom = to_yaml(3.14); + ASSERT_TRUE(dom.has_value()); + + auto output = from_yaml(*dom); + ASSERT_TRUE(output.has_value()); + EXPECT_TRUE(*output > 3.13 && *output < 3.15); +} + +TEST_CASE(dynamic_dom_field) { + auto node = YAML::Load("{name: test, value: 42}"); + YAML::Node captured; + auto result = from_yaml(node, captured); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(captured["name"].as(), "test"); + EXPECT_EQ(captured["value"].as(), 42); +} + +}; // TEST_SUITE + +} // namespace + +} // namespace kota::codec + +#endif From 91ea434164b6801e3d1597ebb1b2f804ec0a93cd Mon Sep 17 00:00:00 2001 From: ykiko Date: Fri, 24 Apr 2026 14:14:36 +0800 Subject: [PATCH 2/3] feat(zest): add snapshot testing with JSON, YAML, inline, and glob support Adds snapshot testing inspired by Rust's insta crate: - EXPECT_SNAPSHOT/ASSERT_SNAPSHOT for any pretty_dump-able value - EXPECT_JSON_SNAPSHOT/ASSERT_JSON_SNAPSHOT via codec::json serialization - EXPECT_YAML_SNAPSHOT/ASSERT_YAML_SNAPSHOT via codec::content -> YAML - EXPECT_INLINE_SNAPSHOT/ASSERT_INLINE_SNAPSHOT for inline comparisons - SNAPSHOT_GLOB for iterating files matching a pattern - Snapshot files stored in __snapshots__/ with ZEST_UPDATE_SNAPSHOTS=1 to update - TestContext tracks suite/test/file/counter per test for path computation - 18 tests covering all snapshot modes and utilities Co-Authored-By: Claude Opus 4.6 --- include/kota/zest/detail/registry.h | 13 + include/kota/zest/detail/suite.h | 1 + include/kota/zest/snap.h | 135 ++++++ src/zest/CMakeLists.txt | 1 + src/zest/snap.cpp | 408 ++++++++++++++++++ .../zest_snap__snapshot_json_struct.snap | 8 + .../zest_snap__snapshot_json_vector.snap | 14 + ...zest_snap__snapshot_multiple_per_test.snap | 1 + ...st_snap__snapshot_multiple_per_test@2.snap | 1 + ...st_snap__snapshot_multiple_per_test@3.snap | 1 + .../zest_snap__snapshot_scalar.snap | 1 + .../zest_snap__snapshot_scalar@2.snap | 1 + .../zest_snap__snapshot_yaml_struct.snap | 5 + tests/unit/zest/snap_test.cpp | 181 ++++++++ 14 files changed, 771 insertions(+) create mode 100644 include/kota/zest/snap.h create mode 100644 src/zest/snap.cpp create mode 100644 tests/unit/zest/__snapshots__/zest_snap__snapshot_json_struct.snap create mode 100644 tests/unit/zest/__snapshots__/zest_snap__snapshot_json_vector.snap create mode 100644 tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test.snap create mode 100644 tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test@2.snap create mode 100644 tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test@3.snap create mode 100644 tests/unit/zest/__snapshots__/zest_snap__snapshot_scalar.snap create mode 100644 tests/unit/zest/__snapshots__/zest_snap__snapshot_scalar@2.snap create mode 100644 tests/unit/zest/__snapshots__/zest_snap__snapshot_yaml_struct.snap create mode 100644 tests/unit/zest/snap_test.cpp diff --git a/include/kota/zest/detail/registry.h b/include/kota/zest/detail/registry.h index fe4ddf5e..77a339f7 100644 --- a/include/kota/zest/detail/registry.h +++ b/include/kota/zest/detail/registry.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -52,6 +53,18 @@ inline void skip() { current_test_state() = TestState::Skipped; } +struct TestContext { + std::string_view suite; + std::string_view test; + std::string_view file; + std::uint32_t snap_counter = 0; +}; + +inline TestContext& current_test_context() { + thread_local TestContext ctx; + return ctx; +} + class Runner { public: static Runner& instance(); diff --git a/include/kota/zest/detail/suite.h b/include/kota/zest/detail/suite.h index 50e6563f..b527d4eb 100644 --- a/include/kota/zest/detail/suite.h +++ b/include/kota/zest/detail/suite.h @@ -50,6 +50,7 @@ struct TestSuiteDef { auto run_test = +[] -> TestState { current_test_state() = TestState::Passed; + current_test_context() = {TestName.data(), case_name.data(), path.data()}; Derived test; if constexpr(requires { test.setup(); }) { test.setup(); diff --git a/include/kota/zest/snap.h b/include/kota/zest/snap.h new file mode 100644 index 00000000..d57ee79c --- /dev/null +++ b/include/kota/zest/snap.h @@ -0,0 +1,135 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "kota/zest/detail/check.h" +#include "kota/zest/detail/registry.h" +#include "kota/zest/detail/trace.h" + +namespace kota::codec::content { +class Value; +} + +namespace kota::zest::snap { + +enum class Result { + matched, + created, + updated, + mismatch, +}; + +void reset_counter(); +std::uint32_t next_counter(); + +struct GlobContext { + std::string stem; +}; + +std::optional& current_glob_context(); + +Result check(std::string_view content, + std::string_view expr, + std::source_location loc); + +bool check_inline(std::string_view actual, + std::string_view expected, + std::string_view expr, + std::source_location loc); + +std::string prettify_json(std::string_view compact); + +std::string value_to_yaml(const codec::content::Value& value); + +void glob(std::string_view pattern, + std::string_view source_file, + std::function callback); + +std::filesystem::path snapshot_path(std::string_view source_file, + std::string_view suite, + std::string_view test, + std::uint32_t counter, + const std::optional& glob_ctx); + +} // namespace kota::zest::snap + +// clang-format off + +#define ZEST_SNAPSHOT_CHECK(return_action, ...) \ + do { \ + auto _zest_snap_str_ = ::kota::zest::pretty_dump(__VA_ARGS__); \ + auto _zest_snap_result_ = ::kota::zest::snap::check( \ + _zest_snap_str_, #__VA_ARGS__, std::source_location::current()); \ + if(_zest_snap_result_ == ::kota::zest::snap::Result::mismatch) { \ + ::kota::zest::print_trace(std::source_location::current()); \ + ::kota::zest::failure(); \ + return_action; \ + } \ + } while(0) + +#define EXPECT_SNAPSHOT(...) ZEST_SNAPSHOT_CHECK((void)0, __VA_ARGS__) +#define ASSERT_SNAPSHOT(...) ZEST_SNAPSHOT_CHECK(return, __VA_ARGS__) + +#define ZEST_INLINE_SNAPSHOT_CHECK(return_action, value, expected) \ + do { \ + auto _zest_snap_str_ = ::kota::zest::pretty_dump(value); \ + if(!::kota::zest::snap::check_inline( \ + _zest_snap_str_, (expected), #value, std::source_location::current())) { \ + ::kota::zest::print_trace(std::source_location::current()); \ + ::kota::zest::failure(); \ + return_action; \ + } \ + } while(0) + +#define EXPECT_INLINE_SNAPSHOT(value, expected) ZEST_INLINE_SNAPSHOT_CHECK((void)0, value, expected) +#define ASSERT_INLINE_SNAPSHOT(value, expected) ZEST_INLINE_SNAPSHOT_CHECK(return, value, expected) + +#define ZEST_JSON_SNAPSHOT_CHECK(return_action, ...) \ + do { \ + auto _zest_json_result_ = ::kota::codec::json::to_json(__VA_ARGS__); \ + auto _zest_snap_str_ = _zest_json_result_ \ + ? ::kota::zest::snap::prettify_json(*_zest_json_result_) \ + : std::string(""); \ + auto _zest_snap_result_ = ::kota::zest::snap::check( \ + _zest_snap_str_, #__VA_ARGS__, std::source_location::current()); \ + if(_zest_snap_result_ == ::kota::zest::snap::Result::mismatch) { \ + ::kota::zest::print_trace(std::source_location::current()); \ + ::kota::zest::failure(); \ + return_action; \ + } \ + } while(0) + +#define EXPECT_JSON_SNAPSHOT(...) ZEST_JSON_SNAPSHOT_CHECK((void)0, __VA_ARGS__) +#define ASSERT_JSON_SNAPSHOT(...) ZEST_JSON_SNAPSHOT_CHECK(return, __VA_ARGS__) + +#define ZEST_YAML_SNAPSHOT_CHECK(return_action, ...) \ + do { \ + ::kota::codec::content::Serializer<> _zest_yaml_ser_; \ + auto _zest_yaml_val_ = ::kota::codec::serialize(_zest_yaml_ser_, (__VA_ARGS__)); \ + auto _zest_snap_str_ = _zest_yaml_val_ \ + ? ::kota::zest::snap::value_to_yaml(*_zest_yaml_val_) \ + : std::string(""); \ + auto _zest_snap_result_ = ::kota::zest::snap::check( \ + _zest_snap_str_, #__VA_ARGS__, std::source_location::current()); \ + if(_zest_snap_result_ == ::kota::zest::snap::Result::mismatch) { \ + ::kota::zest::print_trace(std::source_location::current()); \ + ::kota::zest::failure(); \ + return_action; \ + } \ + } while(0) + +#define EXPECT_YAML_SNAPSHOT(...) ZEST_YAML_SNAPSHOT_CHECK((void)0, __VA_ARGS__) +#define ASSERT_YAML_SNAPSHOT(...) ZEST_YAML_SNAPSHOT_CHECK(return, __VA_ARGS__) + +#define SNAPSHOT_GLOB(pattern, ...) \ + ::kota::zest::snap::glob(pattern, \ + std::source_location::current().file_name(), \ + __VA_ARGS__) + +// clang-format on diff --git a/src/zest/CMakeLists.txt b/src/zest/CMakeLists.txt index 4505dd03..1af59bda 100644 --- a/src/zest/CMakeLists.txt +++ b/src/zest/CMakeLists.txt @@ -51,6 +51,7 @@ target_sources(kota_zest PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/trace.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/runner.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/expr.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/snap.cpp" ) target_include_directories(kota_zest PUBLIC diff --git a/src/zest/snap.cpp b/src/zest/snap.cpp new file mode 100644 index 00000000..65d63243 --- /dev/null +++ b/src/zest/snap.cpp @@ -0,0 +1,408 @@ +#include "kota/zest/snap.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "kota/codec/content/document.h" + +namespace fs = std::filesystem; + +namespace { + +constexpr std::string_view snap_dir_name = "__snapshots__"; +constexpr std::string_view snap_ext = ".snap"; +constexpr std::string_view yellow = "\033[33m"; +constexpr std::string_view red = "\033[31m"; +constexpr std::string_view green = "\033[32m"; +constexpr std::string_view cyan = "\033[36m"; +constexpr std::string_view clear = "\033[0m"; + +bool should_update_snapshots() { + static const bool update = [] { + const char* env = std::getenv("ZEST_UPDATE_SNAPSHOTS"); + return env != nullptr && std::string_view(env) == "1"; + }(); + return update; +} + +std::string read_file(const fs::path& path) { + std::ifstream file(path, std::ios::binary); + if(!file) { + return {}; + } + std::ostringstream ss; + ss << file.rdbuf(); + return ss.str(); +} + +bool write_file(const fs::path& path, std::string_view content) { + fs::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if(!file) { + return false; + } + file.write(content.data(), static_cast(content.size())); + return file.good(); +} + +bool matches_glob_pattern(std::string_view text, std::string_view pattern) { + std::size_t ti = 0, pi = 0, star = std::string_view::npos, match = 0; + while(ti < text.size()) { + if(pi < pattern.size() && (pattern[pi] == text[ti])) { + ++ti; + ++pi; + } else if(pi < pattern.size() && pattern[pi] == '*') { + star = pi++; + match = ti; + } else if(star != std::string_view::npos) { + pi = star + 1; + ti = ++match; + } else { + return false; + } + } + while(pi < pattern.size() && pattern[pi] == '*') { + ++pi; + } + return pi == pattern.size(); +} + +void print_diff(std::string_view expected, std::string_view actual) { + std::println("{} --- expected{}", red, clear); + std::println("{} +++ actual{}", green, clear); + + auto lines_of = [](std::string_view s) { + std::vector lines; + while(!s.empty()) { + auto pos = s.find('\n'); + if(pos == std::string_view::npos) { + lines.push_back(s); + break; + } + lines.push_back(s.substr(0, pos)); + s.remove_prefix(pos + 1); + } + return lines; + }; + + auto expected_lines = lines_of(expected); + auto actual_lines = lines_of(actual); + + auto max_lines = std::max(expected_lines.size(), actual_lines.size()); + for(std::size_t i = 0; i < max_lines; ++i) { + bool have_exp = i < expected_lines.size(); + bool have_act = i < actual_lines.size(); + if(have_exp && have_act && expected_lines[i] == actual_lines[i]) { + std::println(" {}", expected_lines[i]); + } else { + if(have_exp) { + std::println("{} - {}{}", red, expected_lines[i], clear); + } + if(have_act) { + std::println("{} + {}{}", green, actual_lines[i], clear); + } + } + } +} + +void emit_yaml_impl(std::string& out, + const kota::codec::content::Value& val, + int depth, + bool is_list_item) { + using namespace kota::codec::content; + + auto indent = [&](int extra = 0) { + out.append(static_cast((depth + extra) * 2), ' '); + }; + + switch(val.kind()) { + case ValueKind::null_value: out += "null"; break; + case ValueKind::boolean: out += val.as_bool() ? "true" : "false"; break; + case ValueKind::signed_int: out += std::to_string(val.as_int()); break; + case ValueKind::unsigned_int: out += std::to_string(val.as_uint()); break; + case ValueKind::floating: { + auto d = val.as_double(); + auto s = std::to_string(d); + // trim trailing zeros but keep at least one decimal + auto dot = s.find('.'); + if(dot != std::string::npos) { + auto last_nonzero = s.find_last_not_of('0'); + if(last_nonzero != std::string::npos && last_nonzero > dot) { + s.erase(last_nonzero + 1); + } else { + s.erase(dot + 2); + } + } + out += s; + break; + } + case ValueKind::string: { + auto sv = val.as_string(); + bool needs_quote = sv.empty() || sv.find_first_of(":#{}[]&*?|>!%@`,\n\"'") != std::string_view::npos; + if(!needs_quote) { + // check if it looks like a bool/null/number + if(sv == "true" || sv == "false" || sv == "null" || sv == "~" || + sv == "yes" || sv == "no" || sv == "on" || sv == "off") { + needs_quote = true; + } + } + if(needs_quote) { + out += '"'; + for(char c : sv) { + switch(c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\t': out += "\\t"; break; + case '\r': out += "\\r"; break; + default: out += c; + } + } + out += '"'; + } else { + out += sv; + } + break; + } + case ValueKind::array: { + const auto& arr = val.as_array(); + if(arr.empty()) { + out += "[]"; + break; + } + for(std::size_t i = 0; i < arr.size(); ++i) { + if(i > 0 || (depth > 0 && !is_list_item)) { + out += '\n'; + indent(); + } + out += "- "; + bool child_compound = arr[i].is_object() || arr[i].is_array(); + emit_yaml_impl(out, arr[i], depth + 1, true); + if(!child_compound && i < arr.size() - 1) { + // scalar items are already complete + } + } + break; + } + case ValueKind::object: { + const auto& obj = val.as_object(); + if(obj.empty()) { + out += "{}"; + break; + } + std::size_t i = 0; + for(const auto& [key, value] : obj) { + if(i > 0 || (depth > 0 && !is_list_item)) { + out += '\n'; + indent(); + } + out += key; + out += ':'; + if(value.is_object() || value.is_array()) { + emit_yaml_impl(out, value, depth + 1, false); + } else { + out += ' '; + emit_yaml_impl(out, value, depth + 1, false); + } + ++i; + } + break; + } + } +} + +} // namespace + +namespace kota::zest::snap { + +void reset_counter() { + current_test_context().snap_counter = 0; +} + +std::uint32_t next_counter() { + return current_test_context().snap_counter++; +} + +std::optional& current_glob_context() { + static thread_local std::optional ctx; + return ctx; +} + +fs::path snapshot_path(std::string_view source_file, + std::string_view suite, + std::string_view test, + std::uint32_t counter, + const std::optional& glob_ctx) { + auto source_dir = fs::path(source_file).parent_path(); + auto snap_dir = source_dir / snap_dir_name; + + std::string filename; + filename += suite; + filename += "__"; + filename += test; + if(glob_ctx.has_value()) { + filename += "__"; + filename += glob_ctx->stem; + } + if(counter > 0) { + filename += '@'; + filename += std::to_string(counter + 1); + } + filename += snap_ext; + return snap_dir / filename; +} + +Result check(std::string_view content, + std::string_view expr, + std::source_location loc) { + auto& ctx = current_test_context(); + auto counter = next_counter(); + auto& glob_ctx = current_glob_context(); + + auto path = snapshot_path(ctx.file, ctx.suite, ctx.test, counter, glob_ctx); + + if(should_update_snapshots()) { + write_file(path, content); + std::println("{}[ snap ] updated: {}{}", yellow, path.string(), clear); + return Result::updated; + } + + if(!fs::exists(path)) { + write_file(path, content); + std::println("{}[ snap ] created: {}{}", cyan, path.string(), clear); + return Result::created; + } + + auto expected = read_file(path); + if(expected == content) { + return Result::matched; + } + + std::println("[ snap ] snapshot mismatch for: {}", expr); + std::println(" file: {}", path.string()); + std::println(" at {}:{}", loc.file_name(), loc.line()); + print_diff(expected, std::string(content)); + return Result::mismatch; +} + +bool check_inline(std::string_view actual, + std::string_view expected, + std::string_view expr, + std::source_location loc) { + if(actual == expected) { + return true; + } + + std::println("[ snap ] inline snapshot mismatch for: {}", expr); + std::println(" at {}:{}", loc.file_name(), loc.line()); + print_diff(expected, actual); + return false; +} + +std::string prettify_json(std::string_view json) { + std::string out; + out.reserve(json.size() * 2); + int depth = 0; + bool in_string = false; + + auto indent_newline = [&] { + out += '\n'; + out.append(static_cast(depth * 2), ' '); + }; + + for(std::size_t i = 0; i < json.size(); ++i) { + char c = json[i]; + + if(in_string) { + out += c; + if(c == '\\' && i + 1 < json.size()) { + out += json[++i]; + } else if(c == '"') { + in_string = false; + } + continue; + } + + switch(c) { + case '"': in_string = true; out += c; break; + case '{': + case '[': + out += c; + if(i + 1 < json.size() && (json[i + 1] == '}' || json[i + 1] == ']')) { + out += json[++i]; + } else { + ++depth; + indent_newline(); + } + break; + case '}': + case ']': --depth; indent_newline(); out += c; break; + case ',': out += ','; indent_newline(); break; + case ':': out += ": "; break; + case ' ': + case '\t': + case '\n': + case '\r': break; + default: out += c; + } + } + return out; +} + +std::string value_to_yaml(const codec::content::Value& value) { + std::string out; + emit_yaml_impl(out, value, 0, false); + out += '\n'; + return out; +} + +void glob(std::string_view pattern, + std::string_view source_file, + std::function callback) { + auto source_dir = fs::path(source_file).parent_path(); + + std::string_view dir_part; + std::string_view file_pattern; + auto last_slash = pattern.rfind('/'); + if(last_slash != std::string_view::npos) { + dir_part = pattern.substr(0, last_slash); + file_pattern = pattern.substr(last_slash + 1); + } else { + file_pattern = pattern; + } + + auto search_dir = dir_part.empty() ? source_dir : source_dir / std::string(dir_part); + + if(!fs::exists(search_dir) || !fs::is_directory(search_dir)) { + std::println("{}[ snap ] glob: directory not found: {}{}", red, search_dir.string(), clear); + return; + } + + std::vector matched; + for(const auto& entry : fs::directory_iterator(search_dir)) { + if(!entry.is_regular_file()) { + continue; + } + auto filename = entry.path().filename().string(); + if(matches_glob_pattern(filename, file_pattern)) { + matched.push_back(entry.path()); + } + } + + std::sort(matched.begin(), matched.end()); + + auto& glob_ctx = current_glob_context(); + for(const auto& path : matched) { + glob_ctx = GlobContext{path.stem().string()}; + reset_counter(); + callback(path); + } + glob_ctx.reset(); +} + +} // namespace kota::zest::snap diff --git a/tests/unit/zest/__snapshots__/zest_snap__snapshot_json_struct.snap b/tests/unit/zest/__snapshots__/zest_snap__snapshot_json_struct.snap new file mode 100644 index 00000000..393c877f --- /dev/null +++ b/tests/unit/zest/__snapshots__/zest_snap__snapshot_json_struct.snap @@ -0,0 +1,8 @@ +{ + "name": "Alice", + "age": 30, + "addr": { + "city": "Tokyo", + "zip": 100 + } +} \ No newline at end of file diff --git a/tests/unit/zest/__snapshots__/zest_snap__snapshot_json_vector.snap b/tests/unit/zest/__snapshots__/zest_snap__snapshot_json_vector.snap new file mode 100644 index 00000000..4b7eb781 --- /dev/null +++ b/tests/unit/zest/__snapshots__/zest_snap__snapshot_json_vector.snap @@ -0,0 +1,14 @@ +[ + { + "x": 1, + "y": 2 + }, + { + "x": 3, + "y": 4 + }, + { + "x": 5, + "y": 6 + } +] \ No newline at end of file diff --git a/tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test.snap b/tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test.snap new file mode 100644 index 00000000..105d7d9a --- /dev/null +++ b/tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test.snap @@ -0,0 +1 @@ +100 \ No newline at end of file diff --git a/tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test@2.snap b/tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test@2.snap new file mode 100644 index 00000000..ae4ee13c --- /dev/null +++ b/tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test@2.snap @@ -0,0 +1 @@ +200 \ No newline at end of file diff --git a/tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test@3.snap b/tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test@3.snap new file mode 100644 index 00000000..f1efb205 --- /dev/null +++ b/tests/unit/zest/__snapshots__/zest_snap__snapshot_multiple_per_test@3.snap @@ -0,0 +1 @@ +300 \ No newline at end of file diff --git a/tests/unit/zest/__snapshots__/zest_snap__snapshot_scalar.snap b/tests/unit/zest/__snapshots__/zest_snap__snapshot_scalar.snap new file mode 100644 index 00000000..f70d7bba --- /dev/null +++ b/tests/unit/zest/__snapshots__/zest_snap__snapshot_scalar.snap @@ -0,0 +1 @@ +42 \ No newline at end of file diff --git a/tests/unit/zest/__snapshots__/zest_snap__snapshot_scalar@2.snap b/tests/unit/zest/__snapshots__/zest_snap__snapshot_scalar@2.snap new file mode 100644 index 00000000..95d09f2b --- /dev/null +++ b/tests/unit/zest/__snapshots__/zest_snap__snapshot_scalar@2.snap @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/tests/unit/zest/__snapshots__/zest_snap__snapshot_yaml_struct.snap b/tests/unit/zest/__snapshots__/zest_snap__snapshot_yaml_struct.snap new file mode 100644 index 00000000..82fc403d --- /dev/null +++ b/tests/unit/zest/__snapshots__/zest_snap__snapshot_yaml_struct.snap @@ -0,0 +1,5 @@ +name: Bob +age: 25 +addr: + city: Osaka + zip: 530 diff --git a/tests/unit/zest/snap_test.cpp b/tests/unit/zest/snap_test.cpp new file mode 100644 index 00000000..c988b71a --- /dev/null +++ b/tests/unit/zest/snap_test.cpp @@ -0,0 +1,181 @@ +#include +#include +#include +#include + +#include "fixtures/schema/common.h" +#include "kota/codec/content/serializer.h" +#include "kota/codec/json/json.h" +#include "kota/zest/snap.h" +#include "kota/zest/zest.h" + +namespace kota::zest { + +namespace { + +namespace fs = std::filesystem; + +using codec::json::to_json; +using meta::fixtures::Person; +using meta::fixtures::Point2i; + +TEST_SUITE(zest_snap) { + +TEST_CASE(snapshot_scalar) { + EXPECT_SNAPSHOT(42); + EXPECT_SNAPSHOT(std::string("hello world")); +} + +TEST_CASE(snapshot_json_struct) { + Person p{.name = "Alice", .age = 30, .addr = {.city = "Tokyo", .zip = 100}}; + EXPECT_JSON_SNAPSHOT(p); +} + +TEST_CASE(snapshot_json_vector) { + std::vector points = {{1, 2}, {3, 4}, {5, 6}}; + EXPECT_JSON_SNAPSHOT(points); +} + +TEST_CASE(snapshot_yaml_struct) { + Person p{.name = "Bob", .age = 25, .addr = {.city = "Osaka", .zip = 530}}; + EXPECT_YAML_SNAPSHOT(p); +} + +TEST_CASE(snapshot_inline_basic) { + EXPECT_INLINE_SNAPSHOT(42, "42"); + EXPECT_INLINE_SNAPSHOT(std::string("test"), "test"); +} + +TEST_CASE(snapshot_multiple_per_test) { + EXPECT_SNAPSHOT(100); + EXPECT_SNAPSHOT(200); + EXPECT_SNAPSHOT(300); +} + +TEST_CASE(prettify_json_basic) { + auto result = snap::prettify_json(R"({"name":"Alice","age":30})"); + auto expected = R"({ + "name": "Alice", + "age": 30 +})"; + EXPECT_EQ(result, expected); +} + +TEST_CASE(prettify_json_nested) { + auto result = snap::prettify_json(R"({"a":{"b":1},"c":[1,2,3]})"); + auto expected = R"({ + "a": { + "b": 1 + }, + "c": [ + 1, + 2, + 3 + ] +})"; + EXPECT_EQ(result, expected); +} + +TEST_CASE(prettify_json_empty_containers) { + EXPECT_EQ(snap::prettify_json("{}"), "{}"); + EXPECT_EQ(snap::prettify_json("[]"), "[]"); + EXPECT_EQ(snap::prettify_json(R"({"a":{},"b":[]})"), R"({ + "a": {}, + "b": [] +})"); +} + +TEST_CASE(prettify_json_strings_with_special_chars) { + auto result = snap::prettify_json(R"({"msg":"hello \"world\""})"); + auto expected = R"({ + "msg": "hello \"world\"" +})"; + EXPECT_EQ(result, expected); +} + +TEST_CASE(yaml_basic_object) { + codec::content::Value val(codec::content::Object{ + {"name", codec::content::Value("Alice")}, + {"age", codec::content::Value(30)}, + }); + auto yaml = snap::value_to_yaml(val); + auto expected = R"(name: Alice +age: 30 +)"; + EXPECT_EQ(yaml, expected); +} + +TEST_CASE(yaml_nested_object) { + codec::content::Value val(codec::content::Object{ + {"person", + codec::content::Value(codec::content::Object{ + {"name", codec::content::Value("Bob")}, + {"city", codec::content::Value("Osaka")}, + })}, + }); + auto yaml = snap::value_to_yaml(val); + auto expected = R"(person: + name: Bob + city: Osaka +)"; + EXPECT_EQ(yaml, expected); +} + +TEST_CASE(yaml_array) { + codec::content::Value val(codec::content::Array{ + codec::content::Value(1), + codec::content::Value(2), + codec::content::Value(3), + }); + auto yaml = snap::value_to_yaml(val); + auto expected = R"(- 1 +- 2 +- 3 +)"; + EXPECT_EQ(yaml, expected); +} + +TEST_CASE(yaml_empty_containers) { + EXPECT_EQ(snap::value_to_yaml(codec::content::Value(codec::content::Object{})), "{}\n"); + EXPECT_EQ(snap::value_to_yaml(codec::content::Value(codec::content::Array{})), "[]\n"); +} + +TEST_CASE(yaml_string_quoting) { + codec::content::Value val(codec::content::Object{ + {"plain", codec::content::Value("hello")}, + {"needs_quote", codec::content::Value("true")}, + {"with_colon", codec::content::Value("key: value")}, + {"empty", codec::content::Value("")}, + }); + auto yaml = snap::value_to_yaml(val); + auto expected = R"(plain: hello +needs_quote: "true" +with_colon: "key: value" +empty: "" +)"; + EXPECT_EQ(yaml, expected); +} + +TEST_CASE(snapshot_path_single) { + auto path = snap::snapshot_path("/home/user/tests/test.cpp", "suite", "case", 0, std::nullopt); + EXPECT_EQ(path.filename().string(), "suite__case.snap"); + EXPECT_TRUE(path.parent_path().filename().string() == "__snapshots__"); +} + +TEST_CASE(snapshot_path_multi) { + auto path = snap::snapshot_path("/home/user/tests/test.cpp", "suite", "case", 1, std::nullopt); + EXPECT_EQ(path.filename().string(), "suite__case@2.snap"); +} + +TEST_CASE(snapshot_path_glob) { + snap::GlobContext glob_ctx{"input1"}; + auto path = + snap::snapshot_path("/home/user/tests/test.cpp", "suite", "case", 0, glob_ctx); + EXPECT_EQ(path.filename().string(), "suite__case__input1.snap"); +} + +}; // TEST_SUITE + +} // namespace + +} // namespace kota::zest From 3d0825bb6740fb486ce8fa1af5e74a0874cb3e08 Mon Sep 17 00:00:00 2001 From: ykiko Date: Fri, 24 Apr 2026 14:23:05 +0800 Subject: [PATCH 3/3] fix(codec/yaml): replace try/catch with YAML::convert::decode for no-exception builds - Deserializer now uses YAML::convert::decode() which returns bool instead of node.as() which throws on failure - parse_node() guarded with #if KOTA_ENABLE_EXCEPTIONS for YAML::Load() Co-Authored-By: Claude Opus 4.6 --- include/kota/codec/yaml/deserializer.h | 38 +++++++++++++------------- include/kota/codec/yaml/yaml.h | 4 +++ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/include/kota/codec/yaml/deserializer.h b/include/kota/codec/yaml/deserializer.h index 1a5c2cfe..675ac1fa 100644 --- a/include/kota/codec/yaml/deserializer.h +++ b/include/kota/codec/yaml/deserializer.h @@ -128,11 +128,11 @@ class Deserializer { status_t deserialize_bool(bool& value) { return read_scalar(value, [](const YAML::Node& node) -> result_t { - try { - return node.as(); - } catch(...) { + bool v{}; + if(!YAML::convert::decode(node, v)) { return std::unexpected(error_kind::type_mismatch); } + return v; }); } @@ -140,11 +140,11 @@ class Deserializer { status_t deserialize_int(T& value) { std::int64_t parsed = 0; auto status = read_scalar(parsed, [](const YAML::Node& node) -> result_t { - try { - return node.as(); - } catch(...) { + std::int64_t v{}; + if(!YAML::convert::decode(node, v)) { return std::unexpected(error_kind::type_mismatch); } + return v; }); if(!status) { return std::unexpected(status.error()); @@ -163,11 +163,11 @@ class Deserializer { status_t deserialize_uint(T& value) { std::int64_t parsed = 0; auto status = read_scalar(parsed, [](const YAML::Node& node) -> result_t { - try { - return node.as(); - } catch(...) { + std::int64_t v{}; + if(!YAML::convert::decode(node, v)) { return std::unexpected(error_kind::type_mismatch); } + return v; }); if(!status) { return std::unexpected(status.error()); @@ -192,11 +192,11 @@ class Deserializer { status_t deserialize_float(T& value) { double parsed = 0.0; auto status = read_scalar(parsed, [](const YAML::Node& node) -> result_t { - try { - return node.as(); - } catch(...) { + double v{}; + if(!YAML::convert::decode(node, v)) { return std::unexpected(error_kind::type_mismatch); } + return v; }); if(!status) { return std::unexpected(status.error()); @@ -214,11 +214,11 @@ class Deserializer { status_t deserialize_char(char& value) { std::string text; auto status = read_scalar(text, [](const YAML::Node& node) -> result_t { - try { - return node.as(); - } catch(...) { + std::string v; + if(!YAML::convert::decode(node, v)) { return std::unexpected(error_kind::type_mismatch); } + return v; }); if(!status) { return std::unexpected(status.error()); @@ -236,11 +236,11 @@ class Deserializer { status_t deserialize_str(std::string& value) { return read_scalar(value, [](const YAML::Node& node) -> result_t { - try { - return node.as(); - } catch(...) { + std::string v; + if(!YAML::convert::decode(node, v)) { return std::unexpected(error_kind::type_mismatch); } + return v; }); } @@ -303,7 +303,7 @@ class Deserializer { return std::optional(std::nullopt); } - frame.pending_key = frame.iter->first.template as(); + YAML::convert::decode(frame.iter->first, frame.pending_key); frame.pending_valid = true; current_value.set(frame.iter->second); return std::optional(frame.pending_key); diff --git a/include/kota/codec/yaml/yaml.h b/include/kota/codec/yaml/yaml.h index 6e35730a..f1497967 100644 --- a/include/kota/codec/yaml/yaml.h +++ b/include/kota/codec/yaml/yaml.h @@ -15,11 +15,15 @@ namespace kota::codec::yaml { inline auto parse_node(std::string_view text) -> std::expected { +#if KOTA_ENABLE_EXCEPTIONS try { return YAML::Load(std::string(text)); } catch(const YAML::ParserException&) { return std::unexpected(error_kind::parse_error); } +#else + return YAML::Load(std::string(text)); +#endif } template