I1: SML parser — inverse of to_sml()
Adds parse_sml(text) -> Item / try_parse_sml(text) -> optional<Item> in secs2/sml.hpp. Round-trips with the existing to_sml() emitter for every Item shape the codec produces: lists with nesting, ASCII / JIS8, Binary (decimal and 0xHH literals), Boolean (T/F or 1/0, scalar and multi-element), U1-U8 / I1-I8 / F4 / F8 vectors, and the optional `[n]` count syntax (accepted but not enforced). The parser is whitespace-insensitive outside quoted strings and uses a small Cursor type for read_word / read_quoted / skip_ws. Numeric literals go through strtoul/strtoll/strtod so SML can carry hex, octal, and decimal interchangeably (the emitter writes hex for Binary and decimal everywhere else). 11 test cases cover the full round-trip surface, the whitespace invariant, unknown-tag rejection, the try_parse error-swallowing variant, and the optional `[n]` count. secsgem-py has secs/sml.py for the same purpose; this brings the C++ port to parity on the tooling side. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ add_custom_target(generate_messages DEPENDS ${MESSAGES_HPP})
|
|||||||
|
|
||||||
add_library(secsgem
|
add_library(secsgem
|
||||||
src/secs2/codec.cpp
|
src/secs2/codec.cpp
|
||||||
|
src/secs2/sml.cpp
|
||||||
src/hsms/header.cpp
|
src/hsms/header.cpp
|
||||||
src/hsms/connection.cpp
|
src/hsms/connection.cpp
|
||||||
src/secsi/header.cpp
|
src/secsi/header.cpp
|
||||||
@@ -110,6 +111,7 @@ add_executable(secsgem_tests
|
|||||||
tests/test_ept.cpp
|
tests/test_ept.cpp
|
||||||
tests/test_cem_objects.cpp
|
tests/test_cem_objects.cpp
|
||||||
tests/test_modules.cpp
|
tests/test_modules.cpp
|
||||||
|
tests/test_sml.cpp
|
||||||
)
|
)
|
||||||
target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest)
|
target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest)
|
||||||
target_compile_definitions(secsgem_tests PRIVATE
|
target_compile_definitions(secsgem_tests PRIVATE
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#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 `<TAG [count]? values >`
|
||||||
|
// 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<Item> try_parse_sml(const std::string& text);
|
||||||
|
|
||||||
|
} // namespace secsgem::secs2
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#include "secsgem/secs2/sml.hpp"
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<unsigned char>(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<unsigned char>(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<uint8_t>(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<uint16_t> v;
|
||||||
|
while (true) {
|
||||||
|
c.skip_ws();
|
||||||
|
if (c.done() || c.peek() == '>') break;
|
||||||
|
v.push_back(static_cast<uint16_t>(std::strtoul(c.read_word().c_str(), nullptr, 0)));
|
||||||
|
}
|
||||||
|
return Item::c2(v);
|
||||||
|
}
|
||||||
|
case Format::Binary: {
|
||||||
|
std::vector<uint8_t> 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<uint8_t> 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<ctype> v; \
|
||||||
|
while (true) { \
|
||||||
|
c.skip_ws(); \
|
||||||
|
if (c.done() || c.peek() == '>') break; \
|
||||||
|
auto w = c.read_word(); \
|
||||||
|
v.push_back(static_cast<ctype>(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<float> 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<double> 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<Item> try_parse_sml(const std::string& text) {
|
||||||
|
try {
|
||||||
|
return parse_sml(text);
|
||||||
|
} catch (const SmlError&) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace secsgem::secs2
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#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<uint16_t>{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<int64_t>{-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<uint8_t>{0xDE, 0xAD, 0xBE, 0xEF}));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("SML: Boolean scalar and multi-element") {
|
||||||
|
roundtrip(Item::boolean(true));
|
||||||
|
roundtrip(Item::boolean(std::vector<uint8_t>{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<uint8_t>{0x01, 0x02}),
|
||||||
|
});
|
||||||
|
roundtrip(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("SML: parser is whitespace-insensitive") {
|
||||||
|
auto compact = parse_sml(R"(<L<A "x"><U4 42>>)");
|
||||||
|
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("<XXX>"), SmlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("SML: try_parse_sml swallows errors") {
|
||||||
|
CHECK_FALSE(try_parse_sml("<garbage").has_value());
|
||||||
|
CHECK(try_parse_sml("<U4 1 >").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("SML: optional [n] count is accepted but not enforced") {
|
||||||
|
// Emitter writes "<L [2] ...>"; parser must accept it back.
|
||||||
|
auto m = parse_sml(R"(<L [3] <A "a"> <A "b"> <A "c">>)");
|
||||||
|
REQUIRE(m.format() == Format::List);
|
||||||
|
CHECK(m.size() == 3);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user