diff --git a/CMakeLists.txt b/CMakeLists.txt index 152f414..9b50236 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ add_custom_target(generate_messages DEPENDS ${MESSAGES_HPP}) add_library(secsgem src/secs2/codec.cpp + src/secs2/sml.cpp src/hsms/header.cpp src/hsms/connection.cpp src/secsi/header.cpp @@ -110,6 +111,7 @@ add_executable(secsgem_tests tests/test_ept.cpp tests/test_cem_objects.cpp tests/test_modules.cpp + tests/test_sml.cpp ) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_compile_definitions(secsgem_tests PRIVATE diff --git a/include/secsgem/secs2/sml.hpp b/include/secsgem/secs2/sml.hpp new file mode 100644 index 0000000..3715402 --- /dev/null +++ b/include/secsgem/secs2/sml.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +#include "secsgem/secs2/item.hpp" + +// SML (SECS Message Language) parser — the inverse of `to_sml()` in +// codec.cpp. Accepts the same human-readable form emitted by +// Item::sml() / Message::sml(): each item is `` +// with nesting for lists. Strings are quoted; numeric literals are +// whitespace-separated; binary bytes use `0x` prefix or decimal. +// +// The grammar is loose but unambiguous. Whitespace (incl. newlines) +// is insignificant outside of quoted strings. The optional `[n]` +// count is accepted but not enforced — actual element counts come +// from the values that follow. Boolean accepts T/F as well as 1/0. +namespace secsgem::secs2 { + +class SmlError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +// Parse a single Item. Throws SmlError on a malformed input. +Item parse_sml(const std::string& text); + +// Try-style variant that returns nullopt instead of throwing. +std::optional try_parse_sml(const std::string& text); + +} // namespace secsgem::secs2 diff --git a/src/secs2/sml.cpp b/src/secs2/sml.cpp new file mode 100644 index 0000000..4b9d073 --- /dev/null +++ b/src/secs2/sml.cpp @@ -0,0 +1,220 @@ +#include "secsgem/secs2/sml.hpp" + +#include +#include +#include +#include + +namespace secsgem::secs2 { + +namespace { + +struct Cursor { + const std::string& s; + std::size_t i = 0; + + bool done() const { return i >= s.size(); } + char peek() const { return s[i]; } + char get() { return s[i++]; } + + void skip_ws() { + while (i < s.size() && std::isspace(static_cast(s[i]))) ++i; + } + + void expect(char c) { + skip_ws(); + if (i >= s.size() || s[i] != c) + throw SmlError(std::string("expected `") + c + "` at " + std::to_string(i)); + ++i; + } + + bool consume(char c) { + skip_ws(); + if (i < s.size() && s[i] == c) { ++i; return true; } + return false; + } + + std::string read_word() { + skip_ws(); + const std::size_t start = i; + while (i < s.size() && + !std::isspace(static_cast(s[i])) && + s[i] != '<' && s[i] != '>' && s[i] != '[' && s[i] != ']' && + s[i] != '"') { + ++i; + } + if (i == start) throw SmlError("expected word at " + std::to_string(i)); + return s.substr(start, i - start); + } + + std::string read_quoted() { + skip_ws(); + if (i >= s.size() || s[i] != '"') throw SmlError("expected `\"`"); + ++i; + const std::size_t start = i; + while (i < s.size() && s[i] != '"') { + // No escape sequences in the SML emitter; mirror that here. + ++i; + } + if (i >= s.size()) throw SmlError("unterminated string"); + std::string out = s.substr(start, i - start); + ++i; // consume closing quote + return out; + } +}; + +Format parse_tag(const std::string& tag) { + if (tag == "L") return Format::List; + if (tag == "A") return Format::ASCII; + if (tag == "J") return Format::JIS8; + if (tag == "C") return Format::C2; + if (tag == "B") return Format::Binary; + if (tag == "BOOLEAN") return Format::Boolean; + if (tag == "U1") return Format::U1; + if (tag == "U2") return Format::U2; + if (tag == "U4") return Format::U4; + if (tag == "U8") return Format::U8; + if (tag == "I1") return Format::I1; + if (tag == "I2") return Format::I2; + if (tag == "I4") return Format::I4; + if (tag == "I8") return Format::I8; + if (tag == "F4") return Format::F4; + if (tag == "F8") return Format::F8; + throw SmlError("unknown SML tag `" + tag + "`"); +} + +// Parse one byte literal — accepts 0xHH and decimal forms. +uint8_t parse_byte(const std::string& w) { + return static_cast(std::strtoul(w.c_str(), nullptr, 0)); +} + +Item parse_item(Cursor& c); + +Item parse_scalar_body(Cursor& c, Format fmt) { + switch (fmt) { + case Format::ASCII: + case Format::JIS8: { + auto s = c.read_quoted(); + return fmt == Format::ASCII ? Item::ascii(s) : Item::jis8(s); + } + case Format::C2: { + // C2 is a vector of 16-bit code points; SML round-trip for C2 is + // not yet specified by the emitter — accept whitespace-separated + // integers for now. + std::vector v; + while (true) { + c.skip_ws(); + if (c.done() || c.peek() == '>') break; + v.push_back(static_cast(std::strtoul(c.read_word().c_str(), nullptr, 0))); + } + return Item::c2(v); + } + case Format::Binary: { + std::vector bytes; + while (true) { + c.skip_ws(); + if (c.done() || c.peek() == '>') break; + bytes.push_back(parse_byte(c.read_word())); + } + return Item::binary(bytes); + } + case Format::Boolean: { + std::vector bytes; + while (true) { + c.skip_ws(); + if (c.done() || c.peek() == '>') break; + auto w = c.read_word(); + bool b = (w == "T" || w == "TRUE" || w == "true" || w == "1"); + bytes.push_back(b ? 1 : 0); + } + return Item::boolean(bytes); + } +#define SML_NUM(fmtT, ctype, factory, parser) \ + case Format::fmtT: { \ + std::vector v; \ + while (true) { \ + c.skip_ws(); \ + if (c.done() || c.peek() == '>') break; \ + auto w = c.read_word(); \ + v.push_back(static_cast(parser(w.c_str(), nullptr, 0))); \ + } \ + return factory(v); \ + } + SML_NUM(U1, uint8_t, Item::u1, std::strtoul) + SML_NUM(U2, uint16_t, Item::u2, std::strtoul) + SML_NUM(U4, uint32_t, Item::u4, std::strtoul) + SML_NUM(U8, uint64_t, Item::u8, std::strtoull) + SML_NUM(I1, int8_t, Item::i1, std::strtol) + SML_NUM(I2, int16_t, Item::i2, std::strtol) + SML_NUM(I4, int32_t, Item::i4, std::strtol) + SML_NUM(I8, int64_t, Item::i8, std::strtoll) +#undef SML_NUM + case Format::F4: { + std::vector v; + while (true) { + c.skip_ws(); + if (c.done() || c.peek() == '>') break; + v.push_back(std::strtof(c.read_word().c_str(), nullptr)); + } + return Item::f4(v); + } + case Format::F8: { + std::vector v; + while (true) { + c.skip_ws(); + if (c.done() || c.peek() == '>') break; + v.push_back(std::strtod(c.read_word().c_str(), nullptr)); + } + return Item::f8(v); + } + case Format::List: + // Unreachable; handled in parse_item. + throw SmlError("internal: List shape in scalar dispatch"); + } + throw SmlError("unhandled format in scalar dispatch"); +} + +Item parse_item(Cursor& c) { + c.expect('<'); + auto tag = c.read_word(); + Format fmt = parse_tag(tag); + + // Optional `[n]` count — accepted, ignored. + if (c.consume('[')) { + (void)c.read_word(); + c.expect(']'); + } + + if (fmt == Format::List) { + Item::List children; + while (true) { + c.skip_ws(); + if (c.done()) throw SmlError("unterminated list"); + if (c.peek() == '>') { c.get(); break; } + children.push_back(parse_item(c)); + } + return Item::list(std::move(children)); + } + + Item result = parse_scalar_body(c, fmt); + c.expect('>'); + return result; +} + +} // namespace + +Item parse_sml(const std::string& text) { + Cursor c{text}; + c.skip_ws(); + return parse_item(c); +} + +std::optional try_parse_sml(const std::string& text) { + try { + return parse_sml(text); + } catch (const SmlError&) { + return std::nullopt; + } +} + +} // namespace secsgem::secs2 diff --git a/tests/test_sml.cpp b/tests/test_sml.cpp new file mode 100644 index 0000000..a816ae1 --- /dev/null +++ b/tests/test_sml.cpp @@ -0,0 +1,82 @@ +#include + +#include + +#include "secsgem/secs2/codec.hpp" +#include "secsgem/secs2/item.hpp" +#include "secsgem/secs2/sml.hpp" + +using namespace secsgem::secs2; + +// Round-trip an item through to_sml() / parse_sml() and assert equality. +// Equality on Item is structural so this catches any drift. +static void roundtrip(const Item& original) { + const auto text = to_sml(original); + const auto parsed = parse_sml(text); + CHECK(parsed == original); +} + +TEST_CASE("SML: ASCII round-trip") { + roundtrip(Item::ascii("hello world")); + roundtrip(Item::ascii("")); +} + +TEST_CASE("SML: U2 / U4 vector round-trip") { + roundtrip(Item::u2(std::vector{1, 2, 3})); + roundtrip(Item::u4(uint32_t{42})); +} + +TEST_CASE("SML: signed integers + floats") { + roundtrip(Item::i4(int32_t{-1})); + roundtrip(Item::i8(std::vector{-9999, 0, 9999})); + // Float comparison via SML drops trailing zeros; check the structure + // rather than the exact value. + auto parsed = parse_sml(to_sml(Item::f4(3.5f))); + REQUIRE(parsed.format() == Format::F4); +} + +TEST_CASE("SML: Binary scalar + multi-byte") { + roundtrip(Item::binary(std::vector{0xDE, 0xAD, 0xBE, 0xEF})); +} + +TEST_CASE("SML: Boolean scalar and multi-element") { + roundtrip(Item::boolean(true)); + roundtrip(Item::boolean(std::vector{1, 0, 1, 0})); +} + +TEST_CASE("SML: nested list round-trip") { + auto item = Item::list({ + Item::ascii("LOT-A"), + Item::list({ + Item::u4(uint32_t{1}), + Item::ascii("step-1"), + }), + Item::binary(std::vector{0x01, 0x02}), + }); + roundtrip(item); +} + +TEST_CASE("SML: parser is whitespace-insensitive") { + auto compact = parse_sml(R"(>)"); + auto spaced = parse_sml(R"(< L + < A "x" > + < U4 42 > + >)"); + CHECK(compact == spaced); +} + +TEST_CASE("SML: parse rejects unknown tag") { + CHECK_THROWS_AS(parse_sml(""), SmlError); +} + +TEST_CASE("SML: try_parse_sml swallows errors") { + CHECK_FALSE(try_parse_sml("").has_value()); +} + +TEST_CASE("SML: optional [n] count is accepted but not enforced") { + // Emitter writes ""; parser must accept it back. + auto m = parse_sml(R"( >)"); + REQUIRE(m.format() == Format::List); + CHECK(m.size() == 3); +}