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:
2026-06-08 03:56:13 +02:00
parent 90fdf45df5
commit e2348db082
4 changed files with 336 additions and 0 deletions
+2
View File
@@ -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
+32
View File
@@ -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
+220
View File
@@ -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
+82
View File
@@ -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);
}