diff --git a/CHANGELOG.md b/CHANGELOG.md index b49b2f9..ddca178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ ### 新增 +- 增加独立 SecsFrame.Sml 程序集,为动态消息和全部 Item 类型 + 提供确定性 SML 调试文本写出与严格读取。 +- 增加 ASCII 可逆转义、JIS-8 原始十六进制字节、InvariantCulture 数字、 + 无 Body/空 List 区分,以及深度、Item、值和文本长度资源边界。 +- 增加 SML 黄金向量、全类型往返、非法语法、源位置和资源限制测试;该 + profile 不声明标准 SML 合规。 - 初始化 SecsFrame 仓库、工程规范和 CI。 - 增加 HSMS 帧头、严格长度定界与编解码基础实现。 - 增加 SEMI E5-0725 的完整 SECS-II Item 动态数据模型与严格二进制编解码。 diff --git a/README.md b/README.md index 042a0e3..3f0c492 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ |---|---| | `SecsFrame` | SECS-II 数据模型、HSMS-SS 会话与事务基础 | | `SecsFrame.Gem` | GEM 通用状态模型与能力服务 | +| `SecsFrame.Sml` | 动态消息的确定性 SML 调试读写 | | `SecsFrame.Smn` | E173 SMN 日志、文档与消息表示 | | `SecsFrame.Sedd` | E172 SEDD 设备接口数据字典 | @@ -36,6 +37,9 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor Equipment 应用发送策略的 Collection Event、报警目录查询、单报警发送 启停,以及最小报警通知、Equipment 报警通知发送策略、远程命令运行期精确 目录/逐项可用性/全局回退和应用状态接受策略。 +独立 SecsFrame.Sml 为相同动态消息模型提供确定性文本写出、 +严格读取、全部 Item 类型往返和显式资源边界;它是非规范性调试 profile, +不进入线上通讯路径。 Item 用法与边界见 [docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见 [docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见 @@ -53,6 +57,8 @@ Host/Equipment 端点见 [docs/SECS-ENDPOINT-ROLES.md](docs/SECS-ENDPOINT-ROLES.md), 独立 GEM 基础行为见 [docs/GEM-FOUNDATION.md](docs/GEM-FOUNDATION.md), +SML 调试文本见 +[docs/SML.md](docs/SML.md), 官方 secs4net 跨实现证据见 [docs/SECS4NET-INTEROP.md](docs/SECS4NET-INTEROP.md), 整体路线图见 diff --git a/SecsFrame.slnx b/SecsFrame.slnx index 8580fd5..0e90820 100644 --- a/SecsFrame.slnx +++ b/SecsFrame.slnx @@ -1,6 +1,7 @@ + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index afed159..40e1cbd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,6 +11,8 @@ StreamFrame TCP、Pipe、帧边界、连接生命周期 ``` E172 SEDD 与 E173 SMN 是可选元数据/表示层,不进入核心通讯路径。 +SecsFrame.Sml 同样是只依赖核心动态消息模型的可选调试表示层; +核心包不反向依赖它。 ## 核心原则 @@ -157,5 +159,6 @@ HSMS 头 Session ID 与 System Bytes。没有 W-Bit 的出站消息在写出后 - `SecsFrame` 可以依赖 `StreamFrame`。 - `SecsFrame.Gem` 依赖 `SecsFrame`。 +- `SecsFrame.Sml` 依赖 `SecsFrame`,不得依赖 GEM 或线上传输实现。 - 核心包不得依赖 GEM、SML/SMN、依赖注入容器或具体日志实现。 - 状态机计时使用可替换的时间抽象,测试不得依赖真实长时间等待。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3ce874c..feba74b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -92,14 +92,15 @@ Collection Event 发送策略沿用相同的默认不注册模式;拒绝发生 串行化决策,库不引入后台循环或未经 E30 核对的状态矩阵。 阶段 2 其余报警历史/批量控制和完整命令权限/调度都需要先取得授权 -E30-0526 材料。下一可执行优先级转入阶段 3 的 SML 调试读写;最小垂直切片 -先核对可公开、可许可的语法来源,再为现有动态 SecsMessage 和 -全部 SecsItem 类型提供确定性文本写出、读取回环与严格向量, -不宣称标准 SML 合规。 +E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写已经基于许可 +清晰的公开实现完成非规范性 profile,为现有动态 SecsMessage +和全部 SecsItem 类型提供确定性文本写出、严格读取、资源边界与 +自主协议向量,不宣称标准 SML 合规。下一切片在该稳定文本表示上增加 Trace +导出、结构化脱敏与受控重放。 ## 阶段 3:元数据与工具 -- SML 调试读写。 +- [x] SML 调试读写。 - E173 SMN 日志与消息文档。 - E172 SEDD 设备字典导入、校验与发现。 - Trace 重放、脱敏和互操作诊断工具。 diff --git a/docs/SML.md b/docs/SML.md new file mode 100644 index 0000000..1e282d8 --- /dev/null +++ b/docs/SML.md @@ -0,0 +1,74 @@ +# SML 调试读写 + +## 范围 + +SecsFrame.Sml 为现有动态 SecsMessage 与全部 +SecsItem 类型提供确定性文本写出和严格读取。它是独立的可选 +表示层,不进入 HSMS 会话、事务或线上二进制编解码路径。 + +当前实现是 SecsFrame 自主定义并验证的非规范性 SML 调试 profile,不声明 +符合某个 SEMI 标准、厂商方言或一致性测试。消息模型没有名称字段,因此 +profile 只表示 Stream、Function、W-Bit 和可选单根 Item。 + +## 使用 + +~~~csharp +using SecsFrame; +using SecsFrame.Sml; + +var message = new SecsMessage( + stream: 6, + function: 11, + replyExpected: true, + rootItem: SecsItem.List( + SecsItem.Ascii("LOT-001"), + SecsItem.U4(1001))); + +var codec = new SmlMessageCodec(); +var text = codec.Encode(message); +var decoded = codec.Decode(text); +~~~ + +写出固定使用 LF、InvariantCulture 数字、明确的 Item 数量和稳定缩进: + +~~~text +'S6F11'W + + +> +. +~~~ + +读取允许 token 之间存在空白,但格式名、Boolean 和十六进制 token 的大小写 +保持严格;完整消息之后的尾随文本会被拒绝。无 Body 使用消息头后直接跟 +句点,空 List 使用 <L [0] ... >,两者不会混淆。 + +## 文本约定 + +- Item 格式名为 L、B、Boolean、 + A、J、I1/I2/I4/I8、 + U1/U2/U4/U8 和 F4/F8。 +- Binary 与 JIS-8 使用 0xHH 原始字节;JIS-8 不猜测现场代码页。 +- Boolean 使用 True 或 False。 +- ASCII 位于单引号内,反斜线、单引号、回车、换行、制表符和不可打印 + 七位字符分别使用可逆转义;\xHH 仍必须位于七位范围。 +- 浮点数使用 .NET round-trip 格式,保留有限值、非数和正负无穷的往返。 +- 读取严格验证声明数量、数值宽度、单根 Item、消息终止符和尾随文本。 + +SmlMessageCodec 默认沿用核心 Item codec 的最大嵌套深度和 +Item 总数,并另外限制单个 Item 的值数量及输入/输出文本长度。调用方可以 +在构造时收紧这些限制;解析失败使用带字符偏移、行和列的 +SmlParseException。 + +## 参考与版权边界 + +实现前只读交叉核对了 MIT 许可的 +[secs4net](https://github.com/mkjeff/secs4net) 中公开的 +Secs4Net.Sml 项目,以确认常见的消息头、Item 格式名和 Boolean +拼写。本实现围绕 SecsFrame 的不可变模型和严格资源边界独立编写,没有复制 +其源码;测试向量也在本仓库自主构造。JIS-8 原始字节与 ASCII 转义是本 +profile 为无损调试增加的明确约定。 + +该参考不参与构建。官方 secs4net 互操作项目仍只使用 nuget.org 发布并固定 +为 3.1.0 的 NuGet 包。 diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index 8270b46..cdb23c6 100644 --- a/docs/STANDARDS.md +++ b/docs/STANDARDS.md @@ -65,6 +65,11 @@ 未来如引入实质性开源代码,必须保留对应版权与许可证文本,并在变更说明 中记录来源。 +SecsFrame.Sml 的调试 profile 不是规范性标准实现。开发前只读 +参考了 MIT 许可的 secs4net 公开 SML 项目以交叉核对常见文本拼写;当前 +实现和测试向量均独立编写,JIS-8 原始字节与 ASCII 转义边界见 +[SML.md](SML.md)。 + 与官方 Secs4Net 3.1.0 的 Select、Linktest 和动态消息往返是独立软件实现 之间的工程互操作证据,不代表任何一方通过 SEMI 一致性认证。具体矩阵见 [SECS4NET-INTEROP.md](SECS4NET-INTEROP.md)。 diff --git a/src/SecsFrame.Sml/SecsFrame.Sml.csproj b/src/SecsFrame.Sml/SecsFrame.Sml.csproj new file mode 100644 index 0000000..a137945 --- /dev/null +++ b/src/SecsFrame.Sml/SecsFrame.Sml.csproj @@ -0,0 +1,23 @@ + + + netstandard2.0;net8.0;net10.0 + Deterministic SML debug text support for SecsFrame messages. + SecsFrame.Sml + MIT + README.md + secs;secs-ii;sml;debug;serialization;secsframe + 0.1.0-alpha.1 + true + false + true + snupkg + + + + + + + + + + diff --git a/src/SecsFrame.Sml/SmlMessageCodec.cs b/src/SecsFrame.Sml/SmlMessageCodec.cs new file mode 100644 index 0000000..f193a63 --- /dev/null +++ b/src/SecsFrame.Sml/SmlMessageCodec.cs @@ -0,0 +1,87 @@ +namespace SecsFrame.Sml; + +/// +/// Encodes and decodes the deterministic SecsFrame SML debug profile. +/// +/// +/// This is a non-normative diagnostic representation. It does not claim SEMI +/// SML conformance. +/// +public sealed class SmlMessageCodec +{ + /// The default number of spaces added for each nested Item. + public const int DefaultIndentSize = 4; + + /// The default maximum root-inclusive Item depth. + public const int DefaultMaxNestingDepth = SecsItemCodec.DefaultMaxNestingDepth; + + /// The default maximum number of Items in one message. + public const int DefaultMaxItemCount = SecsItemCodec.DefaultMaxItemCount; + + /// The default maximum primitive values in one Item. + public const int DefaultMaxValueCount = 1_000_000; + + /// The default maximum input or output text length. + public const int DefaultMaxTextLength = 16 * 1024 * 1024; + + /// Creates a deterministic SML debug codec with explicit resource limits. + public SmlMessageCodec( + int indentSize = DefaultIndentSize, + int maxNestingDepth = DefaultMaxNestingDepth, + int maxItemCount = DefaultMaxItemCount, + int maxValueCount = DefaultMaxValueCount, + int maxTextLength = DefaultMaxTextLength) + { + if (indentSize <= 0 || indentSize > 16) + throw new ArgumentOutOfRangeException(nameof(indentSize), indentSize, "The indent size must be between 1 and 16."); + if (maxNestingDepth <= 0) + throw new ArgumentOutOfRangeException(nameof(maxNestingDepth), maxNestingDepth, "The maximum depth must be positive."); + if (maxItemCount <= 0) + throw new ArgumentOutOfRangeException(nameof(maxItemCount), maxItemCount, "The maximum Item count must be positive."); + if (maxValueCount <= 0) + throw new ArgumentOutOfRangeException(nameof(maxValueCount), maxValueCount, "The maximum value count must be positive."); + if (maxTextLength <= 0) + throw new ArgumentOutOfRangeException(nameof(maxTextLength), maxTextLength, "The maximum text length must be positive."); + + IndentSize = indentSize; + MaxNestingDepth = maxNestingDepth; + MaxItemCount = maxItemCount; + MaxValueCount = maxValueCount; + MaxTextLength = maxTextLength; + } + + /// Gets the number of spaces added for each nested Item. + public int IndentSize { get; } + + /// Gets the maximum root-inclusive Item depth. + public int MaxNestingDepth { get; } + + /// Gets the maximum number of Items in one message. + public int MaxItemCount { get; } + + /// Gets the maximum primitive values in one Item. + public int MaxValueCount { get; } + + /// Gets the maximum accepted or produced text length. + public int MaxTextLength { get; } + + /// Encodes one dynamic message to deterministic LF-terminated text. + public string Encode(SecsMessage message) + { + if (message is null) + throw new ArgumentNullException(nameof(message)); + + return new SmlMessageWriter(this).Write(message); + } + + /// Strictly decodes one complete SML message. + public SecsMessage Decode(string text) + { + if (text is null) + throw new ArgumentNullException(nameof(text)); + if (text.Length > MaxTextLength) + throw new ArgumentOutOfRangeException(nameof(text), text.Length, $"The SML text length cannot exceed {MaxTextLength} characters."); + + return new SmlMessageParser(text, this).Parse(); + } +} diff --git a/src/SecsFrame.Sml/SmlMessageParser.cs b/src/SecsFrame.Sml/SmlMessageParser.cs new file mode 100644 index 0000000..8e0f040 --- /dev/null +++ b/src/SecsFrame.Sml/SmlMessageParser.cs @@ -0,0 +1,427 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; + +namespace SecsFrame.Sml; + +internal sealed class SmlMessageParser +{ + private readonly string _text; + private readonly SmlMessageCodec _options; + private int _index; + private int _line = 1; + private int _column = 1; + private int _itemCount; + + public SmlMessageParser(string text, SmlMessageCodec options) + { + _text = text; + _options = options; + } + + public SecsMessage Parse() + { + SkipWhitespace(); + var (stream, function, replyExpected) = ParseHeader(); + SkipWhitespace(); + + SecsItem? rootItem = null; + if (!IsAt('.')) + rootItem = ParseItem(depth: 1); + + SkipWhitespace(); + Expect('.', "the message terminator"); + SkipWhitespace(); + if (!IsEnd) + throw Error("A complete SML message was followed by trailing text"); + + return new SecsMessage(stream, function, replyExpected, rootItem); + } + + private (byte Stream, byte Function, bool ReplyExpected) ParseHeader() + { + Expect('\'', "the opening message quote"); + Expect('S', "an uppercase S in the message header"); + var stream = ReadDecimal("the stream number", byte.MaxValue); + if (stream > 0x7F) + throw Error("The stream number must be between 0 and 127"); + + Expect('F', "an uppercase F in the message header"); + var function = ReadDecimal("the function number", byte.MaxValue); + Expect('\'', "the closing message quote"); + SkipWhitespace(); + + var replyExpected = false; + if (IsAt('W')) + { + Advance(); + replyExpected = true; + } + + return ((byte)stream, (byte)function, replyExpected); + } + + private SecsItem ParseItem(int depth) + { + if (depth > _options.MaxNestingDepth) + throw Error($"The Item nesting depth exceeds the configured maximum {_options.MaxNestingDepth}"); + if (++_itemCount > _options.MaxItemCount) + throw Error($"The Item count exceeds the configured maximum {_options.MaxItemCount}"); + + SkipWhitespace(); + Expect('<', "an Item opening delimiter"); + SkipWhitespace(); + var formatToken = ReadToken("an Item format", stopAtOpenBracket: true); + var format = ParseFormat(formatToken); + SkipWhitespace(); + Expect('[', "an Item count opening bracket"); + var count = ReadDecimal("the Item count", SecsItem.MaxEncodedLength); + SkipWhitespace(); + Expect(']', "an Item count closing bracket"); + + if (format == SecsItemFormat.List) + return ParseList(count, depth); + if (count > _options.MaxValueCount) + throw Error($"The Item value count exceeds the configured maximum {_options.MaxValueCount}"); + + return ParsePrimitive(format, count); + } + + private SecsItem ParseList(int count, int depth) + { + if (count > _options.MaxItemCount - _itemCount) + throw Error($"The declared List count exceeds the configured maximum Item count {_options.MaxItemCount}"); + + var items = new SecsItem[count]; + for (var index = 0; index < items.Length; index++) + { + SkipWhitespace(); + items[index] = ParseItem(depth + 1); + } + + SkipWhitespace(); + Expect('>', "the List closing delimiter"); + return SecsItem.List(items); + } + + private SecsItem ParsePrimitive(SecsItemFormat format, int count) + { + SkipWhitespace(); + SecsItem item = format switch + { + SecsItemFormat.Binary => SecsItem.Binary(ReadValues(count, ParseHexByte)), + SecsItemFormat.Boolean => SecsItem.Boolean(ReadValues(count, ParseBoolean)), + SecsItemFormat.Ascii => ParseAscii(count), + SecsItemFormat.Jis8 => SecsItem.Jis8(ReadValues(count, ParseHexByte)), + SecsItemFormat.I8 => SecsItem.I8(ReadValues(count, ParseInt64)), + SecsItemFormat.I1 => SecsItem.I1(ReadValues(count, ParseSByte)), + SecsItemFormat.I2 => SecsItem.I2(ReadValues(count, ParseInt16)), + SecsItemFormat.I4 => SecsItem.I4(ReadValues(count, ParseInt32)), + SecsItemFormat.F8 => SecsItem.F8(ReadValues(count, ParseDouble)), + SecsItemFormat.F4 => SecsItem.F4(ReadValues(count, ParseSingle)), + SecsItemFormat.U8 => SecsItem.U8(ReadValues(count, ParseUInt64)), + SecsItemFormat.U1 => SecsItem.U1(ReadValues(count, ParseByte)), + SecsItemFormat.U2 => SecsItem.U2(ReadValues(count, ParseUInt16)), + SecsItemFormat.U4 => SecsItem.U4(ReadValues(count, ParseUInt32)), + _ => throw Error($"Unsupported Item format {format}"), + }; + + SkipWhitespace(); + Expect('>', "the Item closing delimiter"); + return item; + } + + private SecsItem ParseAscii(int expectedCount) + { + Expect('\'', "an ASCII opening quote"); + var value = new StringBuilder(expectedCount); + while (!IsEnd && !IsAt('\'')) + { + var character = Current; + if (character == '\r' || character == '\n') + throw Error("ASCII text cannot contain an unescaped line break"); + + Advance(); + if (character != '\\') + { + if (character > 0x7F) + throw Error("ASCII text contains a character outside the seven-bit range"); + value.Append(character); + continue; + } + + value.Append(ParseAsciiEscape()); + } + + Expect('\'', "an ASCII closing quote"); + if (value.Length != expectedCount) + throw Error($"The ASCII value count {value.Length} does not match the declared count {expectedCount}"); + + return SecsItem.Ascii(value.ToString()); + } + + private char ParseAsciiEscape() + { + if (IsEnd) + throw Error("An ASCII escape sequence is incomplete"); + + var escape = Current; + Advance(); + return escape switch + { + '\\' => '\\', + '\'' => '\'', + 'r' => '\r', + 'n' => '\n', + 't' => '\t', + 'x' => ParseAsciiHexEscape(), + _ => throw Error($"Unsupported ASCII escape sequence \\{escape}"), + }; + } + + private char ParseAsciiHexEscape() + { + var high = ReadHexDigit("the first ASCII hexadecimal escape digit"); + var low = ReadHexDigit("the second ASCII hexadecimal escape digit"); + var value = (high << 4) | low; + if (value > 0x7F) + throw Error("An ASCII hexadecimal escape must remain in the seven-bit range"); + return (char)value; + } + + private T[] ReadValues(int count, Func parse) + { + var values = new T[count]; + for (var index = 0; index < values.Length; index++) + { + SkipWhitespace(); + values[index] = parse(ReadToken("an Item value", stopAtOpenBracket: false)); + } + return values; + } + + private byte ParseHexByte(Token token) + { + if (token.Value.Length != 4 || token.Value[0] != '0' || token.Value[1] != 'x') + throw ErrorAt(token, "A Binary or JIS-8 value must use the exact form 0xHH"); + + var high = ParseHexDigit(token.Value[2]); + var low = ParseHexDigit(token.Value[3]); + if (high < 0 || low < 0) + throw ErrorAt(token, "A Binary or JIS-8 value contains an invalid hexadecimal digit"); + return (byte)((high << 4) | low); + } + + private bool ParseBoolean(Token token) + => token.Value switch + { + "True" => true, + "False" => false, + _ => throw ErrorAt(token, "A Boolean value must be True or False"), + }; + + private sbyte ParseSByte(Token token) + => sbyte.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The I1 value is outside its signed 8-bit range"); + + private short ParseInt16(Token token) + => short.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The I2 value is outside its signed 16-bit range"); + + private int ParseInt32(Token token) + => int.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The I4 value is outside its signed 32-bit range"); + + private long ParseInt64(Token token) + => long.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The I8 value is outside its signed 64-bit range"); + + private byte ParseByte(Token token) + => byte.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The U1 value is outside its unsigned 8-bit range"); + + private ushort ParseUInt16(Token token) + => ushort.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The U2 value is outside its unsigned 16-bit range"); + + private uint ParseUInt32(Token token) + => uint.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The U4 value is outside its unsigned 32-bit range"); + + private ulong ParseUInt64(Token token) + => ulong.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The U8 value is outside its unsigned 64-bit range"); + + private float ParseSingle(Token token) + => float.TryParse(token.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The F4 value is not a valid invariant single-precision number"); + + private double ParseDouble(Token token) + => double.TryParse(token.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + ? value + : throw ErrorAt(token, "The F8 value is not a valid invariant double-precision number"); + + private SecsItemFormat ParseFormat(Token token) + => token.Value switch + { + "L" => SecsItemFormat.List, + "B" => SecsItemFormat.Binary, + "Boolean" => SecsItemFormat.Boolean, + "A" => SecsItemFormat.Ascii, + "J" => SecsItemFormat.Jis8, + "I8" => SecsItemFormat.I8, + "I1" => SecsItemFormat.I1, + "I2" => SecsItemFormat.I2, + "I4" => SecsItemFormat.I4, + "F8" => SecsItemFormat.F8, + "F4" => SecsItemFormat.F4, + "U8" => SecsItemFormat.U8, + "U1" => SecsItemFormat.U1, + "U2" => SecsItemFormat.U2, + "U4" => SecsItemFormat.U4, + _ => throw ErrorAt(token, $"Unknown Item format {token.Value}"), + }; + + private int ReadDecimal(string description, int maximum) + { + SkipWhitespace(); + var start = CapturePosition(); + var value = 0; + var digitCount = 0; + while (!IsEnd && Current >= '0' && Current <= '9') + { + var digit = Current - '0'; + if (value > (maximum - digit) / 10) + throw ErrorAt(start, $"{description} exceeds {maximum}"); + value = (value * 10) + digit; + digitCount++; + Advance(); + } + + if (digitCount == 0) + throw ErrorAt(start, $"Expected {description}"); + return value; + } + + private Token ReadToken(string description, bool stopAtOpenBracket) + { + var start = CapturePosition(); + while (!IsEnd && !char.IsWhiteSpace(Current) && Current != '>' && (!stopAtOpenBracket || Current != '[')) + Advance(); + if (_index == start.Offset) + throw ErrorAt(start, $"Expected {description}"); + return new Token(_text.Substring(start.Offset, _index - start.Offset), start.Offset, start.Line, start.Column); + } + + private int ReadHexDigit(string description) + { + if (IsEnd) + throw Error($"Expected {description}"); + var value = ParseHexDigit(Current); + if (value < 0) + throw Error($"Expected {description}"); + Advance(); + return value; + } + + private void Expect(char expected, string description) + { + if (IsEnd || Current != expected) + throw Error($"Expected {description}"); + Advance(); + } + + private void SkipWhitespace() + { + while (!IsEnd && char.IsWhiteSpace(Current)) + Advance(); + } + + private void Advance() + { + if (Current == '\n') + { + _line++; + _column = 1; + } + else + { + _column++; + } + _index++; + } + + private Position CapturePosition() + => new(_index, _line, _column); + + private SmlParseException Error(string message) + => new(message, _index, _line, _column); + + private static SmlParseException ErrorAt(Position position, string message) + => new(message, position.Offset, position.Line, position.Column); + + private static SmlParseException ErrorAt(Token token, string message) + => new(message, token.Offset, token.Line, token.Column); + + private bool IsAt(char value) + => !IsEnd && Current == value; + + private bool IsEnd => _index >= _text.Length; + + private char Current => _text[_index]; + + private static int ParseHexDigit(char value) + { + if (value >= '0' && value <= '9') + return value - '0'; + if (value >= 'A' && value <= 'F') + return value - 'A' + 10; + return -1; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct Position + { + public Position(int offset, int line, int column) + { + Offset = offset; + Line = line; + Column = column; + } + + public int Offset { get; } + + public int Line { get; } + + public int Column { get; } + } + + [StructLayout(LayoutKind.Auto)] + private readonly struct Token + { + public Token(string value, int offset, int line, int column) + { + Value = value; + Offset = offset; + Line = line; + Column = column; + } + + public string Value { get; } + + public int Offset { get; } + + public int Line { get; } + + public int Column { get; } + } +} diff --git a/src/SecsFrame.Sml/SmlMessageWriter.cs b/src/SecsFrame.Sml/SmlMessageWriter.cs new file mode 100644 index 0000000..ef69638 --- /dev/null +++ b/src/SecsFrame.Sml/SmlMessageWriter.cs @@ -0,0 +1,242 @@ +using System.Globalization; +using System.Text; + +namespace SecsFrame.Sml; + +internal sealed class SmlMessageWriter +{ + private readonly SmlMessageCodec _options; + private readonly StringBuilder _text = new(); + private int _itemCount; + + public SmlMessageWriter(SmlMessageCodec options) + => _options = options; + + public string Write(SecsMessage message) + { + _text.Append("'S"); + _text.Append(message.Stream.ToString(CultureInfo.InvariantCulture)); + _text.Append('F'); + _text.Append(message.Function.ToString(CultureInfo.InvariantCulture)); + _text.Append('\''); + if (message.ReplyExpected) + _text.Append('W'); + _text.Append('\n'); + + if (message.RootItem is not null) + WriteItem(message.RootItem, depth: 1); + + _text.Append(".\n"); + EnsureTextLength(); + return _text.ToString(); + } + + private void WriteItem(SecsItem item, int depth) + { + if (depth > _options.MaxNestingDepth) + throw new InvalidOperationException($"The Item nesting depth exceeds the configured maximum {_options.MaxNestingDepth}."); + if (++_itemCount > _options.MaxItemCount) + throw new InvalidOperationException($"The Item count exceeds the configured maximum {_options.MaxItemCount}."); + if (item.Format != SecsItemFormat.List && item.Count > _options.MaxValueCount) + throw new InvalidOperationException($"The Item value count exceeds the configured maximum {_options.MaxValueCount}."); + + AppendIndent(depth - 1); + _text.Append('<'); + _text.Append(GetFormatName(item.Format)); + _text.Append(" ["); + _text.Append(item.Count.ToString(CultureInfo.InvariantCulture)); + _text.Append(']'); + + if (item.Format == SecsItemFormat.List) + { + _text.Append('\n'); + foreach (var child in item.Items) + WriteItem(child, depth + 1); + AppendIndent(depth - 1); + } + else if (item.Format == SecsItemFormat.Ascii || item.Count != 0) + { + _text.Append(' '); + WriteValues(item); + } + + _text.Append(">\n"); + EnsureTextLength(); + } + + private void WriteValues(SecsItem item) + { + switch (item.Format) + { + case SecsItemFormat.Binary: + case SecsItemFormat.Jis8: + WriteHexValues(item.GetValues()); + break; + case SecsItemFormat.Boolean: + WriteBooleanValues(item.GetValues()); + break; + case SecsItemFormat.Ascii: + WriteAscii(item.GetString()); + break; + case SecsItemFormat.I8: + WriteIntegerValues(item.GetValues()); + break; + case SecsItemFormat.I1: + WriteIntegerValues(item.GetValues()); + break; + case SecsItemFormat.I2: + WriteIntegerValues(item.GetValues()); + break; + case SecsItemFormat.I4: + WriteIntegerValues(item.GetValues()); + break; + case SecsItemFormat.F8: + WriteFloatValues(item.GetValues()); + break; + case SecsItemFormat.F4: + WriteFloatValues(item.GetValues()); + break; + case SecsItemFormat.U8: + WriteIntegerValues(item.GetValues()); + break; + case SecsItemFormat.U1: + WriteIntegerValues(item.GetValues()); + break; + case SecsItemFormat.U2: + WriteIntegerValues(item.GetValues()); + break; + case SecsItemFormat.U4: + WriteIntegerValues(item.GetValues()); + break; + default: + throw new ArgumentOutOfRangeException(nameof(item), item.Format, "Unknown SECS-II Item format."); + } + } + + private void WriteAscii(string value) + { + _text.Append('\''); + foreach (var character in value) + { + switch (character) + { + case '\\': + _text.Append("\\\\"); + break; + case '\'': + _text.Append("\\'"); + break; + case '\r': + _text.Append("\\r"); + break; + case '\n': + _text.Append("\\n"); + break; + case '\t': + _text.Append("\\t"); + break; + default: + if (character >= 0x20 && character <= 0x7E) + { + _text.Append(character); + } + else + { + _text.Append("\\x"); + AppendHexByte((byte)character); + } + break; + } + } + _text.Append('\''); + } + + private void WriteHexValues(ReadOnlySpan values) + { + for (var index = 0; index < values.Length; index++) + { + if (index != 0) + _text.Append(' '); + _text.Append("0x"); + AppendHexByte(values[index]); + } + } + + private void AppendHexByte(byte value) + { + const string hex = "0123456789ABCDEF"; + _text.Append(hex[value >> 4]); + _text.Append(hex[value & 0x0F]); + } + + private void WriteBooleanValues(ReadOnlySpan values) + { + for (var index = 0; index < values.Length; index++) + { + if (index != 0) + _text.Append(' '); + _text.Append(values[index] ? "True" : "False"); + } + } + + private void WriteIntegerValues(ReadOnlySpan values) + where T : struct, IFormattable + { + for (var index = 0; index < values.Length; index++) + { + if (index != 0) + _text.Append(' '); + _text.Append(values[index].ToString(null, CultureInfo.InvariantCulture)); + } + } + + private void WriteFloatValues(ReadOnlySpan values) + { + for (var index = 0; index < values.Length; index++) + { + if (index != 0) + _text.Append(' '); + _text.Append(values[index].ToString("R", CultureInfo.InvariantCulture)); + } + } + + private void WriteFloatValues(ReadOnlySpan values) + { + for (var index = 0; index < values.Length; index++) + { + if (index != 0) + _text.Append(' '); + _text.Append(values[index].ToString("R", CultureInfo.InvariantCulture)); + } + } + + private void AppendIndent(int level) + => _text.Append(' ', checked(level * _options.IndentSize)); + + private void EnsureTextLength() + { + if (_text.Length > _options.MaxTextLength) + throw new InvalidOperationException($"The SML text length exceeds the configured maximum {_options.MaxTextLength}."); + } + + private static string GetFormatName(SecsItemFormat format) + => format switch + { + SecsItemFormat.List => "L", + SecsItemFormat.Binary => "B", + SecsItemFormat.Boolean => "Boolean", + SecsItemFormat.Ascii => "A", + SecsItemFormat.Jis8 => "J", + SecsItemFormat.I8 => "I8", + SecsItemFormat.I1 => "I1", + SecsItemFormat.I2 => "I2", + SecsItemFormat.I4 => "I4", + SecsItemFormat.F8 => "F8", + SecsItemFormat.F4 => "F4", + SecsItemFormat.U8 => "U8", + SecsItemFormat.U1 => "U1", + SecsItemFormat.U2 => "U2", + SecsItemFormat.U4 => "U4", + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown SECS-II Item format."), + }; +} diff --git a/src/SecsFrame.Sml/SmlParseException.cs b/src/SecsFrame.Sml/SmlParseException.cs new file mode 100644 index 0000000..43544f0 --- /dev/null +++ b/src/SecsFrame.Sml/SmlParseException.cs @@ -0,0 +1,22 @@ +namespace SecsFrame.Sml; + +/// Reports a strict SML parsing failure with source coordinates. +public sealed class SmlParseException : FormatException +{ + internal SmlParseException(string message, int offset, int line, int column) + : base($"{message} (line {line}, column {column}, offset {offset}).") + { + Offset = offset; + Line = line; + Column = column; + } + + /// Gets the zero-based character offset. + public int Offset { get; } + + /// Gets the one-based line number. + public int Line { get; } + + /// Gets the one-based column number. + public int Column { get; } +} diff --git a/test/SecsFrame.Tests/SecsFrame.Tests.csproj b/test/SecsFrame.Tests/SecsFrame.Tests.csproj index e4e6ce3..2a9e300 100644 --- a/test/SecsFrame.Tests/SecsFrame.Tests.csproj +++ b/test/SecsFrame.Tests/SecsFrame.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/test/SecsFrame.Tests/SmlMessageCodecTests.cs b/test/SecsFrame.Tests/SmlMessageCodecTests.cs new file mode 100644 index 0000000..07f6bf4 --- /dev/null +++ b/test/SecsFrame.Tests/SmlMessageCodecTests.cs @@ -0,0 +1,167 @@ +using SecsFrame.Sml; + +namespace SecsFrame.Tests; + +public sealed class SmlMessageCodecTests +{ + [Fact] + public void Encode_produces_a_deterministic_nested_vector() + { + var codec = new SmlMessageCodec(indentSize: 2); + var message = new SecsMessage( + 1, + 2, + replyExpected: true, + SecsItem.List( + SecsItem.Ascii("LOT"), + SecsItem.Binary(0x00, 0xFF), + SecsItem.List())); + + var text = codec.Encode(message); + + Assert.Equal( + "'S1F2'W\n" + + "\n" + + " \n" + + " \n" + + ">\n" + + ".\n", + text); + } + + [Fact] + public void All_item_types_round_trip_without_culture_or_width_loss() + { + var root = SecsItem.List( + SecsItem.Binary(0x00, 0x80, 0xFF), + SecsItem.Boolean(true, false), + SecsItem.Ascii("A'\\\r\n\t\0Z"), + SecsItem.Jis8(0x00, 0x7F, 0x80, 0xFF), + SecsItem.I8(long.MinValue, 0, long.MaxValue), + SecsItem.I1(sbyte.MinValue, 0, sbyte.MaxValue), + SecsItem.I2(short.MinValue, 0, short.MaxValue), + SecsItem.I4(int.MinValue, 0, int.MaxValue), + SecsItem.F8(double.NegativeInfinity, -0d, double.Epsilon, double.NaN, double.PositiveInfinity), + SecsItem.F4(float.NegativeInfinity, -0f, float.Epsilon, float.NaN, float.PositiveInfinity), + SecsItem.U8(0, ulong.MaxValue), + SecsItem.U1(0, byte.MaxValue), + SecsItem.U2(0, ushort.MaxValue), + SecsItem.U4(0, uint.MaxValue), + SecsItem.List()); + var expected = new SecsMessage(127, 255, true, root); + var codec = new SmlMessageCodec(); + + var encoded = codec.Encode(expected); + var actual = codec.Decode(encoded); + + AssertMessageEqual(expected, actual); + Assert.Contains("", encoded, StringComparison.Ordinal); + Assert.Contains("", encoded, StringComparison.Ordinal); + } + + [Fact] + public void No_body_and_empty_list_remain_distinct() + { + var codec = new SmlMessageCodec(); + + var noBody = codec.Decode(codec.Encode(new SecsMessage(1, 0))); + var emptyList = codec.Decode(codec.Encode(new SecsMessage(1, 0, rootItem: SecsItem.List()))); + + Assert.Null(noBody.RootItem); + Assert.NotNull(emptyList.RootItem); + Assert.Equal(SecsItemFormat.List, emptyList.RootItem.Format); + Assert.Empty(emptyList.RootItem.Items); + } + + [Fact] + public void Decode_accepts_insignificant_whitespace_but_canonicalizes_output() + { + const string input = " 'S6F11' W < L [ 2 ] < U4 [1] 42 > < Boolean [2] True False > > . "; + var codec = new SmlMessageCodec(indentSize: 2); + + var message = codec.Decode(input); + + Assert.Equal( + "'S6F11'W\n" + + "\n" + + " \n" + + ">\n" + + ".\n", + codec.Encode(message)); + } + + [Theory] + [InlineData("'S128F1'\n.\n")] + [InlineData("'S1F1'\n\n.\n")] + [InlineData("'S1F1'\n\n.\n")] + [InlineData("'S1F1'\n\n.\n")] + [InlineData("'S1F1'\n\n.\n")] + [InlineData("'S1F1'\n\n. trailing")] + public void Decode_rejects_non_canonical_or_malformed_vectors(string text) + { + var error = Assert.Throws(() => new SmlMessageCodec().Decode(text)); + + Assert.True(error.Line > 0); + Assert.True(error.Column > 0); + Assert.True(error.Offset >= 0); + } + + [Fact] + public void Decode_reports_the_source_location() + { + const string text = "'S1F1'\n\n>\n.\n"; + + var error = Assert.Throws(() => new SmlMessageCodec().Decode(text)); + + Assert.Equal(3, error.Line); + Assert.Equal(13, error.Column); + Assert.Contains("U1", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Decode_enforces_depth_item_value_and_text_limits() + { + const string nested = "'S1F1'\n\n>\n.\n"; + const string twoItems = "'S1F1'\n\n>\n.\n"; + const string twoValues = "'S1F1'\n\n.\n"; + + Assert.Throws(() => new SmlMessageCodec(maxNestingDepth: 1).Decode(nested)); + Assert.Throws(() => new SmlMessageCodec(maxItemCount: 1).Decode(twoItems)); + Assert.Throws(() => new SmlMessageCodec(maxValueCount: 1).Decode(twoValues)); + Assert.Throws(() => new SmlMessageCodec(maxTextLength: 4).Decode("12345")); + } + + [Fact] + public void Encode_enforces_the_same_structural_limits() + { + var nested = new SecsMessage(1, 1, rootItem: SecsItem.List(SecsItem.List())); + var values = new SecsMessage(1, 1, rootItem: SecsItem.U1(1, 2)); + + Assert.Throws(() => new SmlMessageCodec(maxNestingDepth: 1).Encode(nested)); + Assert.Throws(() => new SmlMessageCodec(maxItemCount: 1).Encode(nested)); + Assert.Throws(() => new SmlMessageCodec(maxValueCount: 1).Encode(values)); + Assert.Throws(() => new SmlMessageCodec(maxTextLength: 4).Encode(values)); + } + + [Fact] + public void Constructor_rejects_invalid_limits() + { + Assert.Throws(() => new SmlMessageCodec(indentSize: 0)); + Assert.Throws(() => new SmlMessageCodec(indentSize: 17)); + Assert.Throws(() => new SmlMessageCodec(maxNestingDepth: 0)); + Assert.Throws(() => new SmlMessageCodec(maxItemCount: 0)); + Assert.Throws(() => new SmlMessageCodec(maxValueCount: 0)); + Assert.Throws(() => new SmlMessageCodec(maxTextLength: 0)); + } + + private static void AssertMessageEqual(SecsMessage expected, SecsMessage actual) + { + Assert.Equal(expected.Stream, actual.Stream); + Assert.Equal(expected.Function, actual.Function); + Assert.Equal(expected.ReplyExpected, actual.ReplyExpected); + Assert.Equal(expected.RootItem, actual.RootItem); + } +}