From 29a76e9146f4fb9d3589999545962303bf492d0b Mon Sep 17 00:00:00 2001 From: Frank Osterfeld Date: Wed, 13 Nov 2024 12:15:55 +0100 Subject: [PATCH] Implement YAML <-> pmtv::map_t generator and parser This implements serialization and deserialization of pmtv::map_t to a subset YAML 1.2. It doesn't support the full YAML 1.2 standard, but rather focuses to serialize any possible pmtv::map_t value to valid YAML and back. --- core/include/gnuradio-4.0/YamlPmt.hpp | 975 ++++++++++++++++++++++++++ core/test/CMakeLists.txt | 1 + core/test/qa_YamlPmt.cpp | 562 +++++++++++++++ 3 files changed, 1538 insertions(+) create mode 100644 core/include/gnuradio-4.0/YamlPmt.hpp create mode 100644 core/test/qa_YamlPmt.cpp diff --git a/core/include/gnuradio-4.0/YamlPmt.hpp b/core/include/gnuradio-4.0/YamlPmt.hpp new file mode 100644 index 000000000..f19ac434c --- /dev/null +++ b/core/include/gnuradio-4.0/YamlPmt.hpp @@ -0,0 +1,975 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace pmtv::yaml { + +struct ParseError { + std::size_t line; + std::size_t column; + std::string message; + std::string context; +}; +namespace detail { + +template +struct is_complex : std::false_type {}; + +template +struct is_complex> : std::true_type {}; + +template +inline constexpr bool is_complex_v = is_complex::value; + +// serialization + +inline std::string escapeString(std::string_view str, bool escapeForQuotedString) { + std::string result; + result.reserve(str.size()); + for (char c : str) { + if (c == '"' && escapeForQuotedString) { + result.append("\\\""); + } else if (c == '\n' && escapeForQuotedString) { + result.append("\\n"); + } else if (c == '\\' && escapeForQuotedString) { + result.append("\\\\"); + } else if (c == '\t' && escapeForQuotedString) { + result.append("\\t"); + } else if (c == '\r' && escapeForQuotedString) { + result.append("\\r"); + } else if (std::iscntrl(c) && c != '\n') { + result.append(fmt::format("\\x{:02x}", static_cast(c))); + } else { + result.push_back(c); + } + } + return result; +} + +inline void indent(std::ostream& os, int level) { os << std::setw(level * 2) << std::setfill(' ') << ""; } + +enum class TypeTagMode { None, Auto }; + +template +inline void serialize(std::ostream& os, const pmtv::pmt& value, int level = 0); + +inline void serializeString(std::ostream& os, std::string_view value, int level, bool is_multiline = false, bool use_folded = false) noexcept { + if (is_multiline) { + const auto ends_with_newline = value.ends_with('\n'); + os << (use_folded ? ">" : "|"); + if (!ends_with_newline) { + os << "-"; + } + os << "\n"; + std::istringstream stream(std::string(value.data())); + + std::string line; + while (std::getline(stream, line)) { + indent(os, level + 1); // increase indentation for multi-line content + os << escapeString(line, false) << "\n"; + } + } else { + os << '"' << escapeString(value, true) << '"' << "\n"; + } +} + +template +constexpr std::string_view tag_for_type() noexcept { + if constexpr (std::is_same_v) { + return "!!null"; + } else if constexpr (std::is_same_v) { + return "!!bool"; + } else if constexpr (std::is_same_v) { + return "!!uint8"; + } else if constexpr (std::is_same_v) { + return "!!uint16"; + } else if constexpr (std::is_same_v) { + return "!!uint32"; + } else if constexpr (std::is_same_v) { + return "!!uint64"; + } else if constexpr (std::is_same_v) { + return "!!int8"; + } else if constexpr (std::is_same_v) { + return "!!int16"; + } else if constexpr (std::is_same_v) { + return "!!int32"; + } else if constexpr (std::is_same_v) { + return "!!int64"; + } else if constexpr (std::is_same_v) { + return "!!float32"; + } else if constexpr (std::is_same_v) { + return "!!float64"; + } else if constexpr (std::is_same_v>) { + return "!!complex32"; + } else if constexpr (std::is_same_v>) { + return "!!complex64"; + } else if constexpr (std::is_same_v) { + return "!!str"; + } else { + return ""; + } +} + +template +inline void serialize(std::ostream& os, const pmtv::pmt& var, int level) { + std::visit( + [&os, level](const T& value) { + if constexpr (tagMode == TypeTagMode::Auto && !std::is_same_v) { + if constexpr (!std::is_same_v && std::ranges::random_access_range) { + os << tag_for_type(); + } else { + os << tag_for_type() << " "; + } + } + if constexpr (std::same_as) { + os << "null\n"; + } else if constexpr (std::same_as) { + os << (value ? "true" : "false") << "\n"; + } else if constexpr (std::is_integral_v) { + if constexpr (sizeof(T) == 1) { + // write uint8_t and int8_t as integer, not char + os << static_cast(value) << "\n"; + } else { + os << value << "\n"; + } + } else if constexpr (std::is_floating_point_v) { + if (std::isnan(value)) { + os << ".nan\n"; + } else if (std::isinf(value)) { + os << (value < 0 ? "-.inf" : ".inf") << "\n"; + } else { + os << value << "\n"; + } + } else if constexpr (is_complex_v) { + os << "(" << value.real() << "," << value.imag() << ")\n"; + } else if constexpr (std::is_same_v) { + // Use multiline for strings containing newlines and printable characters only + bool multiline = value.contains('\n') && std::ranges::all_of(value, [](char c) { return std::isprint(c) || c == '\n'; }); + bool use_folded = value.contains(" "); // Use folded if indented lines are detected + serializeString(os, value, level, multiline, use_folded); + } else if constexpr (std::same_as) { + // flow-style formatting + if (value.empty()) { + os << " {}\n"; + return; + } + // block-style formatting + os << "\n"; + for (const auto& [key, val] : value) { + indent(os, level + 1); + if (key.contains(':') || !std::ranges::all_of(key, ::isprint)) { + os << '"' << escapeString(key, true) << "\": "; + } else { + os << key << ": "; + } + serialize(os, val, level + 1); + } + } else if constexpr (std::ranges::random_access_range) { + // flow-style formatting + if (value.empty()) { + os << " []\n"; + return; + } + // block-style formatting + os << "\n"; + for (const auto& item : value) { + indent(os, level + 1); + os << "- "; + constexpr auto childTagMode = std::is_same_v ? TypeTagMode::Auto : TypeTagMode::None; + serialize(os, item, level + 1); + } + } + }, + var); +} + +// deserialization + +struct ValueParseError { + std::size_t offset; + std::string message; +}; + +struct ParseContext { + std::span lines; + std::size_t lineIdx = 0; + std::size_t columnIdx = 0; + + bool hasMoreLines() const { return lineIdx < lines.size(); } + + bool documentStart() const { return lineIdx == 0 && columnIdx == 0; } + + bool startsWith(std::string_view sv) const { return lines[lineIdx].substr(columnIdx).starts_with(sv); } + + char front() const { return lines[lineIdx][columnIdx]; } + + void skipToNextLine() { + if (lineIdx < lines.size()) { + ++lineIdx; + columnIdx = 0; + } + } + + void consume(std::size_t n) { + columnIdx += n; + assert(columnIdx <= lines[lineIdx].size()); + } + + void consumeSpaces() { + if (columnIdx >= lines[lineIdx].size()) { + return; + } + const auto nextNonSpace = lines[lineIdx].find_first_not_of(' ', columnIdx); + if (nextNonSpace != std::string_view::npos) { + columnIdx = nextNonSpace; + } else { + columnIdx = lines[lineIdx].size(); + } + } + + std::string_view currentLine() const { return currentLineEmpty() ? std::string_view{} : lines[lineIdx].substr(columnIdx); } + + bool currentLineEmpty() const { return columnIdx == lines[lineIdx].size(); } + + std::size_t currentIndent() const { return lines[lineIdx].find_first_not_of(' '); } + + ParseError makeError(std::string message) const { return {.line = lineIdx + 1, .column = columnIdx + 1, .message = std::move(message)}; } + + ParseError makeError(ValueParseError error) const { return {.line = lineIdx + 1, .column = columnIdx + 1 + error.offset, .message = std::move(error.message)}; } +}; + +inline std::vector split(std::string_view str, std::string_view separator = "\n") { + std::vector lines; + + std::size_t start = 0; + while (start < str.size()) { + std::size_t end = str.find(separator, start); + if (end == std::string_view::npos) { + end = str.size(); + } + lines.emplace_back(str.data() + start, end - start); + start = end + 1; + } + return lines; +} + +template class Fnc, typename... Args> +inline R applyTag(std::string_view tag, Args&&... args) { + if (tag == "!!bool") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!int8") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!int16") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!int32") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!int64") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!uint8") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!uint16") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!uint32") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!uint64") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!float32") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!float64") { + return Fnc{}(std::forward(args)...); + } else if (tag == "!!complex32") { + return Fnc>{}(std::forward(args)...); + } else if (tag == "!!complex64") { + return Fnc>{}(std::forward(args)...); + } else if (tag == "!!str") { + return Fnc{}(std::forward(args)...); + } else { + return Fnc{}(std::forward(args)...); + } +} + +inline std::optional parseBytesFromHex(std::string_view sv) { + std::string result; + result.reserve(sv.size() / 2); + for (std::size_t i = 0; i < sv.size(); i += 2) { + std::string_view byte = sv.substr(i, 2); + char c; + if (auto [_, ec] = std::from_chars(byte.begin(), byte.end(), c, 16); ec == std::errc{}) { + result.push_back(c); + } else { + return std::nullopt; + } + } + return result; +} + +inline std::expected resolveYamlEscapes_multiline(ParseContext& ctx) { + auto str = ctx.currentLine(); + std::string result; + result.reserve(str.size()); + + for (auto i = 0UZ; i < str.size(); ++i) { + if (str[i] == '\\' && i + 1 < str.size()) { + ++i; + switch (str[i]) { + case '0': result.push_back('\0'); break; + case 'x': { + if (i + 2 >= str.size()) { + return std::unexpected(ValueParseError{i, "Invalid escape sequence"}); + } + const auto byte = parseBytesFromHex(str.substr(i + 1, 2)); + if (!byte) { + return std::unexpected(ValueParseError{i, "Invalid escape sequence"}); + } + result.append(*byte); + i += 2; + break; + } + default: + result.push_back('\\'); + result.push_back(str[i]); + break; + } + } else { + result.push_back(str[i]); + } + } + ctx.consume(str.size()); + return result; +}; + +inline std::expected resolveYamlEscapes_quoted(std::string_view str) { + std::string result; + result.reserve(str.size()); + for (auto i = 0UZ; i < str.size(); ++i) { + if (str[i] == '\\' && i + 1 < str.size()) { + ++i; + switch (str[i]) { + case '\\': result.push_back('\\'); break; + case 'n': result.push_back('\n'); break; + case 't': result.push_back('\t'); break; + case 'r': result.push_back('\r'); break; + case '"': result.push_back('"'); break; + case '0': result.push_back('\0'); break; + case 'x': { + if (i + 2 >= str.size()) { + return std::unexpected(ValueParseError{i, "Invalid escape sequence"}); + } + const auto byte = parseBytesFromHex(str.substr(i + 1, 2)); + if (!byte) { + return std::unexpected(ValueParseError{i, "Invalid escape sequence"}); + } + result.append(*byte); + i += 2; + break; + } + default: + result.push_back('\\'); + result.push_back(str[i]); + break; + } + } else { + result.push_back(str[i]); + } + } + return result; +}; + +template +std::expected parseAs(std::string_view sv) { + if constexpr (std::is_same_v) { + return std::monostate{}; + } else if constexpr (std::is_same_v) { + if (sv == "true" || sv == "True" || sv == "TRUE") { + return true; + } + if (sv == "false" || sv == "False" || sv == "FALSE") { + return false; + } + return std::unexpected(ValueParseError{0UZ, "Invalid value for type"}); + } else if constexpr (std::is_arithmetic_v) { + if constexpr (std::is_floating_point_v) { + if (sv == ".inf" || sv == ".Inf" || sv == ".INF") { + return std::numeric_limits::infinity(); + } else if (sv == "-.inf" || sv == "-.Inf" || sv == "-.INF") { + return -std::numeric_limits::infinity(); + } else if (sv == ".nan" || sv == ".NaN" || sv == ".NAN") { + return std::numeric_limits::quiet_NaN(); + } + } + + if constexpr (std::is_integral_v) { + if (sv.contains(".")) { + // from_chars() accepts "123.456", but we reject it + return std::unexpected(ValueParseError{0UZ, "Invalid value for type"}); + } + + auto parseWithBase = [](std::string_view s, int base) -> std::expected { + T value; + const auto [_, ec] = std::from_chars(s.begin(), s.end(), value, base); + if (ec != std::errc{}) { + return std::unexpected(ValueParseError{0UZ, "Invalid value for type"}); + } + return value; + }; + if (sv.starts_with("0x")) { + return parseWithBase(sv.substr(2), 16); + } else if (sv.starts_with("0o")) { + return parseWithBase(sv.substr(2), 8); + } else if (sv.starts_with("0b")) { + return parseWithBase(sv.substr(2), 2); + } + } + T value; + const auto [_, ec] = std::from_chars(sv.begin(), sv.end(), value); + if (ec != std::errc{}) { + return std::unexpected(ValueParseError{0UZ, "Invalid value for type"}); + } + return value; + } else if constexpr (is_complex_v) { + auto trim = [](std::string_view s) { + while (!s.empty() && std::isspace(s.front())) { + s.remove_prefix(1); + } + while (!s.empty() && std::isspace(s.back())) { + s.remove_suffix(1); + } + return s; + }; + sv = trim(sv); + if (!sv.starts_with('(') || !sv.ends_with(')')) { + return std::unexpected(ValueParseError{0UZ, "Invalid value for type"}); + } + auto trimmed = sv; + trimmed.remove_prefix(1); + trimmed.remove_suffix(1); + const auto segments = split(trimmed, ","); + if (segments.size() != 2) { + return std::unexpected(ValueParseError{0UZ, "Invalid value for type"}); + } + using value_type = typename T::value_type; + auto real = parseAs(trim(segments[0])); + if (!real) { + return real; + } + auto imag = parseAs(trim(segments[1])); + if (!imag) { + return imag; + } + return T{*real, *imag}; + } else if constexpr (std::is_same_v) { + return resolveYamlEscapes_quoted(sv); + } else { + static_assert(false, "Unsupported type"); + return std::monostate(); + } +} + +template +struct ParseAs { + auto operator()(std::string_view sv) { return parseAs(sv); } +}; + +inline bool isKnownTag(std::string_view tag) { return tag == "!!null" || tag == "!!bool" || tag == "!!uint8" || tag == "!!uint16" || tag == "!!uint32" || tag == "!!uint64" || tag == "!!int8" || tag == "!!int16" || tag == "!!int32" || tag == "!!int64" || tag == "!!float32" || tag == "!!float64" || tag == "!!complex32" || tag == "!!complex64" || tag == "!!str"; } + +inline std::expected parseTag(ParseContext& ctx) { + std::string_view tag; + if (ctx.startsWith("!!")) { + auto line = ctx.currentLine(); + auto tag_end = line.find(' '); + if (tag_end != std::string_view::npos) { + tag = line.substr(0, tag_end); + if (!isKnownTag(tag)) { + return std::unexpected(ctx.makeError("Unsupported type")); + } + ctx.consume(tag_end + 1); + } else { + tag = line; + if (!isKnownTag(tag)) { + return std::unexpected(ctx.makeError("Unsupported type")); + } + ctx.consume(line.size()); + } + } + return tag; +} + +std::expected parseMap(ParseContext& ctx, int parent_indent_level); +std::expected parseList(ParseContext& ctx, std::string_view type_tag, int parent_indent_level); + +inline size_t findClosingQuote(std::string_view sv, char quoteChar) { + bool inEscape = false; + for (size_t i = 1; i < sv.size(); ++i) { + if (inEscape) { + inEscape = false; + continue; + } + if (sv[i] == '\\') { + inEscape = true; + continue; + } + if (sv[i] == quoteChar) { + return i; + } + } + return std::string_view::npos; +} + +inline std::pair findString(std::string_view sv) { + if (sv.size() < 2) { + return {0, sv.size()}; + } + const char firstChar = sv.front(); + const auto quoted = firstChar == '"' || firstChar == '\''; + if (!quoted) { + // Ignore trailing comments + auto commentPos = sv.find('#'); + if (commentPos != std::string_view::npos) { + return {0, commentPos}; + } + return {0, sv.size()}; + } + + auto closePos = findClosingQuote(sv, firstChar); + if (closePos != std::string_view::npos) { + return {1, closePos - 1}; + } + return {1, sv.size() - 1}; // Unterminated quote (TODO: error? or do we need to chop off comments?) +} +template +inline std::expected parseNextString(ParseContext& ctx, Fnc fnc) { + auto [offset, length] = findString(ctx.currentLine()); + ctx.consume(offset); + const auto fncResult = fnc(offset, ctx.currentLine().substr(0, length)); + if (!fncResult) { + return std::unexpected(ctx.makeError(fncResult.error())); + } + // Only move cursor in the good case, to not lose the error location (column) + ctx.consume(offset + length); + return *fncResult; +} + +inline std::expected parseScalar(ParseContext& ctx, std::string_view typeTag, int currentIndentLevel) { + // remove leading spaces + ctx.consumeSpaces(); + + // handle multi-line indicators '|', '|-', '>', '>-' + if ((typeTag == "!!str" || typeTag.empty()) && (!ctx.currentLineEmpty() && (ctx.front() == '|' || ctx.front() == '>'))) { + char indicator = ctx.front(); + ctx.consume(1); + + bool trailingNewline = true; + if (ctx.startsWith("-")) { + trailingNewline = false; + ctx.consume(1); + } + + ctx.consumeSpaces(); + const auto& [_, length] = findString(ctx.currentLine()); + if (length > 0) { + return std::unexpected(ctx.makeError("Unexpected characters after multi-line indicator")); + } + std::ostringstream oss; + const auto expectedIndent = static_cast(currentIndentLevel + 2); + + bool firstLine = true; + ctx.skipToNextLine(); + + for (; ctx.hasMoreLines(); ctx.skipToNextLine()) { + auto lineIndent = ctx.currentIndent(); + if (lineIndent == std::string_view::npos) { + // empty or whitespace-only line + // folded style ('|'): empty line becomes newline + // literal style ('>'): retain empty line + oss << '\n'; + continue; + } + + if (lineIndent < expectedIndent) { + // indentation decreased; end of multi-line string + break; + } + + ctx.consume(expectedIndent); + if (indicator == '>' && !firstLine) { + oss << ' '; + } + auto resolved = resolveYamlEscapes_multiline(ctx); + if (!resolved) { + return std::unexpected(ctx.makeError(resolved.error())); + } + oss << *resolved; + if (indicator == '|') { + oss << '\n'; + } + firstLine = false; + } + + std::string result = oss.str(); + if (indicator == '|' && !trailingNewline) { + // trim trailing newlines for literal block style '|-' + while (!result.empty() && result.back() == '\n') { + result.pop_back(); + } + } else if (indicator == '>') { + if (!trailingNewline) { + // trim trailing spaces for folded block style '>-' + while (!result.empty() && std::isspace(static_cast(result.back()))) { + result.pop_back(); + } + } else { + // add trailing newline for folded block style '>' + result.push_back('\n'); + } + } + ctx.consumeSpaces(); + return result; + } + + const auto result = [&] -> std::expected { + // if we have a type tag, enforce the type + if (!typeTag.empty()) { + return parseNextString(ctx, [typeTag](std::size_t, std::string_view sv) { return applyTag, ParseAs>(typeTag, sv); }); + } + + // fallback for parsing without a YAML tag + return parseNextString(ctx, [&](std::size_t quoteOffset, std::string_view sv) -> std::expected { + // If it's quoted, treat as string + if (quoteOffset > 0) { + return resolveYamlEscapes_quoted(sv); + } + + // null + if (sv.empty() || sv == "null" || sv == "Null" || sv == "NULL" || sv == "~") { + return std::monostate{}; + } + + // boolean + if (sv == "true" || sv == "True" || sv == "TRUE") { + return true; + } + if (sv == "false" || sv == "False" || sv == "FALSE") { + return false; + } + + // try numbers + if (const auto asInt = parseAs(sv)) { + return *asInt; + } + if (const auto asDouble = parseAs(sv)) { + return *asDouble; + } + + // Anything else: string + return parseAs(sv).transform_error([&](ValueParseError error) { return ValueParseError{quoteOffset + error.offset, error.message}; }); + }); + }(); + + if (!result) { + return std::unexpected(result.error()); + } + + ctx.consumeSpaces(); + if (!ctx.currentLineEmpty()) { + const auto& [offset, length] = findString(ctx.currentLine()); + if (offset > 0 || length > 0) { + return std::unexpected(ctx.makeError("Unexpected characters after scalar value")); + } + } + + ctx.skipToNextLine(); + + return result; +} + +enum class ValueType { List, Map, Scalar }; + +inline ValueType peekToFindValueType(ParseContext ctx, int previousIndent) { + ctx.consumeSpaces(); + if (ctx.startsWith("[")) { + return ValueType::List; + } + if (ctx.startsWith("{")) { + return ValueType::Map; + } + if (!ctx.currentLineEmpty()) { + return ValueType::Scalar; + } + ctx.skipToNextLine(); + while (ctx.hasMoreLines()) { + if (ctx.startsWith("#")) { + ctx.skipToNextLine(); + continue; + } + const auto indent = ctx.currentIndent(); + if (indent == std::string_view::npos) { + ctx.skipToNextLine(); + continue; + } + if (previousIndent >= 0 && indent <= static_cast(previousIndent)) { + return ValueType::Scalar; + } + ctx.consumeSpaces(); + if (ctx.startsWith("-")) { + return ValueType::List; + } + if (ctx.currentLine().find(':') != std::string_view::npos) { + return ValueType::Map; + } + return ValueType::Scalar; + } + return ValueType::Scalar; +} + +inline std::expected parseKey(ParseContext& ctx) { + ctx.consumeSpaces(); + + if (ctx.startsWith("-")) { + return std::unexpected(ctx.makeError("Unexpected list item in map.")); + } + + const auto& [quoteOffset, length] = findString(ctx.currentLine()); + if (quoteOffset > 0) { + // quoted + auto maybeKey = resolveYamlEscapes_quoted(ctx.currentLine().substr(quoteOffset, length)); + if (!maybeKey) { + return std::unexpected(ctx.makeError(maybeKey.error())); + } + ctx.consume(2 * quoteOffset + length); + ctx.consumeSpaces(); + if (!ctx.currentLineEmpty() && ctx.front() != ':') { + return std::unexpected(ctx.makeError("Could not find key/value separator ':'")); + } + ctx.consume(1); + return *maybeKey; + } + + // not quoted + auto colonPos = ctx.currentLine().find(':'); + auto commentPos = ctx.currentLine().find('#'); + if (colonPos == std::string_view::npos || (commentPos != std::string_view::npos && commentPos < colonPos)) { + return std::unexpected(ctx.makeError("Could not find key/value separator ':'")); + } + auto key = std::string(ctx.currentLine().substr(0, colonPos)); + ctx.consume(colonPos + 1); + + return key; +} + +inline std::expected parseMap(ParseContext& ctx, int parentIndentLevel) { + pmtv::map_t map; + + if (!ctx.documentStart()) { + if (ctx.currentLine() == "{}") { + // TODO support flow style for non-empty maps + ctx.skipToNextLine(); + return map; + } + ctx.skipToNextLine(); + } + + while (ctx.hasMoreLines()) { + ctx.consumeSpaces(); + if (ctx.currentLineEmpty() || ctx.startsWith("#")) { + // skip empty lines and comments + ctx.skipToNextLine(); + continue; + } + + const auto line_indent = ctx.currentIndent(); + + if (parentIndentLevel >= 0 && line_indent <= static_cast(parentIndentLevel)) { + // indentation decreased; end of current map + break; + } + + const auto maybeKey = parseKey(ctx); + if (!maybeKey.has_value()) { + return std::unexpected(maybeKey.error()); + } + + auto key = maybeKey.value(); + + ctx.consumeSpaces(); + const auto maybeTag = parseTag(ctx); + if (!maybeTag.has_value()) { + return std::unexpected(maybeTag.error()); + } + auto typeTag = maybeTag.value(); + ctx.consumeSpaces(); + + const auto peekedType = peekToFindValueType(ctx, static_cast(line_indent)); + + switch (peekedType) { + case ValueType::List: { + auto parsedValue = parseList(ctx, typeTag, static_cast(line_indent)); + if (!parsedValue.has_value()) { + return std::unexpected(parsedValue.error()); + } + map[key] = parsedValue.value(); + break; + } + case ValueType::Map: { + if (!typeTag.empty()) { + return std::unexpected(ctx.makeError("Cannot have type tag for map entry")); + } + auto parsedValue = parseMap(ctx, static_cast(line_indent)); + if (!parsedValue.has_value()) { + return std::unexpected(parsedValue.error()); + } + map[key] = parsedValue.value(); + break; + } + case ValueType::Scalar: { + auto parsedValue = parseScalar(ctx, typeTag, static_cast(line_indent)); + if (!parsedValue.has_value()) { + return std::unexpected(parsedValue.error()); + } + map[key] = parsedValue.value(); + break; + } + } + } + return map; +} + +template +struct ConvertList { + pmtv::pmt operator()(const std::vector& list) { + if constexpr (std::is_same_v) { + return std::monostate{}; + } else { + auto resultView = list | std::views::transform([](const auto& item) { return std::get(item); }); + return std::vector(resultView.begin(), resultView.end()); + } + } +}; + +inline std::expected parseList(ParseContext& ctx, std::string_view typeTag, int parentIndentLevel) { + std::vector list; + if (ctx.currentLine() == "[]") { + // TODO support flow style for non-empty lists + ctx.skipToNextLine(); + + if (typeTag.empty()) { + return list; + } + return applyTag(typeTag, list); + } + + ctx.skipToNextLine(); + + while (ctx.hasMoreLines()) { + ctx.consumeSpaces(); + if (ctx.currentLineEmpty() || ctx.startsWith("#")) { + // skip empty lines and comments + ctx.skipToNextLine(); + continue; + } + + const std::size_t line_indent = ctx.currentIndent(); + if (parentIndentLevel >= 0 && line_indent <= static_cast(parentIndentLevel)) { + // indentation decreased; end of current list + break; + } + + ctx.consumeSpaces(); + + if (!ctx.currentLine().starts_with('-')) { + // not a list item + return std::unexpected(ctx.makeError("Expected list item")); + } + + // remove leading '- ' from the line + ctx.consume(1); + ctx.consumeSpaces(); + + const auto maybeLocalTag = parseTag(ctx); + if (!maybeLocalTag.has_value()) { + return std::unexpected(maybeLocalTag.error()); + } + auto localTag = maybeLocalTag.value(); + if (!typeTag.empty() && !localTag.empty()) { + return std::unexpected(ctx.makeError("Cannot have type tag for both list and list item")); + } + + ctx.consumeSpaces(); + + const auto peekedType = peekToFindValueType(ctx, static_cast(line_indent)); + switch (peekedType) { + case ValueType::List: { + if (!typeTag.empty()) { + return std::unexpected(ctx.makeError("Cannot have type tag for list containing lists")); + } + auto parsedValue = parseList(ctx, "", static_cast(line_indent)); + if (!parsedValue.has_value()) { + return std::unexpected(parsedValue.error()); + } + list.push_back(parsedValue.value()); + break; + } + case ValueType::Map: { + if (!typeTag.empty()) { + return std::unexpected(ctx.makeError("Cannot have type tag for maps")); + } + auto parsedValue = parseMap(ctx, static_cast(line_indent)); + if (!parsedValue.has_value()) { + return std::unexpected(parsedValue.error()); + } + list.push_back(parsedValue.value()); + break; + } + case ValueType::Scalar: { + auto parsedValue = parseScalar(ctx, !typeTag.empty() ? typeTag : localTag, static_cast(line_indent)); + if (!parsedValue.has_value()) { + return std::unexpected(parsedValue.error()); + } + list.push_back(parsedValue.value()); + break; + } + } + } + + if (typeTag.empty()) { + return list; + } + // TODO maybe avoid the conversion from pmtv::pmt back type_tag's T and make this whole function a template, + // but check for code size increase + return applyTag(typeTag, list); +} + +} // namespace detail + +inline std::string serialize(const pmtv::map_t& map) { + std::ostringstream oss; + if (!map.empty()) { + detail::serialize(oss, map, -1); // Start at level -1 to avoid indenting top-level keys + } + return oss.str(); +} + +inline std::expected deserialize(std::string_view yaml_str) { + auto lines = detail::split(yaml_str, "\n"); + detail::ParseContext ctx{.lines = lines}; + return detail::parseMap(ctx, -1); +} + +} // namespace yaml diff --git a/core/test/CMakeLists.txt b/core/test/CMakeLists.txt index 6b514bc04..3dcb87130 100644 --- a/core/test/CMakeLists.txt +++ b/core/test/CMakeLists.txt @@ -48,6 +48,7 @@ add_ut_test(qa_Messages) add_ut_test(qa_thread_affinity) add_ut_test(qa_thread_pool) add_ut_test(qa_PerformanceMonitor) +add_ut_test(qa_YamlPmt) if (ENABLE_BLOCK_REGISTRY AND ENABLE_BLOCK_PLUGINS) add_app_test(qa_grc) diff --git a/core/test/qa_YamlPmt.cpp b/core/test/qa_YamlPmt.cpp new file mode 100644 index 000000000..5aa6e09e7 --- /dev/null +++ b/core/test/qa_YamlPmt.cpp @@ -0,0 +1,562 @@ +#include "pmtv/pmt.hpp" +#include + +#include +#include +#include +#include + +#include +#include +#include + +// #define ENABLE_KNOWN_FAIL + +template +std::string_view typeName() { + return typeid(T).name(); +} + +template +std::string_view variantTypeName(const std::variant& v) { + return std::visit( + [](auto&& arg) { + // Get the type of the current alternative + using T = std::decay_t; + return typeName(); + }, + v); +} + +bool diff(const pmtv::map_t& original, const pmtv::map_t& deserialized); + +void print_diff(const std::string& key, const pmtv::pmt& originalValue, const pmtv::pmt& deserializedValue) { + std::ostringstream originalOss; + std::ostringstream deserializedOss; + pmtv::yaml::detail::serialize(originalOss, originalValue); + pmtv::yaml::detail::serialize<>(deserializedOss, deserializedValue); + std::cout << "Difference found at key: " << key << "\n"; + + std::cout << " Expected: " << originalOss.str() << "\n"; + std::cout << " Deserialized: " << deserializedOss.str() << "\n"; +} + +// Work around NaN != NaN when comparing floats/doubles +template +bool testEqual(const T& lhs, const T& rhs) { + if constexpr (std::is_floating_point_v) { + if (std::isnan(lhs) && std::isnan(rhs)) { + return true; + } + } + + if constexpr (std::ranges::random_access_range) { + if (lhs.size() != rhs.size()) { + return false; + } + for (size_t i = 0; i < lhs.size(); ++i) { + if (!testEqual(lhs[i], rhs[i])) { + return false; + } + } + return true; + } + + if constexpr (std::is_same_v) { + return !diff(lhs, rhs); + } + return lhs == rhs; +} + +bool diff(const pmtv::map_t& original, const pmtv::map_t& deserialized) { + bool foundDiff = false; + for (const auto& [key, originalValue] : original) { + auto it = deserialized.find(key); + if (it == deserialized.end()) { + std::cout << "Missing key in deserialized map: '" << key << "'\n"; + foundDiff = true; + continue; + } + const auto& deserializedValue = it->second; + if (originalValue.index() != deserializedValue.index()) { + std::cout << "Found different types for: " << key << "\n"; + std::cout << " Expected: " << variantTypeName(originalValue) << "\n"; + std::cout << " Deserialized: " << variantTypeName(deserializedValue) << "\n"; + foundDiff = true; + } else if (!std::visit( + [&](const auto& arg) { + using T = std::decay_t; + return testEqual(arg, std::get(deserializedValue)); + }, + originalValue)) { + print_diff(key, originalValue, deserializedValue); + foundDiff = true; + } + } + for (const auto& [key, deserializedValue] : deserialized) { + if (original.find(key) == original.end()) { + std::cout << "Extra key in deserialized map: '" << key << "'\n"; + foundDiff = true; + } + } + return foundDiff; +} + +template +std::string formatResult(const std::expected& result) { + if (!result.has_value()) { + const auto& error = result.error(); + return fmt::format("Error in {}:{}: {}", error.line, error.column, error.message); + } else { + return ""; + } +} + +void testYAML(std::string_view src, const pmtv::map_t expected) { + using namespace boost::ut; + // First test that the deserialized map matches the expected map + const auto deserialized_map = pmtv::yaml::deserialize(src); + if (deserialized_map) { + expect(eq(diff(expected, *deserialized_map), false)); + } else { + fmt::println(std::cerr, "Unexpected: {}", formatResult(deserialized_map)); + expect(false); + } + + // Then test that serializing and deserializing the map again results in the same map + const auto serializedStr = pmtv::yaml::serialize(expected); + const auto deserializedMap2 = pmtv::yaml::deserialize(serializedStr); + if (deserializedMap2) { + expect(eq(diff(expected, *deserializedMap2), false)) << "YAML:" << serializedStr; + } else { + fmt::println(std::cerr, "Unexpected: {}\nYAML:\n{}", formatResult(deserializedMap2), serializedStr); + expect(false); + } +} + +const boost::ut::suite YamlPmtTests = [] { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wuseless-cast" // we want explicit casts for testing + using namespace boost::ut; + using namespace pmtv; + using namespace std::string_literals; + using namespace std::string_view_literals; + + "Comments"_test = [] { + constexpr std::string_view src1 = R"(# Comment +double: !!float64 42 # Comment +string: "#Hello" # Comment +null: # Comment +#Comment: 43 +# string: | # Comment +# Hello +)"; + + pmtv::map_t expected; + expected["double"] = 42.0; + expected["string"] = "#Hello"; + expected["null"] = std::monostate{}; + + testYAML(src1, expected); + }; + + "Strings"_test = [] { + constexpr std::string_view src = R"yaml( +empty: !!str "" +spaces_only: !!str " " +multiline1: !!str | + First line + Second line + Third line with trailing newline + Null byte (\x00) + This is a quoted backslash "\" +multiline2: !!str > + This is a long + paragraph that will + be folded into + a single line + with trailing newlines + This is a quoted backslash "\" + +multiline3: !!str |- + First line + Second line + Third line without trailing newline + +multiline4: !!str >- + This is a long + paragraph that will + be folded into + a single line without + trailing newline +multiline_listlike: !!str |- + - First line + - Second line +multiline_maplike: !!str |- + key: First line + key2: Second line +multiline_with_empty: !!str | + First line + + Third line +unicode: !!str "Hello δΈ–η•Œ 🌍" +escapes: !!str "Quote\"Backslash\\Not a comment#Tab\tNewline\n" +single_quoted: !!str '"quoted"' +special_chars: !!str "!@#$%^&*()" +unprintable_chars: !!str "\x01\x02\x03\x04\x05\x00\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F" +)yaml"; + + pmtv::map_t expected; + expected["empty"] = ""s; + expected["spaces_only"] = " "s; + expected["multiline1"] = "First line\nSecond line\nThird line with trailing newline\nNull byte (\x00)\nThis is a quoted backslash \"\\\"\n"s; + expected["multiline2"] = "This is a long paragraph that will be folded into a single line with trailing newlines This is a quoted backslash \"\\\"\n\n"s; + expected["multiline3"] = "First line\nSecond line\nThird line without trailing newline"s; + expected["multiline4"] = "This is a long paragraph that will be folded into a single line without trailing newline"s; + expected["multiline_listlike"] = "- First line\n- Second line"s; + expected["multiline_maplike"] = "key: First line\nkey2: Second line"s; + expected["multiline_with_empty"] = "First line\n\nThird line\n"s; + expected["unicode"] = "Hello δΈ–η•Œ 🌍"s; + expected["escapes"] = "Quote\"Backslash\\Not a comment#Tab\tNewline\n"s; + expected["single_quoted"] = "\"quoted\""s; + expected["special_chars"] = "!@#$%^&*()"s; + expected["unprintable_chars"] = "\x01\x02\x03\x04\x05\x00\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F"s; + testYAML(src, expected); + }; + + "Nulls"_test = [] { + constexpr std::string_view src = R"yaml( +null_value: !!null null +null_value2: null +null_value3: !!null ~ +null_value4: ~ +null_value5: !!null anything +null_value6: Null +null_value7: NULL +null_value8: +not_null: NuLl +)yaml"; + + pmtv::map_t expected; + expected["null_value"] = std::monostate{}; + expected["null_value2"] = std::monostate{}; + expected["null_value3"] = std::monostate{}; + expected["null_value4"] = std::monostate{}; + expected["null_value5"] = std::monostate{}; + expected["null_value6"] = std::monostate{}; + expected["null_value7"] = std::monostate{}; + expected["null_value8"] = std::monostate{}; + expected["not_null"] = "NuLl"; + testYAML(src, expected); + }; + + "Bools"_test = [] { + constexpr std::string_view src = R"yaml( +true: !!bool true +false: !!bool false +untagged_true: true +untagged_false: false +untagged_true2: True +untagged_false2: False +untagged_true3: TRUE +untagged_false3: FALSE +)yaml"; + + pmtv::map_t expected; + expected["true"] = true; + expected["false"] = false; + expected["untagged_true"] = true; + expected["untagged_false"] = false; + expected["untagged_true2"] = true; + expected["untagged_false2"] = false; + expected["untagged_true3"] = true; + expected["untagged_false3"] = false; + + testYAML(src, expected); + + expect(eq(formatResult(yaml::deserialize("bool: !!bool 1")), "Error in 1:14: Invalid value for type"sv)); + expect(eq(formatResult(yaml::deserialize("bool: !!bool TrUe")), "Error in 1:14: Invalid value for type"sv)); + expect(eq(formatResult(yaml::deserialize("bool: !!bool 1")), "Error in 1:14: Invalid value for type"sv)); + expect(eq(formatResult(yaml::deserialize("bool: !!bool FaLsE")), "Error in 1:14: Invalid value for type"sv)); + }; + + "Numbers"_test = [] { + constexpr std::string_view src = R"yaml( +integers: + hex: !!int64 0xFF + oct: !!int64 0o77 + bin: !!int64 0b1010 + positive: !!int64 42 + negative: !!int64 -42 + zero: !!int64 0 + uint8: !!uint8 255 + uint16: !!uint16 65535 + uint32: !!uint32 4294967295 + uint64: !!uint64 18446744073709551615 + int8: !!int8 -128 + int16: !!int16 -32768 + int32: !!int32 -2147483648 + int64: !!int64 -9223372036854775808 + untagged: 42 + untagged_hex: 0xFF + untagged_oct: 0o77 + untagged_bin: 0b1010 +doubles: + normal: !!float64 123.456 + scientific: !!float64 1.23e-4 + infinity: !!float64 .inf + infinity2: !!float64 .Inf + infinity3: !!float64 .INF + neg_infinity: !!float64 -.inf + neg_infinity2: !!float64 -.Inf + neg_infinity3: !!float64 -.INF + not_a_number: !!float64 .nan + not_a_number2: !!float64 .NaN + not_a_number3: !!float64 .NAN + negative_zero: !!float64 -0.0 + untagged: 123.456 + untagged_scientific: 1.23e-4 + untagged_infinity: .inf + untagged_infinity2: .Inf + untagged_infinity3: .INF + untagged_neg_infinity: -.inf + untagged_neg_infinity2: -.Inf + untagged_neg_infinity3: -.INF + untagged_not_a_number: .nan + untagged_not_a_number2: .NaN + untagged_not_a_number3: .NAN + untagged_negative_zero: -0.0 +)yaml"; + + pmtv::map_t expected; + + pmtv::map_t integers; + integers["hex"] = static_cast(255); + integers["oct"] = static_cast(63); + integers["bin"] = static_cast(10); + integers["positive"] = static_cast(42); + integers["negative"] = static_cast(-42); + integers["zero"] = static_cast(0); + integers["uint8"] = std::numeric_limits::max(); + integers["uint16"] = std::numeric_limits::max(); + integers["uint32"] = std::numeric_limits::max(); + integers["uint64"] = std::numeric_limits::max(); + integers["int8"] = std::numeric_limits::min(); + integers["int16"] = std::numeric_limits::min(); + integers["int32"] = std::numeric_limits::min(); + integers["int64"] = std::numeric_limits::min(); + integers["untagged"] = static_cast(42); + integers["untagged_hex"] = static_cast(255); + integers["untagged_oct"] = static_cast(63); + integers["untagged_bin"] = static_cast(10); + + pmtv::map_t doubles; + doubles["normal"] = 123.456; + doubles["scientific"] = 1.23e-4; + doubles["infinity"] = std::numeric_limits::infinity(); + doubles["infinity2"] = std::numeric_limits::infinity(); + doubles["infinity3"] = std::numeric_limits::infinity(); + doubles["neg_infinity"] = -std::numeric_limits::infinity(); + doubles["neg_infinity2"] = -std::numeric_limits::infinity(); + doubles["neg_infinity3"] = -std::numeric_limits::infinity(); + doubles["not_a_number"] = std::numeric_limits::quiet_NaN(); + doubles["not_a_number2"] = std::numeric_limits::quiet_NaN(); + doubles["not_a_number3"] = std::numeric_limits::quiet_NaN(); + doubles["negative_zero"] = -0.0; + doubles["untagged"] = 123.456; + doubles["untagged_scientific"] = 1.23e-4; + doubles["untagged_infinity"] = std::numeric_limits::infinity(); + doubles["untagged_infinity2"] = std::numeric_limits::infinity(); + doubles["untagged_infinity3"] = std::numeric_limits::infinity(); + doubles["untagged_neg_infinity"] = -std::numeric_limits::infinity(); + doubles["untagged_neg_infinity2"] = -std::numeric_limits::infinity(); + doubles["untagged_neg_infinity3"] = -std::numeric_limits::infinity(); + doubles["untagged_not_a_number"] = std::numeric_limits::quiet_NaN(); + doubles["untagged_not_a_number2"] = std::numeric_limits::quiet_NaN(); + doubles["untagged_not_a_number3"] = std::numeric_limits::quiet_NaN(); + doubles["untagged_negative_zero"] = -0.0; + + expected["integers"] = integers; + expected["doubles"] = doubles; + + // TODO also test special cases for untagged values? + testYAML(src, expected); + }; + + "Vectors"_test = [] { + constexpr std::string_view src1 = R"( +stringVector: !!str + - "Hello" + - "World" + - |- + Multiple + lines +boolVector: !!bool + - true + - false + - true +pmtVectorWithBools: + - !!bool true + - !!bool false + - !!bool true +mixedPmtVector: + - !!bool true + - !!float64 42 + - !!str "Hello" +pmtVectorWithUntaggedBools: + - true + - false + - true +floatVector: !!float32 + - 1.0 + - 2.0 + - 3.0 +doubleVector: !!float64 + - 1.0 + - 2.0 + - 3.0 +complexVector: !!complex64 + - (1.0, -1.0) + - (2.0, -2.0) + - (3.0, -3.0) +emptyVector: !!str [] +emptyPmtVector: [] +)"; + + pmtv::map_t expected; + expected["boolVector"] = std::vector{true, false, true}; + expected["pmtVectorWithBools"] = std::vector{true, false, true}; + expected["pmtVectorWithUntaggedBools"] = std::vector{true, false, true}; + expected["mixedPmtVector"] = std::vector{true, 42.0, "Hello"}; + expected["floatVector"] = std::vector{1.0f, 2.0f, 3.0f}; + expected["doubleVector"] = std::vector{1.0, 2.0, 3.0}; + expected["stringVector"] = std::vector{"Hello", "World", "Multiple\nlines"}; + expected["complexVector"] = std::vector>{{1.0, -1.0}, {2.0, -2.0}, {3.0, -3.0}}; + expected["emptyVector"] = std::vector{}; + expected["emptyPmtVector"] = std::vector{}; + + testYAML(src1, expected); + }; + + "Complex"_test = [] { + constexpr std::string_view src = R"( +complex: !!complex64 (1.0, -1.0) +complex2: !!complex32 (1.0, -1.0) +complex3: !!complex64 (1.0,-1.0) +complex4: !!complex32 (1.0,-1.0) +)"; + + pmtv::map_t expected; + expected["complex"] = std::complex(1.0, -1.0); + expected["complex2"] = std::complex(1.0, -1.0); + expected["complex3"] = std::complex(1.0, -1.0); + expected["complex4"] = std::complex(1.0, -1.0); + + testYAML(src, expected); + }; + + "Odd Keys"_test = [] { + constexpr std::string_view src = R"yaml( +: empty key +Key with spaces: !!int8 42 +"quoted key with spaces": !!int8 43 +"key with colon:": !!int8 44 +"key with null byte \x00": !!int8 45 +"key with newline \n": !!int8 46 +"key with tab \t": !!int8 47 +"key with CR \r": !!int8 48 +"key with backslash \\": !!int8 49 +"key with quote \"": !!int8 50 +)yaml"; + + pmtv::map_t expected; + expected[""] = "empty key"; + expected["Key with spaces"] = static_cast(42); + expected["quoted key with spaces"] = static_cast(43); + expected["key with colon:"] = static_cast(44); + expected["key with null byte \x00"s] = static_cast(45); + expected["key with newline \n"] = static_cast(46); + expected["key with tab \t"] = static_cast(47); + expected["key with CR \r"] = static_cast(48); + expected["key with backslash \\"] = static_cast(49); + expected["key with quote \""] = static_cast(50); + + testYAML(src, expected); + }; + + "Empty"_test = [] { + testYAML({}, pmtv::map_t{}); + testYAML(" ", pmtv::map_t{}); + testYAML("\n", pmtv::map_t{}); + testYAML("# Empty\n", pmtv::map_t{}); + testYAML("\n# Empty\n", pmtv::map_t{}); + }; + + "std::complex syntax errors"_test = [] { + constexpr std::string_view src1 = R"( +complex: !!complex64 (1.0, -1.0 +)"; + + constexpr std::string_view src2 = R"( +complex: !!complex64 (1.01.0) +)"; + + constexpr std::string_view src3 = R"( +complex: !!complex64 Hello +)"; + + const auto r1 = yaml::deserialize(src1); + const auto r2 = yaml::deserialize(src2); + const auto r3 = yaml::deserialize(src3); + expect(!r1.has_value()); + expect(eq(formatResult(r1), "Error in 2:22: Invalid value for type"sv)); + expect(!r2.has_value()); + expect(eq(formatResult(r2), "Error in 2:22: Invalid value for type"sv)); + expect(!r3.has_value()); + expect(eq(formatResult(r3), "Error in 2:22: Invalid value for type"sv)); + }; + + "Errors"_test = [] { + constexpr std::string_view src1 = R"(value: !!float64 "a string" +)"; + expect(eq(formatResult(yaml::deserialize(src1)), "Error in 1:19: Invalid value for type"sv)); + + constexpr std::string_view atEnd = "value: !!"sv; + expect(eq(formatResult(yaml::deserialize(atEnd)), "Error in 1:8: Unsupported type"sv)); + + constexpr std::string_view emptyTag = R"(value: !! "a string" +)"; + expect(eq(formatResult(yaml::deserialize(emptyTag)), "Error in 1:8: Unsupported type"sv)); + + constexpr std::string_view unknownTag = R"(value: !!unknown "a string" +)"; + expect(eq(formatResult(yaml::deserialize(unknownTag)), "Error in 1:8: Unsupported type"sv)); + + constexpr std::string_view extraCharacters = R"( +value2: !!str "string"#comment +value: !!str "a string" extra +)"; + expect(eq(formatResult(yaml::deserialize(extraCharacters)), "Error in 3:25: Unexpected characters after scalar value"sv)); + + constexpr std::string_view extraMultiline = R"( +value: |- extra + Hello + World +)"; + expect(eq(formatResult(yaml::deserialize(extraMultiline)), "Error in 2:11: Unexpected characters after multi-line indicator"sv)); + + constexpr std::string_view invalidKeyComment = R"( +value: 42 # Comment +key#Comment: foo +)"; + expect(eq(formatResult(yaml::deserialize(invalidKeyComment)), "Error in 3:1: Could not find key/value separator ':'"sv)); + + constexpr std::string_view invalidEscape = R"(value: !!str "\x" +)"; + expect(eq(formatResult(yaml::deserialize(invalidEscape)), "Error in 1:16: Invalid escape sequence"sv)); + }; + +#pragma GCC diagnostic pop // Reenable -Wuseless-cast +}; + +int main() { /* tests are statically executed */ }