You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by sr...@apache.org on 2026/09/30 07:47:53 UTC
(plc4x) branch develop updated: feat(plc4net): add the MSpec C# generator (#2785)
This is an automated email from the ASF dual-hosted git repository.
sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git
The following commit(s) were added to refs/heads/develop by this push:
new b878affa2d feat(plc4net): add the MSpec C# generator (#2785)
b878affa2d is described below
commit b878affa2d7b6234cc26f49084751b98f7bb4a15
Author: TomNewChao <ch...@gmail.com>
AuthorDate: Wed Sep 30 15:47:47 2026 +0800
feat(plc4net): add the MSpec C# generator (#2785)
* fix(plc4net): preserve expression literal semantics
* ci(plc4net): verify generated parser artifacts
* feat(plc4net): render expressions as C#
* feat(plc4net): add the MSpec C# generator
* docs(plc4net): clarify code generator usage
* Fix generated expression helpers and enum accessors
* Harden plc4net code generation boundaries
* Fix generated parser validation and virtual fields
---
.github/workflows/dotnet-platform.yml | 33 +-
.../code-gen-test/CSharpExpressionRendererTests.cs | 85 +
plc4net/test/code-gen-test/ExpressionTests.cs | 197 ++
plc4net/test/code-gen-test/MSpecLexerTests.cs | 78 +
plc4net/test/code-gen-test/MspecParserTests.cs | 93 +
plc4net/test/code-gen-test/ParserSmokeTests.cs | 15 +-
plc4net/test/code-gen-test/PipelineTests.cs | 612 +++++
plc4net/test/code-gen-test/RepoPaths.cs | 53 +
plc4net/test/code-gen-test/TestsuiteRunnerTests.cs | 94 +
plc4net/tools/code-gen/MspecExpressionParser.cs | 7 +-
plc4net/tools/code-gen/MspecModelBuilder.cs | 571 +++++
plc4net/tools/code-gen/MspecReader.cs | 125 +
.../code-gen/ParserSerializerTestsuiteRunner.cs | 148 ++
plc4net/tools/code-gen/Program.cs | 139 ++
plc4net/tools/code-gen/README.md | 101 +
plc4net/tools/code-gen/code-gen.csproj | 2 +-
plc4net/tools/code-gen/model/Definitions.cs | 199 ++
plc4net/tools/code-gen/model/Fields.cs | 184 ++
plc4net/tools/code-gen/model/Protocol.cs | 66 +
plc4net/tools/code-gen/model/TypeReference.cs | 107 +
plc4net/tools/code-gen/model/terms/Term.cs | 13 +-
.../code-gen/output/CSharpExpressionRenderer.cs | 211 ++
plc4net/tools/code-gen/output/CSharpGenerator.cs | 2435 ++++++++++++++++++++
plc4net/tools/code-gen/output/CSharpTypeMapper.cs | 192 ++
plc4net/tools/code-gen/output/CodeWriter.cs | 46 +
plc4net/tools/code-gen/output/FieldScope.cs | 198 ++
26 files changed, 5992 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/dotnet-platform.yml b/.github/workflows/dotnet-platform.yml
index 42b6dca861..c7b56e9880 100644
--- a/.github/workflows/dotnet-platform.yml
+++ b/.github/workflows/dotnet-platform.yml
@@ -21,16 +21,45 @@ name: .NET Platform Compatibility
on:
push:
branches: [develop, plc4net]
- paths: [plc4net/**, .github/workflows/dotnet-platform.yml]
+ paths:
+ - plc4net/**
+ - code-generation/protocol-base-mspec/src/main/antlr4/org/apache/plc4x/plugins/codegenerator/language/mspec/**
+ - .github/workflows/dotnet-platform.yml
pull_request:
branches: [develop, plc4net]
- paths: [plc4net/**, .github/workflows/dotnet-platform.yml]
+ paths:
+ - plc4net/**
+ - code-generation/protocol-base-mspec/src/main/antlr4/org/apache/plc4x/plugins/codegenerator/language/mspec/**
+ - .github/workflows/dotnet-platform.yml
workflow_dispatch:
permissions:
contents: read
jobs:
+ parser-generation:
+ name: Generated parser artifacts
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v7
+ - name: Set up Python
+ uses: actions/setup-python@v7
+ with:
+ python-version: '3.12'
+ - name: Set up Java
+ uses: actions/setup-java@v6
+ with:
+ distribution: 'temurin'
+ java-version: 21
+ - name: Cache ANTLR
+ uses: actions/cache@v6
+ with:
+ path: ~/.cache/plc4x/antlr
+ key: ${{ runner.os }}-antlr-4.13.2
+ - name: Verify generated parser artifacts
+ run: python plc4net/tools/code-gen/generate_parsers.py --check
+
test:
name: Build and test (${{ matrix.os }})
strategy:
diff --git a/plc4net/test/code-gen-test/CSharpExpressionRendererTests.cs b/plc4net/test/code-gen-test/CSharpExpressionRendererTests.cs
new file mode 100644
index 0000000000..436b44fff7
--- /dev/null
+++ b/plc4net/test/code-gen-test/CSharpExpressionRendererTests.cs
@@ -0,0 +1,85 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using org.apache.plc4net.tools.codegen;
+using org.apache.plc4net.tools.codegen.output;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen;
+
+public class CSharpExpressionRendererTests
+{
+ [Theory]
+ [InlineData("0xFFFFFFFFFFFFFFFF", "0xFFFFFFFFFFFFFFFF")]
+ [InlineData("pdu.lengthInBytes + 1", "(pdu.GetLengthInBytes() + 1)")]
+ [InlineData("COUNT(value)", "value.Count")]
+ [InlineData("2 ^ exponent", "System.Math.Pow(2, exponent)")]
+ [InlineData("ARRAY_SIZE_IN_BYTES(items)", "StaticHelper.ArraySizeInBytes(items)")]
+ [InlineData("STATIC_CALL(\"rtuCrcCheck\", address, pdu)", "StaticHelper.RtuCrcCheck(address, pdu)")]
+ [InlineData("CAST(parameter, \"S7Parameter\").items[0]", "((S7Parameter) parameter).Items[0]")]
+ [InlineData("CAST(parameter, S7Parameter).items[0]", "((S7Parameter) parameter).Items[0]")]
+ [InlineData("(-value)[0]", "(-value)[0]")]
+ [InlineData("(!flag)[0]", "(!flag)[0]")]
+ public void RendersExpressionAsCSharp(string expression, string expected)
+ {
+ Assert.Equal(expected, Render(expression));
+ }
+
+ [Fact]
+ public void UsesScopeForReferencesCountsAndMembers()
+ {
+ var renderer = new CSharpExpressionRenderer(new TestScope());
+
+ Assert.Equal("(_value.Start + _value.Size)",
+ renderer.Render(MspecExpressionParser.Parse("start + size")));
+ Assert.Equal("_value.Bytes.Length",
+ renderer.Render(MspecExpressionParser.Parse("COUNT(bytes)")));
+ Assert.Equal("_value.Value.GetCode()",
+ renderer.Render(MspecExpressionParser.Parse("value.code")));
+ }
+
+ [Fact]
+ public void SupportsQualifiedUnquotedCastTypesWithoutResolvingThemAsValues()
+ {
+ var renderer = new CSharpExpressionRenderer(new TestScope());
+
+ Assert.Equal("((Protocol.Message) _value.Value)",
+ renderer.Render(MspecExpressionParser.Parse("CAST(value, Protocol.Message)")));
+ }
+
+ [Fact]
+ public void RendersTheMspecEmptyStringCompatibilitySentinel()
+ {
+ Assert.Equal("\"\"", Render(MspecReader.EmptyStringSentinel));
+ }
+
+ private static string Render(string expression) =>
+ new CSharpExpressionRenderer().Render(MspecExpressionParser.Parse(expression));
+
+ private sealed class TestScope : IExpressionScope
+ {
+ public string ResolveReference(string name) => $"_value.{char.ToUpperInvariant(name[0])}{name[1..]}";
+
+ public string? ResolveCount(string name) =>
+ name == "bytes" ? "_value.Bytes.Length" : null;
+
+ public string? ResolveMember(string name) =>
+ name == "code" ? "GetCode()" : null;
+ }
+}
diff --git a/plc4net/test/code-gen-test/ExpressionTests.cs b/plc4net/test/code-gen-test/ExpressionTests.cs
new file mode 100644
index 0000000000..6beb22a3f5
--- /dev/null
+++ b/plc4net/test/code-gen-test/ExpressionTests.cs
@@ -0,0 +1,197 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using org.apache.plc4net.tools.codegen;
+using org.apache.plc4net.tools.codegen.model.terms;
+using org.apache.plc4net.tools.codegen.output;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+ /// <summary>
+ /// The mspec expression engine: <see cref="MspecExpressionParser"/> (text
+ /// -> <see cref="Term"/> tree, via the <c>Expression.g4</c> grammar) and
+ /// <see cref="CSharpExpressionRenderer"/> (tree -> C#). Every field that
+ /// carries a formula - array <c>count</c> / <c>length</c>, <c>implicit</c>,
+ /// <c>virtual</c>, <c>const</c>, discriminator values - depends on both.
+ /// </summary>
+ public class ExpressionTests
+ {
+ // ── literals ─────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("42", "42")]
+ [InlineData("0", "0")]
+ [InlineData("0x0E", "0x0E")]
+ [InlineData("0xFF", "0xFF")]
+ [InlineData("true", "true")]
+ [InlineData("false", "false")]
+ [InlineData("null", "null")]
+ [InlineData("1.5", "1.5")]
+ public void Literals_round_trip(string mspec, string expected)
+ {
+ Assert.Equal(expected, MspecExpressionParser.Parse(mspec).ToString());
+ }
+
+ [Fact]
+ public void String_literal_drops_the_quotes()
+ {
+ var term = Assert.IsType<StringLiteral>(MspecExpressionParser.Parse("\"UTF8\""));
+ Assert.Equal("UTF8", term.Value);
+ }
+
+ // ── operators and precedence ─────────────────────────────
+
+ [Theory]
+ [InlineData("1 + 2", "(1 + 2)")]
+ [InlineData("1 + 2 * 3", "(1 + (2 * 3))")]
+ [InlineData("(1 + 2) * 3", "((1 + 2) * 3)")]
+ [InlineData("byteCount - 6", "(byteCount - 6)")]
+ [InlineData("numberOfValues * 8", "(numberOfValues * 8)")]
+ [InlineData("(COUNT(fifoValue) * 2) + 2", "((COUNT(fifoValue) * 2) + 2)")]
+ [InlineData("a >= b && c != d", "((a >= b) && (c != d))")]
+ [InlineData("!flag", "!flag")]
+ [InlineData("-1", "-1")]
+ [InlineData("a ? b : c", "(a ? b : c)")]
+ public void Operators_parse_with_the_right_shape(string mspec, string expectedTree)
+ {
+ Assert.Equal(expectedTree, MspecExpressionParser.Parse(mspec).ToString());
+ }
+
+ // ── identifier segments: name (args)? [index]? (. child)? ─
+
+ [Fact]
+ public void Dotted_path_becomes_a_child_chain()
+ {
+ var v = Assert.IsType<VariableLiteral>(MspecExpressionParser.Parse("pdu.lengthInBytes"));
+ Assert.Equal("pdu", v.Name);
+ Assert.Null(v.Args);
+ Assert.NotNull(v.Child);
+ Assert.Equal("lengthInBytes", v.Child.Name);
+ }
+
+ [Fact]
+ public void Call_captures_arguments()
+ {
+ var v = Assert.IsType<VariableLiteral>(MspecExpressionParser.Parse("COUNT(value)"));
+ Assert.Equal("COUNT", v.Name);
+ Assert.NotNull(v.Args);
+ Assert.Single(v.Args);
+ Assert.Equal("value", v.Args[0].ToString());
+ }
+
+ [Fact]
+ public void Static_call_keeps_the_string_target_and_the_rest()
+ {
+ var v = Assert.IsType<VariableLiteral>(
+ MspecExpressionParser.Parse("STATIC_CALL(\"rtuCrcCheck\", address, pdu)"));
+ Assert.Equal("STATIC_CALL", v.Name);
+ Assert.NotNull(v.Args);
+ Assert.Equal(3, v.Args.Count);
+ Assert.IsType<StringLiteral>(v.Args[0]);
+ }
+
+ [Fact]
+ public void Indexing_is_captured()
+ {
+ var v = Assert.IsType<VariableLiteral>(MspecExpressionParser.Parse("items[0].fileNumber"));
+ Assert.Single(v.Index);
+ Assert.Equal("0", v.Index[0].ToString());
+ Assert.NotNull(v.Child);
+ Assert.Equal("fileNumber", v.Child.Name);
+ }
+
+ // ── C# rendering ─────────────────────────────────────────
+
+ [Theory]
+ [InlineData("pdu.lengthInBytes + 1", "(pdu.GetLengthInBytes() + 1)")]
+ [InlineData("COUNT(value)", "value.Count")]
+ [InlineData("COUNT(value) + 6", "(value.Count + 6)")]
+ [InlineData("byteCount - 6", "(byteCount - 6)")]
+ [InlineData("(COUNT(fifoValue) * 2) / 2", "((fifoValue.Count * 2) / 2)")]
+ [InlineData("ARRAY_SIZE_IN_BYTES(items)", "StaticHelper.ArraySizeInBytes(items)")]
+ [InlineData("STATIC_CALL(\"rtuCrcCheck\", address, pdu)", "StaticHelper.RtuCrcCheck(address, pdu)")]
+ [InlineData("numberOfValues * 8", "(numberOfValues * 8)")]
+ [InlineData("errorFlag ? 1 : 0", "(errorFlag ? 1 : 0)")]
+ public void Renders_to_C_sharp(string mspec, string expectedCSharp)
+ {
+ var term = MspecExpressionParser.Parse(mspec);
+ Assert.Equal(expectedCSharp, new CSharpExpressionRenderer().Render(term));
+ }
+
+ [Fact]
+ public void The_scope_rewrites_bare_references()
+ {
+ var term = MspecExpressionParser.Parse("startingAddress + quantity");
+ var renderer = new CSharpExpressionRenderer(new PrefixScope("_value."));
+ Assert.Equal("(_value.startingAddress + _value.quantity)", renderer.Render(term));
+ }
+
+ [Fact]
+ public void Power_operator_maps_to_Math_Pow_not_xor()
+ {
+ var term = MspecExpressionParser.Parse("2 ^ exponent");
+ Assert.Equal("System.Math.Pow(2, exponent)", new CSharpExpressionRenderer().Render(term));
+ }
+
+ [Fact]
+ public void Every_modbus_field_expression_parses()
+ {
+ // The formulas that appear verbatim in modbus.mspec.
+ string[] expressions =
+ {
+ "pdu.lengthInBytes + 1",
+ "COUNT(value)",
+ "COUNT(events) + 6",
+ "byteCount - 6",
+ "(COUNT(fifoValue) * 2) + 2",
+ "(COUNT(fifoValue) * 2) / 2",
+ "ARRAY_SIZE_IN_BYTES(items)",
+ "COUNT(data) + 1",
+ "dataLength - 1",
+ "COUNT(recordData) / 2",
+ "recordLength * 2",
+ "COUNT(objects)",
+ "STATIC_CALL(\"rtuCrcCheck\", address, pdu)",
+ "STATIC_CALL(\"asciiLrcCheck\", address, pdu)",
+ };
+
+ var renderer = new CSharpExpressionRenderer();
+ foreach (var expr in expressions)
+ {
+ var term = MspecExpressionParser.Parse(expr);
+ Assert.False(string.IsNullOrEmpty(renderer.Render(term)), expr);
+ }
+ }
+
+ [Fact]
+ public void A_broken_expression_reports_where()
+ {
+ var ex = Assert.Throws<MspecParseException>(() => MspecExpressionParser.Parse("1 +"));
+ Assert.Contains("'1 +'", ex.Message);
+ }
+
+ private sealed class PrefixScope : IExpressionScope
+ {
+ private readonly string _prefix;
+ public PrefixScope(string prefix) => _prefix = prefix;
+ public string ResolveReference(string name) => _prefix + name;
+ }
+ }
+}
diff --git a/plc4net/test/code-gen-test/MSpecLexerTests.cs b/plc4net/test/code-gen-test/MSpecLexerTests.cs
new file mode 100644
index 0000000000..224554ffc4
--- /dev/null
+++ b/plc4net/test/code-gen-test/MSpecLexerTests.cs
@@ -0,0 +1,78 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Linq;
+using Antlr4.Runtime;
+using org.apache.plc4net.tools.codegen.grammar;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+ /// <summary>
+ /// Pins the column-zero predicate of the mspec lexer's <c>EmptyLine</c> rule.
+ /// The grammar is the upstream one and spells the predicate the way the ANTLR
+ /// Java runtime does, <c>getCharPositionInLine()</c>; <c>MSpecLexer.Predicates.cs</c>
+ /// supplies that method for the C# lexer. A line break that starts a line must
+ /// lex as <c>EmptyLine</c>, one that ends a line of text as <c>NEWLINE</c>.
+ /// </summary>
+ public class MSpecLexerTests
+ {
+ private static int[] LineBreakTokenTypes(string text)
+ {
+ var lexer = new MSpecLexer(CharStreams.fromString(text));
+ return lexer.GetAllTokens()
+ .Select(token => token.Type)
+ .Where(type => type == MSpecLexer.EmptyLine || type == MSpecLexer.NEWLINE)
+ .ToArray();
+ }
+
+ [Fact]
+ public void Line_break_at_column_zero_is_an_EmptyLine()
+ {
+ Assert.Equal(new[] { MSpecLexer.EmptyLine }, LineBreakTokenTypes("\n"));
+ }
+
+ [Fact]
+ public void Whitespace_only_line_is_an_EmptyLine()
+ {
+ Assert.Equal(new[] { MSpecLexer.EmptyLine }, LineBreakTokenTypes(" \t\n"));
+ }
+
+ [Fact]
+ public void Line_break_after_text_is_a_NEWLINE()
+ {
+ Assert.Equal(new[] { MSpecLexer.NEWLINE }, LineBreakTokenTypes("x\n"));
+ }
+
+ [Fact]
+ public void Crlf_line_breaks_follow_the_same_rule()
+ {
+ Assert.Equal(new[] { MSpecLexer.EmptyLine, MSpecLexer.NEWLINE }, LineBreakTokenTypes("\r\nx\r\n"));
+ }
+
+ [Fact]
+ public void Blank_line_between_two_lines_of_text()
+ {
+ // "a\n" ends a line of text; the second "\n" starts, and ends, a line of its own.
+ Assert.Equal(
+ new[] { MSpecLexer.NEWLINE, MSpecLexer.EmptyLine, MSpecLexer.NEWLINE },
+ LineBreakTokenTypes("a\n\nb\n"));
+ }
+ }
+}
\ No newline at end of file
diff --git a/plc4net/test/code-gen-test/MspecParserTests.cs b/plc4net/test/code-gen-test/MspecParserTests.cs
new file mode 100644
index 0000000000..bd5939e4c3
--- /dev/null
+++ b/plc4net/test/code-gen-test/MspecParserTests.cs
@@ -0,0 +1,93 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.IO;
+using org.apache.plc4net.tools.codegen;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+ /// <summary>
+ /// Verifies that the pure-.NET mspec parser can parse the Modbus protocol
+ /// description, which is the simplest of the 24 protocol descriptions and
+ /// therefore the first target for the generator pipeline.
+ /// </summary>
+ public class MspecParserTests
+ {
+ private static string? GetModbusMspecPath()
+ {
+ // The mspec file lives in the repo root under protocols/modbus/...
+ // FindRepoRoot walks up from the test output directory; null means
+ // the tests are not running inside a checkout.
+ var repoRoot = RepoPaths.FindRepoRoot();
+ if (repoRoot == null) return null;
+ return Path.Combine(repoRoot,
+ "protocols", "modbus", "src", "main", "resources",
+ "protocols", "modbus", "modbus.mspec");
+ }
+
+ [Fact]
+ public void Modbus_mspec_parses_without_errors()
+ {
+ var path = GetModbusMspecPath();
+ if (path == null)
+ {
+ // Fail loud rather than passing vacuously outside the repo layout.
+ Assert.Fail("repo root not found; real-protocol coverage would be vacuous");
+ }
+
+ // Deliberately no File.Exists guard here: inside a checkout the
+ // file must exist, and a missing one is a real error worth failing
+ // on, not a reason to pass vacuously.
+ var tree = MspecReader.ReadFile(path);
+
+ Assert.NotNull(tree);
+ }
+
+ [Fact]
+ public void A_minimal_mspec_parses()
+ {
+ var mspec = @"
+[type Byte
+ [simple uint 8 value]
+]
+";
+ var tree = MspecReader.Read(mspec);
+ Assert.NotNull(tree);
+ }
+
+ [Fact]
+ public void Malformed_mspec_throws()
+ {
+ var ex = Assert.Throws<MspecParseException>(
+ () => MspecReader.Read("[type Broken <<<"));
+
+ Assert.Contains("Failed to parse", ex.Message);
+ }
+
+ [Fact]
+ public void A_lexer_error_is_reported_by_strict_parsing()
+ {
+ var ex = Assert.Throws<MspecParseException>(
+ () => MspecReader.Read("[type Packet [simple uint 8 value] @]"));
+
+ Assert.Contains("token recognition error", ex.Message);
+ }
+ }
+}
diff --git a/plc4net/test/code-gen-test/ParserSmokeTests.cs b/plc4net/test/code-gen-test/ParserSmokeTests.cs
index 18d99e2750..4def8408e6 100644
--- a/plc4net/test/code-gen-test/ParserSmokeTests.cs
+++ b/plc4net/test/code-gen-test/ParserSmokeTests.cs
@@ -112,6 +112,17 @@ public class ParserSmokeTests
Assert.Equal(expected, MspecExpressionParser.Parse(input).ToString());
}
+ [Theory]
+ [InlineData("0x0E")]
+ [InlineData("0xFFFFFFFFFFFFFFFF")]
+ [InlineData("0x10000000000000000")]
+ public void PreservesHexadecimalLiteralTextWithoutSignedConversion(string input)
+ {
+ var literal = Assert.IsType<HexadecimalLiteral>(MspecExpressionParser.Parse(input));
+
+ Assert.Equal(input, literal.Text);
+ }
+
[Fact]
public void BuildsCallsIndexesAndMemberChains()
{
@@ -151,6 +162,8 @@ public class ParserSmokeTests
[Theory]
[InlineData("\"abc\"[0]", "\"abc\"[0]")]
[InlineData("(left + right)[offset]", "(left + right)[offset]")]
+ [InlineData("(-value)[0]", "(-value)[0]")]
+ [InlineData("(!flag)[0]", "(!flag)[0]")]
public void PreservesIndexesOnAnyExpression(string input, string expected)
{
Assert.Equal(expected, MspecExpressionParser.Parse(input).ToString());
@@ -226,4 +239,4 @@ public class ParserSmokeTests
Errors.Add($"{line}:{charPositionInLine}: {msg}");
}
}
-}
\ No newline at end of file
+}
diff --git a/plc4net/test/code-gen-test/PipelineTests.cs b/plc4net/test/code-gen-test/PipelineTests.cs
new file mode 100644
index 0000000000..ed68e659ff
--- /dev/null
+++ b/plc4net/test/code-gen-test/PipelineTests.cs
@@ -0,0 +1,612 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Security;
+using org.apache.plc4net.tools.codegen;
+using org.apache.plc4net.tools.codegen.model;
+using org.apache.plc4net.tools.codegen.model.fields;
+using org.apache.plc4net.tools.codegen.output;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+ /// <summary>
+ /// End-to-end for the pure-.NET mspec toolchain: text → type-model IR
+ /// (<see cref="MspecModelBuilder"/>) → C# (<see cref="CSharpGenerator"/>).
+ /// These tests cover the IR and emitted C# shapes without requiring a
+ /// generated protocol model to be checked in with the tool itself.
+ /// </summary>
+ public class PipelineTests
+ {
+ // ── IR ──────────────────────────────────────────────────
+
+ [Fact]
+ public void A_plain_type_has_its_fields_in_order()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type Item
+ [simple uint 8 referenceType]
+ [simple uint 16 fileNumber]
+]
+");
+ var item = Assert.Single(model.Types);
+ Assert.Equal("Item", item.Name);
+ Assert.Equal(new[] { "referenceType", "fileNumber" }, item.Fields.Select(f => f.Name));
+ var f0 = Assert.IsType<SimpleField>(item.Fields[0]);
+ var t0 = Assert.IsType<SimpleTypeReference>(f0.Type);
+ Assert.Equal(8, t0.SizeInBits);
+ }
+
+ [Fact]
+ public void A_discriminated_type_lifts_its_cases_to_top_level_children()
+ {
+ var model = MspecModelBuilder.Build(@"
+[discriminatedType Pdu(bit response)
+ [discriminator uint 8 code]
+ [typeSwitch code
+ ['0x01' PduA [simple uint 16 a]]
+ ['0x02' PduB [simple uint 16 b]]
+ ]
+]
+");
+ var parent = model.FindType("Pdu");
+ Assert.NotNull(parent);
+ Assert.True(parent.IsDiscriminatedParent);
+
+ var a = model.FindType("PduA");
+ Assert.NotNull(a);
+ Assert.Equal("Pdu", a.ParentName);
+ Assert.True(a.IsDiscriminatedChild);
+ Assert.NotNull(a.DiscriminatorValues);
+ Assert.Single(a.DiscriminatorValues);
+
+ Assert.NotNull(parent.TypeSwitch);
+ Assert.Equal(new[] { "PduA", "PduB" }, parent.TypeSwitch.CaseNames);
+ }
+
+ [Fact]
+ public void An_enum_carries_its_base_type_and_values()
+ {
+ var model = MspecModelBuilder.Build("[enum uint 8 E ['1' A] ['2' B]]");
+ var e = Assert.Single(model.Enums);
+ Assert.NotNull(e.BaseType);
+ Assert.Equal(SimpleTypeReference.Base.UInt, e.BaseType.BaseType);
+ Assert.Equal(8, e.BaseType.SizeInBits);
+ Assert.Equal(new[] { "A", "B" }, e.Values.Select(v => v.Name));
+ }
+
+ [Fact]
+ public void An_enum_typed_field_resolves_to_an_enum_reference()
+ {
+ var model = MspecModelBuilder.Build(@"
+[enum uint 8 ErrorCode ['1' BAD]]
+[type Err [simple ErrorCode code]]
+");
+ var errType = model.FindType("Err");
+ Assert.NotNull(errType);
+ Assert.IsType<EnumTypeReference>(errType.Fields[0].Type);
+ }
+
+ // ── generation ──────────────────────────────────────────
+
+ [Fact]
+ public void Generates_a_file_per_type_and_enum()
+ {
+ var model = MspecModelBuilder.Build(@"
+[enum uint 8 E ['1' A]]
+[type T [simple uint 8 x]]
+");
+ var files = new CSharpGenerator(model, "demo", "demo.readwrite").Generate();
+ Assert.Contains("model/T.cs", files.Keys);
+ Assert.Contains("model/E.cs", files.Keys);
+ }
+
+ [Fact]
+ public void A_generated_type_has_parse_serialize_and_length()
+ {
+ var model = MspecModelBuilder.Build("[type T [simple uint 16 startingAddress]]");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+ Assert.Contains("public static T StaticParse(ReadBuffer readBuffer)", code);
+ Assert.Contains("readBuffer.ReadUshort(\"startingAddress\", 16)", code);
+ Assert.Contains("public void Serialize(WriteBuffer writeBuffer)", code);
+ Assert.Contains("writeBuffer.WriteUshort(\"startingAddress\", 16, StartingAddress)", code);
+ Assert.Contains("public int GetLengthInBits()", code);
+ }
+
+ [Fact]
+ public void An_implicit_field_is_read_and_discarded_but_written_from_its_formula()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type T
+ [implicit uint 8 byteCount 'COUNT(value)']
+ [array byte value count 'byteCount']
+]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+ // parse: byteCount read into a local, then used as the array length
+ Assert.Contains("var byteCount = readBuffer.ReadByte(\"byteCount\", 8);", code);
+ Assert.Contains("readBuffer.ReadByteArray(\"value\", (int) (byteCount) * 8)", code);
+ // serialize: byteCount computed from the array, not stored
+ Assert.Contains("writeBuffer.WriteByte(\"byteCount\", 8, (byte) (Value.Length));", code);
+ Assert.DoesNotContain("public byte ByteCount", code);
+ }
+
+ [Fact]
+ public void A_checksum_field_is_validated_after_it_is_read()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type T
+ [simple uint 8 payload]
+ [checksum uint 8 crc 'payload']
+]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+ Assert.Contains("var expectedCrc = (byte) (payload);", code);
+ Assert.Contains("if (!Equals(crc, expectedCrc))", code);
+ Assert.Contains("throw new ParseException", code);
+ }
+
+ [Fact]
+ public void A_virtual_field_is_available_to_later_parse_fields_and_as_a_computed_property()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type T
+ [simple uint 8 payload]
+ [virtual uint 8 commandType 'payload + 1']
+ [optional uint 8 detail 'commandType == 2']
+]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+ Assert.Contains("public byte CommandType =>", code);
+ Assert.Contains("Payload + 1", code);
+ Assert.Contains("var commandType =", code);
+ Assert.Contains("payload + 1", code);
+ Assert.Contains("if ((commandType == 2))", code);
+ Assert.DoesNotContain("byte commandType)", code);
+ }
+
+ [Fact]
+ public void A_virtual_field_can_drive_a_type_switch()
+ {
+ var model = MspecModelBuilder.Build(@"
+[discriminatedType Parent
+ [simple uint 8 payload]
+ [virtual uint 8 commandType 'payload + 1']
+ [typeSwitch commandType
+ ['2' Child
+ [simple uint 8 detail]
+ ]
+ ]
+]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/Parent.cs"];
+
+ Assert.Contains("public byte CommandType =>", code);
+ Assert.Contains("var commandType =", code);
+ Assert.Contains("if (Equals(commandType, (byte) (2)))", code);
+ }
+
+ [Fact]
+ public void Enum_accessors_deduplicate_duplicate_underlying_values()
+ {
+ var model = MspecModelBuilder.Build(@"
+[enum uint 8 TransportSize(uint 8 code)
+ ['0x1C' COUNTER ['1']]
+ ['0x1C' DATE_AND_TIME ['2']]
+]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/TransportSize.cs"];
+
+ Assert.Single(System.Text.RegularExpressions.Regex.Matches(
+ code, "TransportSize\\.(COUNTER|DATE_AND_TIME) =>").Cast<System.Text.RegularExpressions.Match>());
+ }
+
+ [Fact]
+ public void An_optional_field_is_nullable_and_conditional()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type T
+ [simple uint 16 dataLength]
+ [optional uint 8 extra 'dataLength >= 12']
+]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+ Assert.Contains("public byte? Extra { get; }", code);
+ // parse: only read when the condition holds
+ Assert.Contains("byte? extra = null;", code);
+ Assert.Contains("if ((dataLength >= 12))", code);
+ // serialize / length: guarded by the null check
+ Assert.Contains("if (Extra != null)", code);
+ Assert.Contains("(Extra != null ? 8 : 0)", code);
+ }
+
+ [Fact]
+ public void A_padding_field_loops_the_repeat_count()
+ {
+ var model = MspecModelBuilder.Build(
+ "[type T [simple uint 8 n] [padding uint 8 pad '0x00' 'n % 2']]");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+ Assert.Contains("var _timesPadding = (int) ((n % 2));", code);
+ Assert.Contains("while (_timesPadding-- > 0)", code);
+ }
+
+ [Fact]
+ public void A_vstring_field_uses_its_run_time_length_expression()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type T
+ [simple uint 16 len]
+ [simple vstring 'len * 8' text stringEncoding='""UTF8""']
+]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+ Assert.Contains("public string Text { get; }", code);
+ Assert.Contains("readBuffer.ReadString(\"text\", (int) ((len * 8)), System.Text.Encoding.UTF8)", code);
+ Assert.Contains("writeBuffer.WriteString(\"text\", (int) ((Len * 8)), \"UTF8\", Text)", code);
+ Assert.Contains("lengthInBits += ((Len * 8));", code);
+ }
+
+ [Fact]
+ public void The_real_modbus_mspec_generates_without_throwing()
+ {
+ // Fail loud rather than passing vacuously outside the repo layout.
+ var repoRoot = RepoPaths.FindRepoRoot();
+ Assert.NotNull(repoRoot);
+ var mspec = Path.Combine(repoRoot!,
+ "protocols", "modbus", "src", "main", "resources",
+ "protocols", "modbus", "modbus.mspec");
+
+ var model = MspecModelBuilder.BuildFile(mspec);
+ var files = new CSharpGenerator(
+ model, "modbus", "org.apache.plc4net.drivers.modbus.readwrite").Generate();
+
+ Assert.True(model.Types.Count > 40);
+ Assert.Contains("model/ModbusADU.cs", files.Keys);
+ Assert.Contains("model/ModbusPDU.cs", files.Keys);
+ Assert.All(files.Values, src => Assert.Contains("DO NOT EDIT", src));
+ }
+
+ // ── dataIo struct cases (KnxDatapoint / KnxProperty) ────
+
+ [Fact]
+ public void A_dataIo_struct_case_bundles_its_fields_into_a_PlcStruct()
+ {
+ var model = MspecModelBuilder.Build(@"
+[dataIo Composite(vstring kind)
+ [typeSwitch kind
+ ['SWITCH' Struct
+ [reserved uint 6 '0x00']
+ [simple bit control]
+ [simple bit onOff ]
+ ]
+ ]
+]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/Composite.cs"];
+
+ Assert.DoesNotContain("NotImplementedException", code);
+ Assert.Contains("var _map = new System.Collections.Generic.Dictionary<string, IPlcValue>();", code);
+ Assert.Contains("_map[\"control\"] = new PlcBOOL((bool) control);", code);
+ Assert.Contains("_map[\"onOff\"] = new PlcBOOL((bool) onOff);", code);
+ Assert.Contains("return new PlcStruct(_map);", code);
+ // serialize reads each field back off the struct
+ Assert.Contains("_value.GetValue(\"control\")!.GetBool()", code);
+ }
+
+ [Fact]
+ public void A_dataIo_struct_case_with_a_byte_array_uses_PlcRawByteArray()
+ {
+ var model = MspecModelBuilder.Build(@"
+[dataIo Composite(vstring kind)
+ [typeSwitch kind
+ ['POLL' Struct
+ [array byte groupAddress count '2']
+ [simple bit disable ]
+ [reserved uint 3 '0x00' ]
+ [simple uint 4 pollingSoftNr ]
+ ]
+ ]
+]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/Composite.cs"];
+
+ Assert.DoesNotContain("NotImplementedException", code);
+ Assert.Contains("readBuffer.ReadByteArray(\"groupAddress\", (int) (2) * 8)", code);
+ Assert.Contains("_map[\"groupAddress\"] = new PlcRawByteArray(groupAddress);", code);
+ Assert.Contains("writeBuffer.WriteByteArray(\"groupAddress\", _value.GetValue(\"groupAddress\")!.GetRaw())", code);
+ Assert.Contains("lengthInBits += ((2) * 8);", code);
+ }
+
+ [Fact]
+ public void A_dataIo_case_with_no_discriminator_is_the_else_branch()
+ {
+ // KnxProperty ends with a `[* … List]` catch-all; emitting it as
+ // `else if (true)` left an unreachable fallback return (CS0162).
+ var model = MspecModelBuilder.Build(@"
+[dataIo Prop(KnxT kind, uint 8 len)
+ [typeSwitch kind
+ ['A' USINT
+ [simple uint 8 value]
+ ]
+ [ List
+ [array byte value count 'len']
+ ]
+ ]
+]
+[enum uint 8 KnxT ['1' A]]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/Prop.cs"];
+
+ Assert.Contains("else\n", code.Replace("\r\n", "\n"));
+ Assert.DoesNotContain("else if (true)", code);
+ // the fallback return is only for a chain that can fall through
+ var parseBody = code.Split("StaticSerialize")[0];
+ Assert.DoesNotContain("return new PlcNULL();", parseBody);
+ }
+
+ [Fact]
+ public void An_external_enum_is_left_to_the_SPI()
+ {
+ var model = MspecModelBuilder.Build(@"
+[enum PlcValueType external='true']
+[enum uint 8 E(PlcValueType t) ['1' A ['BOOL']]]
+");
+ var files = new CSharpGenerator(model, "demo", "demo.readwrite").Generate();
+
+ Assert.DoesNotContain("model/PlcValueType.cs", files.Keys);
+ // and no accessor that would return the type we cannot see
+ Assert.DoesNotContain("GetT(this E value)", files["model/E.cs"]);
+ }
+
+ [Fact]
+ public void A_hyphenated_enum_id_round_trips_as_a_string_literal()
+ {
+ // 'DPST-1-1' lexes as `DPST - 1 - 1`; a string-typed parameter can
+ // only mean the literal text.
+ var model = MspecModelBuilder.Build(@"
+[enum uint 16 DptId(vstring id, string 8 code) ['1' FIRST ['DPST-1-1', '409']]]
+");
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/DptId.cs"];
+
+ Assert.Contains("\"DPST-1-1\"", code);
+ Assert.DoesNotContain("DPST - 1", code);
+ // a bare number for a string parameter is the literal too
+ Assert.Contains("\"409\"", code);
+ }
+
+ [Fact]
+ public void The_real_knx_mspec_fails_loudly_for_an_unsupported_dataIo_shape()
+ {
+ // Fail loud rather than passing vacuously outside the repo layout.
+ var repoRoot = RepoPaths.FindRepoRoot();
+ Assert.NotNull(repoRoot);
+ var knxDir = Path.Combine(repoRoot!,
+ "protocols", "knxnetip", "src", "main");
+ var model = MspecModelBuilder.BuildFiles(
+ Directory.GetFiles(Path.Combine(knxDir, "resources", "protocols", "knxnetip"), "*.mspec")
+ .Concat(Directory.GetFiles(Path.Combine(knxDir, "generated", "protocols", "knxnetip"), "*.mspec"))
+ .ToArray());
+ var ex = Assert.Throws<System.NotSupportedException>(() => new CSharpGenerator(
+ model, "knxnetip", "org.apache.plc4net.drivers.knxnetip.readwrite").Generate());
+
+ Assert.Contains("KnxDatapoint", ex.Message);
+ Assert.Contains("TIME", ex.Message);
+ }
+
+ [Fact]
+ public void The_real_s7_mspec_fails_loudly_when_a_static_helper_is_missing()
+ {
+ // s7.mspec's TransportSize has COUNTER and DATE_AND_TIME both at
+ // 0x1C. Both enum members remain declared, while the generated
+ // accessor keeps only the first arm because C# rejects duplicate
+ // switch patterns.
+ var repoRoot = RepoPaths.FindRepoRoot();
+ Assert.NotNull(repoRoot);
+ var model = MspecModelBuilder.BuildFiles(Path.Combine(repoRoot!,
+ "protocols", "s7", "src", "main", "resources", "protocols", "s7", "s7.mspec"));
+ var ex = Assert.Throws<System.NotSupportedException>(() => new CSharpGenerator(
+ model, "s7", "org.apache.plc4net.drivers.s7.readwrite").Generate());
+
+ Assert.Contains("STATIC_CALL", ex.Message);
+ Assert.Contains("EventItemLength", ex.Message);
+ }
+
+ [Fact]
+ public void A_little_endian_byte_order_is_rejected_not_generated_wrong()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type LePacket byteOrder='""LITTLE_ENDIAN""'
+ [simple uint 16 a]
+]
+");
+ var ex = Assert.Throws<System.NotSupportedException>(
+ () => new CSharpGenerator(model, "le", "le.readwrite").Generate());
+ Assert.Contains("LePacket", ex.Message);
+ }
+
+ [Fact]
+ public void A_terminated_array_is_rejected_instead_of_being_emitted_as_a_count_array()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type Packet
+ [array byte payload terminated 'false']
+]
+");
+
+ var ex = Assert.Throws<System.NotSupportedException>(
+ () => new CSharpGenerator(model, "demo", "demo.readwrite").Generate());
+
+ Assert.Contains("terminated array", ex.Message);
+ Assert.Contains("payload", ex.Message);
+ }
+
+ [Fact]
+ public void A_byte_array_with_a_byte_length_uses_the_buffer_bulk_read()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type Packet
+ [simple uint 8 length]
+ [array byte payload length 'length']
+]
+");
+
+ var code = new CSharpGenerator(model, "demo", "demo.readwrite")
+ .Generate()["model/Packet.cs"];
+
+ Assert.Contains("readBuffer.ReadByteArray(\"payload\", (int) (length) * 8)", code);
+ Assert.DoesNotContain("new byte[]()", code);
+ Assert.DoesNotContain("payload.Add", code);
+ }
+
+ [Fact]
+ public void An_unknown_mspec_field_is_rejected_before_source_is_emitted()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type Packet
+ [peek uint 8 marker]
+]
+");
+
+ var ex = Assert.Throws<System.NotSupportedException>(
+ () => new CSharpGenerator(model, "demo", "demo.readwrite").Generate());
+
+ Assert.Contains("unsupported mspec field", ex.Message);
+ Assert.Contains("peek", ex.Message);
+ }
+
+ [Fact]
+ public void Modbus_crc_and_lrc_helpers_are_generated_as_real_implementations()
+ {
+ var repoRoot = RepoPaths.FindRepoRoot();
+ Assert.NotNull(repoRoot);
+ var mspec = Path.Combine(repoRoot!, "protocols", "modbus", "src", "main", "resources",
+ "protocols", "modbus", "modbus.mspec");
+
+ var files = new CSharpGenerator(MspecModelBuilder.BuildFile(mspec), "modbus",
+ "org.apache.plc4net.drivers.modbus.readwrite").Generate();
+ var helper = files["model/ModbusStaticHelper.cs"];
+
+ Assert.Contains("public static ushort RtuCrcCheck(byte address, IMessage pdu)", helper);
+ Assert.Contains("public static ushort AsciiLrcCheck(byte address, IMessage pdu)", helper);
+ Assert.DoesNotContain("NotImplementedException", helper);
+ }
+
+ [Fact]
+ public async System.Threading.Tasks.Task A_generated_modbus_model_compiles_against_the_plc4net_spi()
+ {
+ var repoRoot = RepoPaths.FindRepoRoot();
+ Assert.NotNull(repoRoot);
+ var mspec = Path.Combine(repoRoot!, "protocols", "modbus", "src", "main", "resources",
+ "protocols", "modbus", "modbus.mspec");
+ var files = new CSharpGenerator(MspecModelBuilder.BuildFile(mspec), "modbus",
+ "org.apache.plc4net.drivers.modbus.readwrite").Generate();
+ var temp = Path.Combine(Path.GetTempPath(), "plc4net-codegen-" + Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ foreach (var (relativePath, source) in files)
+ {
+ var target = Path.Combine(temp, relativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ File.WriteAllText(target, source);
+ }
+ var spiProject = Path.Combine(repoRoot!, "plc4net", "spi", "spi.csproj");
+ File.WriteAllText(Path.Combine(temp, "generated.csproj"),
+ "<Project Sdk=\"Microsoft.NET.Sdk\"><PropertyGroup><TargetFramework>net8.0</TargetFramework>"
+ + "<ImplicitUsings>disable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup><ItemGroup>"
+ + $"<ProjectReference Include=\"{SecurityElement.Escape(spiProject)}\" />"
+ + "</ItemGroup></Project>");
+
+ var start = new ProcessStartInfo("dotnet", "build generated.csproj --ignore-failed-sources --nologo")
+ {
+ WorkingDirectory = temp,
+ UseShellExecute = false,
+ };
+ using var process = Process.Start(start)!;
+ var exit = process.WaitForExitAsync();
+ if (await System.Threading.Tasks.Task.WhenAny(exit, System.Threading.Tasks.Task.Delay(60000)) != exit)
+ {
+ process.Kill(entireProcessTree: true);
+ await exit;
+ Assert.Fail("Timed out compiling the generated Modbus model.");
+ }
+ Assert.True(process.ExitCode == 0, "Compiling the generated Modbus model failed.");
+ }
+ finally
+ {
+ if (Directory.Exists(temp))
+ {
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+ }
+
+ [Fact]
+ public void An_enum_field_keyed_on_a_missing_accessor_is_rejected()
+ {
+ var model = MspecModelBuilder.Build(@"
+[enum uint 8 E (uint 8 code)
+ ['1' A ['1']]
+]
+[type T
+ [enum E f missingKey]
+]
+");
+ var ex = Assert.Throws<System.InvalidOperationException>(
+ () => new CSharpGenerator(model, "t", "t.readwrite").Generate());
+ Assert.Contains("missingKey", ex.Message);
+ }
+
+ [Fact]
+ public void Duplicate_type_names_fail_instead_of_silently_overwriting()
+ {
+ var ex = Assert.Throws<System.InvalidOperationException>(() => MspecModelBuilder.Build(@"
+[type Item
+ [simple uint 8 a]
+]
+[type Item
+ [simple uint 16 b]
+]
+"));
+ Assert.Contains("Item", ex.Message);
+ }
+
+ [Fact]
+ public void An_unparseable_expression_is_reported_in_diagnostics()
+ {
+ var model = MspecModelBuilder.Build(@"
+[type T
+ [array byte data count '10x']
+]
+");
+ Assert.Contains(model.Diagnostics, d => d.Contains("10x"));
+ }
+ }
+}
diff --git a/plc4net/test/code-gen-test/RepoPaths.cs b/plc4net/test/code-gen-test/RepoPaths.cs
new file mode 100644
index 0000000000..bc972f4c3d
--- /dev/null
+++ b/plc4net/test/code-gen-test/RepoPaths.cs
@@ -0,0 +1,53 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.IO;
+
+namespace org.apache.plc4net.test.codegen
+{
+ /// <summary>
+ /// Resolves files in the repository checkout that integration-style tests
+ /// need — the real modbus.mspec, the KNX test-suite XML. The test host's
+ /// working directory is the test output directory, several levels below
+ /// the checkout root, so a hard-coded walk-up depth silently misses the
+ /// root when the layout changes. Walking up until a directory containing
+ /// pom.xml and plc4net/ marks the root is robust to that depth.
+ /// </summary>
+ internal static class RepoPaths
+ {
+ /// <summary>
+ /// Root of the PLC4X checkout, or null when the tests run somewhere
+ /// that is not inside a checkout (e.g. a decompiled source tree).
+ /// </summary>
+ public static string? FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
+ while (dir != null)
+ {
+ if (File.Exists(Path.Combine(dir.FullName, "pom.xml")) &&
+ Directory.Exists(Path.Combine(dir.FullName, "plc4net")))
+ {
+ return dir.FullName;
+ }
+ dir = dir.Parent;
+ }
+ return null;
+ }
+ }
+}
diff --git a/plc4net/test/code-gen-test/TestsuiteRunnerTests.cs b/plc4net/test/code-gen-test/TestsuiteRunnerTests.cs
new file mode 100644
index 0000000000..487c7755a5
--- /dev/null
+++ b/plc4net/test/code-gen-test/TestsuiteRunnerTests.cs
@@ -0,0 +1,94 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.IO;
+using System.Linq;
+using org.apache.plc4net.tools.codegen;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+ /// <summary>
+ /// Verifies that the .NET ParserSerializerTestsuite runner can load and
+ /// decode the same XML test data that plc4j and plc4go consume.
+ /// </summary>
+ public class TestsuiteRunnerTests
+ {
+ [Fact]
+ public void Can_decode_hex_bytes()
+ {
+ var testCase = new ParserSerializerTestcase
+ {
+ Name = "test",
+ RawHex = "010203ff"
+ };
+
+ var bytes = testCase.GetRawBytes();
+
+ Assert.Equal(new byte[] { 0x01, 0x02, 0x03, 0xFF }, bytes);
+ }
+
+ [Fact]
+ public void Whitespace_in_hex_is_ignored()
+ {
+ var testCase = new ParserSerializerTestcase
+ {
+ Name = "test",
+ RawHex = "01 02\n03\r\n04"
+ };
+
+ var bytes = testCase.GetRawBytes();
+
+ Assert.Equal(new byte[] { 0x01, 0x02, 0x03, 0x04 }, bytes);
+ }
+
+ [Fact]
+ public void Empty_raw_produces_empty_bytes()
+ {
+ var empty = new ParserSerializerTestcase { Name = "test", RawHex = "" };
+ var nullHex = new ParserSerializerTestcase { Name = "test", RawHex = null };
+
+ Assert.Empty(empty.GetRawBytes());
+ Assert.Empty(nullHex.GetRawBytes());
+ }
+
+ [Fact]
+ public void Can_load_modbus_testsuite()
+ {
+ // Same test data consumed by plc4j and plc4go. Modbus, not KNX: the
+ // generator tool has no driver-specific test resources of its own,
+ // so this reads the one shared testsuite XML that already exists
+ // on develop independently of any plc4net driver PR.
+ // Fail loud rather than passing vacuously outside the repo layout.
+ var repoRoot = RepoPaths.FindRepoRoot();
+ Assert.NotNull(repoRoot);
+
+ var path = Path.Combine(repoRoot!,
+ "protocols", "modbus", "src", "test", "resources",
+ "protocols", "modbus", "tcp", "ParserSerializerTestsuite.xml");
+
+ var suite = ParserSerializerTestsuiteRunner.Load(path);
+
+ Assert.Equal("Modbus", suite.Name);
+ Assert.Equal("modbus", suite.ProtocolName);
+ Assert.Equal("BIG_ENDIAN", suite.ByteOrder);
+ Assert.True(suite.TestCases.Any());
+ }
+ }
+}
diff --git a/plc4net/tools/code-gen/MspecExpressionParser.cs b/plc4net/tools/code-gen/MspecExpressionParser.cs
index fafd3cc7f3..f2674fa17a 100644
--- a/plc4net/tools/code-gen/MspecExpressionParser.cs
+++ b/plc4net/tools/code-gen/MspecExpressionParser.cs
@@ -65,9 +65,8 @@ public static class MspecExpressionParser
private static Term Convert(ExpressionParser.ExpressionContext context) => context switch
{
ExpressionParser.NumberExpressionContext value => Number(value.Number().GetText()),
- ExpressionParser.HexExpressionContext value => new IntegerLiteral(
- long.Parse(value.HexExpression().GetText()[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture),
- value.HexExpression().GetText()),
+ ExpressionParser.HexExpressionContext value =>
+ new HexadecimalLiteral(value.HexExpression().GetText()),
ExpressionParser.BoolExpressionContext value =>
new BooleanLiteral(bool.Parse(value.Bool().GetText())),
ExpressionParser.NullExpressionContext => new NullLiteral(),
@@ -168,4 +167,4 @@ public sealed class MspecParseException : Exception
public MspecParseException(string message, Exception innerException) : base(message, innerException)
{
}
-}
\ No newline at end of file
+}
diff --git a/plc4net/tools/code-gen/MspecModelBuilder.cs b/plc4net/tools/code-gen/MspecModelBuilder.cs
new file mode 100644
index 0000000000..945784abec
--- /dev/null
+++ b/plc4net/tools/code-gen/MspecModelBuilder.cs
@@ -0,0 +1,571 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using Antlr4.Runtime;
+using Antlr4.Runtime.Misc;
+using org.apache.plc4net.tools.codegen.grammar;
+using org.apache.plc4net.tools.codegen.model;
+using org.apache.plc4net.tools.codegen.model.fields;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen
+{
+ /// <summary>
+ /// Turns a parsed mspec file into the type-model IR
+ /// (<see cref="Protocol"/>). Walks the ANTLR parse tree with the generated
+ /// typed context classes; expressions inside ticks are handed to
+ /// <see cref="MspecExpressionParser"/>.
+ /// </summary>
+ public sealed class MspecModelBuilder
+ {
+ private readonly Protocol _protocol = new Protocol();
+
+ /// <summary>Set by <c>BuildFiles</c> so diagnostics and errors can
+ /// name the mspec file they come from.</summary>
+ private string? _currentFile;
+
+ public static Protocol Build(string mspecContent)
+ => new MspecModelBuilder().Run(MspecReader.Read(mspecContent));
+
+ public static Protocol BuildFile(string path)
+ => BuildFiles(path);
+
+ /// <summary>
+ /// Builds one model from several mspec files - a protocol whose types
+ /// are split across files (knxnetip.mspec references
+ /// <c>KnxPropertyDataType</c> from device-info.mspec) is compiled the
+ /// way the Java plugin does it: every <c>*.mspec</c> in the directory
+ /// as one unit.
+ /// </summary>
+ public static Protocol BuildFiles(params string[] paths) => BuildFiles(true, paths);
+
+ public static Protocol BuildFiles(bool strict, params string[] paths)
+ {
+ var builder = new MspecModelBuilder();
+ foreach (var path in paths)
+ {
+ builder._currentFile = path;
+ builder.RunInto(MspecReader.ReadFile(path, strict));
+ }
+ builder.ResolveEnumReferences();
+ return builder._protocol;
+ }
+
+ private Protocol Run(MSpecParser.FileContext file)
+ {
+ RunInto(file);
+ ResolveEnumReferences();
+ return _protocol;
+ }
+
+ private void RunInto(MSpecParser.FileContext file)
+ {
+ var constants = file.contantsDefinition();
+ if (constants != null)
+ {
+ foreach (var cf in constants.constField())
+ {
+ _protocol.Constants.Add(new ConstantDeclaration
+ {
+ Name = cf.name.GetText(),
+ // A const's type token is mandatory in the grammar.
+ Type = BuildTypeReference(cf.type)!,
+ Value = ParseValueLiteral(cf.expected),
+ });
+ }
+ }
+
+ foreach (var def in file.complexTypeDefinition())
+ {
+ BuildComplexTypeDefinition(def.complexType());
+ }
+ }
+
+ // ── type definitions ─────────────────────────────────────
+
+ private void BuildComplexTypeDefinition(MSpecParser.ComplexTypeContext ctx)
+ {
+ if (ctx.ENUM() != null)
+ {
+ BuildEnum(ctx);
+ return;
+ }
+ if (ctx.DATAIO() != null)
+ {
+ BuildDataIo(ctx);
+ return;
+ }
+
+ var type = new ComplexTypeDefinition { Name = ctx.name.GetText() };
+ FillArguments(ctx.argumentList(), type.Arguments);
+ FillAttributes(ctx.attributeList(), type.Attributes);
+ foreach (var fd in ctx.fieldDefinition())
+ {
+ AddField(type, fd);
+ }
+ AddTypeDefinition(type);
+ }
+
+ private void BuildEnum(MSpecParser.ComplexTypeContext ctx)
+ {
+ var e = new EnumTypeDefinition
+ {
+ Name = ctx.name.GetText(),
+ BaseType = ctx.dataType() != null ? BuildSimpleType(ctx.dataType()) : null,
+ };
+ FillArguments(ctx.argumentList(), e.Arguments);
+ FillAttributes(ctx.attributeList(), e.Attributes);
+
+ foreach (var v in ctx.enumValueDefinition())
+ {
+ e.Values.Add(new EnumValue
+ {
+ Name = v.name.Text,
+ Value = v.valueExpression != null ? ParseExpression(v.valueExpression) : null,
+ // Every element here comes from a real expression node in
+ // the constant-value list, never from an absent optional.
+ ConstantValues = v.constantValueExpressions != null
+ ? v.constantValueExpressions.expression().Select(e => ParseExpression(e)!).ToList()
+ : (IReadOnlyList<Term>)System.Array.Empty<Term>(),
+ });
+ }
+ AddTypeDefinition(e);
+ }
+
+ private void BuildDataIo(MSpecParser.ComplexTypeContext ctx)
+ {
+ var dio = new DataIoTypeDefinition { Name = ctx.name.GetText() };
+ FillArguments(ctx.argumentList(), dio.Arguments);
+ FillAttributes(ctx.attributeList(), dio.Attributes);
+
+ var tsCtx = ctx.dataIoDefinition()?.typeSwitchField();
+ if (tsCtx != null)
+ {
+ // dataIo case sub-types are not lifted to top-level types: the
+ // names repeat (CHAR / STRING / TIME each appear twice) and a
+ // dataIo parses to an IPlcValue, not a generated class. They are
+ // kept on the DataIoTypeDefinition for the emitter.
+ dio.TypeSwitch = BuildTypeSwitch(tsCtx, dio.Name, dio.Cases);
+ }
+ AddTypeDefinition(dio);
+ }
+
+ // ── fields ───────────────────────────────────────────────
+
+ private void AddField(ComplexTypeDefinition owner, MSpecParser.FieldDefinitionContext fd)
+ {
+ var f = fd.field();
+ Field field = f switch
+ {
+ _ when f.simpleField() is { } s => new SimpleField
+ {
+ Name = s.name.GetText(),
+ Type = BuildTypeReference(s.type),
+ },
+ _ when f.constField() is { } c => new ConstField
+ {
+ Name = c.name.GetText(),
+ Type = BuildTypeReference(c.type),
+ ReferenceValue = ParseValueLiteral(c.expected),
+ },
+ _ when f.implicitField() is { } im => new ImplicitField
+ {
+ Name = im.name.GetText(),
+ Type = BuildSimpleType(im.type),
+ // The serialize expression is mandatory in the grammar.
+ SerializeExpression = ParseExpression(im.serializeExpression)!,
+ },
+ _ when f.reservedField() is { } r => new ReservedField
+ {
+ Name = "reserved",
+ Type = BuildSimpleType(r.type),
+ // The reserved value literal is mandatory in the grammar.
+ ReferenceValue = ParseExpression(r.expected)!,
+ },
+ _ when f.discriminatorField() is { } d => new DiscriminatorField
+ {
+ Name = d.name.GetText(),
+ Type = BuildTypeReference(d.type),
+ },
+ _ when f.enumField() is { } en => new EnumField
+ {
+ Name = en.name.GetText(),
+ Type = BuildTypeReference(en.type),
+ KeyAccessor = en.fieldName?.GetText(),
+ },
+ _ when f.arrayField() is { } a => new ArrayField
+ {
+ Name = a.name.GetText(),
+ Type = BuildTypeReference(a.type),
+ LoopType = a.loopType.Text.Trim('\'') switch
+ {
+ "length" => ArrayField.Loop.Length,
+ "terminated" => ArrayField.Loop.Terminated,
+ _ => ArrayField.Loop.Count,
+ },
+ // The loop (count / length / terminator) expression is
+ // mandatory in the grammar.
+ LoopExpression = ParseExpression(a.loopExpression)!,
+ },
+ _ when f.checksumField() is { } ck => new ChecksumField
+ {
+ Name = ck.name.GetText(),
+ Type = BuildSimpleType(ck.type),
+ ChecksumExpression = ParseExpression(ck.checksumExpression)!,
+ },
+ _ when f.virtualField() is { } vf => new VirtualField
+ {
+ Name = vf.name.GetText(),
+ Type = BuildTypeReference(vf.type),
+ ValueExpression = ParseExpression(vf.valueExpression)!,
+ },
+ _ when f.optionalField() is { } of => new OptionalField
+ {
+ Name = of.name.GetText(),
+ Type = BuildTypeReference(of.type),
+ Condition = of.condition != null ? ParseExpression(of.condition) : null,
+ },
+ // All three manual expressions are mandatory in the grammar.
+ _ when f.manualField() is { } mf => new ManualField
+ {
+ Name = mf.name.GetText(),
+ Type = BuildTypeReference(mf.type),
+ ParseExpression = ParseExpression(mf.parseExpression)!,
+ SerializeExpression = ParseExpression(mf.serializeExpression)!,
+ LengthExpression = ParseExpression(mf.lengthExpression)!,
+ },
+ // Both padding expressions are mandatory in the grammar.
+ _ when f.paddingField() is { } pf => new PaddingField
+ {
+ Name = pf.name.GetText(),
+ Type = BuildSimpleType(pf.type),
+ PaddingValue = ParseExpression(pf.paddingValue)!,
+ TimesPadding = ParseExpression(pf.timesPadding)!,
+ },
+ _ when f.typeSwitchField() is { } ts => BuildTypeSwitch(ts, owner.Name, _protocol.Types),
+ // The field context always has at least the keyword token as
+ // its first child.
+ _ => new UnsupportedField
+ {
+ Name = "unsupported",
+ MspecKeyword = f.GetChild(0)?.GetText() ?? f.GetType().Name,
+ RawText = SourceText(f),
+ },
+ };
+
+ FillAttributes(fd.attributeList(), field.Attributes);
+ ApplyStringEncoding(field);
+ owner.Fields.Add(field);
+ }
+
+ private TypeSwitchField BuildTypeSwitch(
+ MSpecParser.TypeSwitchFieldContext ts, string parentName,
+ List<ComplexTypeDefinition> childSink)
+ {
+ var field = new TypeSwitchField
+ {
+ Name = "typeSwitch",
+ Discriminators = ts.multipleVariableLiterals().variableLiteral()
+ .Select(v =>
+ {
+ try { return MspecExpressionParser.Parse(SourceText(v)); }
+ catch (MspecParseException) { return new VariableLiteral(SourceText(v)); }
+ })
+ .ToList(),
+ };
+
+ foreach (var cs in ts.caseStatement())
+ {
+ var child = new ComplexTypeDefinition
+ {
+ Name = cs.name.Text,
+ ParentName = parentName,
+ DiscriminatorValues = cs.discriminatorValues != null
+ ? cs.discriminatorValues.expression().Select(ParseExpression).ToList()
+ : (IReadOnlyList<Term?>)System.Array.Empty<Term>(),
+ };
+ FillArguments(cs.argumentList(), child.Arguments);
+ foreach (var fd in cs.fieldDefinition())
+ {
+ AddField(child, fd);
+ }
+
+ field.CaseNames.Add(child.Name);
+ if (ReferenceEquals(childSink, _protocol.Types))
+ {
+ // A lifted child becomes its own output file; a name
+ // shared with any other type would silently collide.
+ AddTypeDefinition(child);
+ }
+ else
+ {
+ // dataIo cases stay local and may repeat names.
+ childSink.Add(child);
+ }
+ }
+
+ return field;
+ }
+
+ // ── type references ──────────────────────────────────────
+
+ private TypeReference? BuildTypeReference(MSpecParser.TypeReferenceContext ctx)
+ {
+ if (ctx == null)
+ {
+ return null;
+ }
+ if (ctx.simpleTypeReference != null)
+ {
+ return BuildSimpleType(ctx.simpleTypeReference);
+ }
+
+ return new ComplexTypeReference
+ {
+ Name = ctx.complexTypeReference.Text,
+ // Every element here is a real parser-argument expression,
+ // never from an absent optional.
+ Arguments = ctx.@params != null
+ ? ctx.@params.expression().Select(e => ParseExpression(e)!).ToList()
+ : (IReadOnlyList<Term>)System.Array.Empty<Term>(),
+ };
+ }
+
+ /// <summary>Registers a type / enum / dataIo, rejecting a name that
+ /// already exists anywhere in the model: output files are keyed by
+ /// type name, so a second definition would silently win the file.</summary>
+ private void AddTypeDefinition(TypeDefinition definition)
+ {
+ var clash = _protocol.Types.FirstOrDefault(t => t.Name == definition.Name)
+ ?? (TypeDefinition?)_protocol.Enums.FirstOrDefault(e => e.Name == definition.Name)
+ ?? _protocol.DataIos.FirstOrDefault(d => d.Name == definition.Name);
+ if (clash != null)
+ {
+ throw new System.InvalidOperationException(
+ $"duplicate type name '{definition.Name}' in {_currentFile ?? "<unknown file>"}; "
+ + "output is keyed by type name, so the second definition would overwrite the first");
+ }
+ switch (definition)
+ {
+ case EnumTypeDefinition e:
+ _protocol.Enums.Add(e);
+ break;
+ case DataIoTypeDefinition d:
+ _protocol.DataIos.Add(d);
+ break;
+ default:
+ _protocol.Types.Add((ComplexTypeDefinition)definition);
+ break;
+ }
+ }
+
+ private SimpleTypeReference BuildSimpleType(MSpecParser.DataTypeContext dt)
+ {
+ var baseText = dt.@base.Text;
+ var size = dt.size != null
+ ? int.Parse(dt.size.Text, System.Globalization.CultureInfo.InvariantCulture)
+ : 0;
+
+ var (baseType, bits) = baseText switch
+ {
+ "bit" => (SimpleTypeReference.Base.Bit, 1),
+ "byte" => (SimpleTypeReference.Base.Byte, 8),
+ "uint" => (SimpleTypeReference.Base.UInt, size),
+ "int" => (SimpleTypeReference.Base.Int, size),
+ "float" => (SimpleTypeReference.Base.Float, size),
+ "ufloat" => (SimpleTypeReference.Base.UFloat, size),
+ "string" => (SimpleTypeReference.Base.String, size),
+ "vstring" => (SimpleTypeReference.Base.VString, size),
+ "time" => (SimpleTypeReference.Base.Time, 32),
+ "date" => (SimpleTypeReference.Base.Date, 32),
+ "dateTime" => (SimpleTypeReference.Base.DateTime, 64),
+ _ => throw new System.NotSupportedException(
+ $"base type '{baseText}' is not modelled (vint/vuint have no C# mapping yet)"),
+ };
+
+ return new SimpleTypeReference
+ {
+ BaseType = baseType,
+ SizeInBits = bits,
+ // `vstring 'expr'` - the length in bits is a run-time expression
+ // (ADS: `vstring 'stringLength * 8' value`).
+ LengthExpression = baseType == SimpleTypeReference.Base.VString && dt.length != null
+ ? ParseExpression(dt.length)
+ : null,
+ };
+ }
+
+ /// <summary>Promotes a <see cref="ComplexTypeReference"/> to an
+ /// <see cref="EnumTypeReference"/> once every enum name is known.</summary>
+ private void ResolveEnumReferences()
+ {
+ var enums = _protocol.Enums.ToDictionary(e => e.Name);
+
+ EnumTypeReference? Promote(ComplexTypeReference c) =>
+ enums.TryGetValue(c.Name, out var e)
+ ? new EnumTypeReference
+ {
+ Name = e.Name,
+ BaseType = e.BaseType
+ ?? new SimpleTypeReference { BaseType = SimpleTypeReference.Base.UInt, SizeInBits = 8 },
+ }
+ : null;
+
+ void Fix(System.Func<TypeReference> get, System.Action<TypeReference> set)
+ {
+ if (get() is ComplexTypeReference c && Promote(c) is { } er)
+ {
+ set(er);
+ }
+ }
+
+ foreach (var t in _protocol.Types)
+ {
+ foreach (var a in t.Arguments)
+ {
+ Fix(() => a.Type, v => a.Type = v);
+ }
+ // Field.Type is nullable (null for typeSwitch), unlike
+ // Argument.Type above, so it is handled separately rather
+ // than widening Fix's own delegate types for one caller.
+ foreach (var f in t.Fields)
+ {
+ if (f.Type is ComplexTypeReference fc && Promote(fc) is { } fer)
+ {
+ f.Type = fer;
+ }
+ }
+ }
+ foreach (var en in _protocol.Enums)
+ {
+ foreach (var a in en.Arguments)
+ {
+ Fix(() => a.Type, v => a.Type = v);
+ }
+ }
+ foreach (var dio in _protocol.DataIos)
+ {
+ foreach (var a in dio.Arguments)
+ {
+ Fix(() => a.Type, v => a.Type = v);
+ }
+ }
+ foreach (var c in _protocol.Constants)
+ {
+ Fix(() => c.Type, v => c.Type = v);
+ }
+ }
+
+ // ── expressions, attributes, arguments ───────────────────
+
+ private Term? ParseExpression(MSpecParser.ExpressionContext exprCtx)
+ {
+ if (exprCtx == null)
+ {
+ return null;
+ }
+
+ var raw = SourceText(exprCtx).Trim();
+ if (raw == "*")
+ {
+ return new VariableLiteral("*");
+ }
+
+ var text = raw.Length >= 2 && raw[0] == '\'' && raw[^1] == '\''
+ ? raw.Substring(1, raw.Length - 2)
+ : raw;
+
+ try
+ {
+ return MspecExpressionParser.Parse(text);
+ }
+ catch (MspecParseException)
+ {
+ // Expression positions (count/length/serialize/...) never
+ // legitimately hold prose, so a failure here is a broken
+ // mspec; record it rather than silently emitting a string
+ // literal that breaks generated code far from the cause.
+ _protocol.Diagnostics.Add(
+ $"{_currentFile ?? "<model>"}: expression '{text}' did not parse; kept as a string literal");
+ return new StringLiteral(text);
+ }
+ }
+
+ private Term ParseValueLiteral(MSpecParser.ValueLiteralContext ctx)
+ {
+ var text = SourceText(ctx).Trim();
+ try
+ {
+ return MspecExpressionParser.Parse(text.Trim('\''));
+ }
+ catch (MspecParseException)
+ {
+ // Deliberate: value-table cells legitimately hold quoted
+ // strings that are not expressions (KNX's 'DPST-1-1').
+ return new StringLiteral(text);
+ }
+ }
+
+ private void FillArguments(MSpecParser.ArgumentListContext list, List<Argument> target)
+ {
+ if (list == null)
+ {
+ return;
+ }
+ foreach (var a in list.argument())
+ {
+ target.Add(new Argument
+ {
+ Name = a.name.GetText(),
+ // A parser argument's type token is mandatory in the grammar.
+ Type = BuildTypeReference(a.type)!,
+ });
+ }
+ }
+
+ private void FillAttributes(
+ MSpecParser.AttributeListContext list, Dictionary<string, Term> target)
+ {
+ if (list == null)
+ {
+ return;
+ }
+ foreach (var attr in list.attribute())
+ {
+ // An attribute's value is mandatory in the grammar (name=value).
+ target[attr.name.Text] = ParseExpression(attr.value)!;
+ }
+ }
+
+ private static void ApplyStringEncoding(Field field)
+ {
+ if (field.Type is SimpleTypeReference s
+ && field.Attributes.TryGetValue("stringEncoding", out var enc)
+ && enc is StringLiteral sl)
+ {
+ s.Encoding = sl.Value;
+ }
+ }
+
+ private static string SourceText(ParserRuleContext ctx) =>
+ ctx.Start.InputStream.GetText(Interval.Of(ctx.Start.StartIndex, ctx.Stop.StopIndex));
+ }
+}
diff --git a/plc4net/tools/code-gen/MspecReader.cs b/plc4net/tools/code-gen/MspecReader.cs
new file mode 100644
index 0000000000..344f6a7aae
--- /dev/null
+++ b/plc4net/tools/code-gen/MspecReader.cs
@@ -0,0 +1,125 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.IO;
+using System.Linq;
+using Antlr4.Runtime;
+using org.apache.plc4net.tools.codegen.grammar;
+
+namespace org.apache.plc4net.tools.codegen
+{
+ /// <summary>
+ /// Parses an mspec protocol description file into an ANTLR parse tree.
+ /// This is the first stage of the pure-.NET mspec toolchain: the grammar
+ /// layer that turns text into a parse tree. The type model layer (the
+ /// next stage) walks the tree with a listener.
+ /// </summary>
+ public static class MspecReader
+ {
+ /// <summary>
+ /// Parses the content of an mspec file and returns the parse tree root.
+ /// </summary>
+ /// <summary>
+ /// The empty-string literal <c>''</c> - which the shared expression
+ /// grammar's <c>TICK innerExpression TICK</c> rule cannot match (the
+ /// inner is not optional) - rewritten to a sentinel the model builder
+ /// maps back to <c>""</c>. It appears only in knx-master-data.mspec's
+ /// <c>KnxDatapointType</c> table, for the IEC-type rows with no DPST id.
+ /// </summary>
+ public const string EmptyStringSentinel = "__plc4net_empty_string__";
+
+ public static MSpecParser.FileContext Read(string mspecContent, bool strict = true)
+ {
+ mspecContent = mspecContent.Replace("''", "'" + EmptyStringSentinel + "'");
+ var inputStream = new AntlrInputStream(mspecContent);
+ var lexer = new MSpecLexer(inputStream);
+ var lexerErrors = new LexerErrorListener();
+ lexer.RemoveErrorListeners();
+ lexer.AddErrorListener(lexerErrors);
+ var tokenStream = new CommonTokenStream(lexer);
+ var parser = new MSpecParser(tokenStream);
+
+ // Collect errors rather than printing to stderr.
+ var errorListener = new CollectingErrorListener();
+ parser.RemoveErrorListeners();
+ parser.AddErrorListener(errorListener);
+
+ var tree = parser.file();
+
+ var errors = lexerErrors.Errors.Concat(errorListener.Errors).ToArray();
+ if (errors.Length != 0 && strict)
+ {
+ throw new MspecParseException(
+ $"Failed to parse mspec: {string.Join("; ", errors)}");
+ }
+
+ // Non-strict: ANTLR's error recovery has already skipped the bad
+ // tokens and produced a best-effort tree. The Java MessageFormatParser
+ // behaves the same on knx-master-data.mspec (it logs the same
+ // "mismatched input" lines to stderr and carries on).
+ LastErrors = errors;
+ return tree;
+ }
+
+ /// <summary>Syntax errors from the most recent non-strict
+ /// <see cref="Read(string, bool)"/>, for a caller that wants to report them.</summary>
+ public static System.Collections.Generic.IReadOnlyList<string> LastErrors { get; private set; }
+ = System.Array.Empty<string>();
+
+ /// <summary>
+ /// Parses an mspec file at the given path.
+ /// </summary>
+ public static MSpecParser.FileContext ReadFile(string filePath, bool strict = true)
+ {
+ return Read(File.ReadAllText(filePath), strict);
+ }
+ }
+
+ /// <summary>
+ /// Collects syntax errors rather than printing them.
+ /// </summary>
+ internal class CollectingErrorListener : BaseErrorListener
+ {
+ private readonly System.Collections.Generic.List<string> _errors
+ = new System.Collections.Generic.List<string>();
+
+ public System.Collections.Generic.IReadOnlyList<string> Errors => _errors;
+
+ public bool HasErrors => _errors.Count > 0;
+
+ public override void SyntaxError(TextWriter output, IRecognizer recognizer,
+ IToken offendingSymbol, int line, int charPositionInLine,
+ string msg, RecognitionException e)
+ {
+ _errors.Add($"line {line}:{charPositionInLine} {msg}");
+ }
+ }
+
+ internal sealed class LexerErrorListener : IAntlrErrorListener<int>
+ {
+ public System.Collections.Generic.List<string> Errors { get; } = new System.Collections.Generic.List<string>();
+
+ public void SyntaxError(TextWriter output, IRecognizer recognizer, int offendingSymbol,
+ int line, int charPositionInLine, string msg, RecognitionException e)
+ {
+ Errors.Add($"line {line}:{charPositionInLine} {msg}");
+ }
+ }
+
+}
diff --git a/plc4net/tools/code-gen/ParserSerializerTestsuiteRunner.cs b/plc4net/tools/code-gen/ParserSerializerTestsuiteRunner.cs
new file mode 100644
index 0000000000..07fa84ee77
--- /dev/null
+++ b/plc4net/tools/code-gen/ParserSerializerTestsuiteRunner.cs
@@ -0,0 +1,148 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+
+namespace org.apache.plc4net.tools.codegen
+{
+ /// <summary>
+ /// .NET executor for PLC4X ParserSerializerTestsuite XML files.
+ /// Mirrors the Java <c>ParserSerializerTestsuiteRunner</c> and Go
+ /// <c>ParserSerializerTestRunner</c>: each reads the same XML test
+ /// data and runs it against their own language's generated code.
+ /// </summary>
+ /// <remarks>
+ /// The XML schema lives at
+ /// https://plc4x.apache.org/schemas/parser-serializer-testsuite.xsd
+ /// and is shared across all languages. This runner loads the XML,
+ /// decodes the hex test vectors, and exposes them as a typed model
+ /// so that a protocol-specific test harness can feed them into the
+ /// generated parse/serialize code.
+ /// </remarks>
+ public class ParserSerializerTestsuiteRunner
+ {
+ /// <summary>
+ /// Loads a testsuite XML file.
+ /// </summary>
+ public static ParserSerializerTestsuite Load(string filePath)
+ {
+ var doc = XDocument.Load(filePath);
+ var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None;
+ var testNamespace = ns.NamespaceName.Contains("parser-serializer")
+ ? ns
+ : XNamespace.Get("https://plc4x.apache.org/schemas/parser-serializer-testsuite.xsd");
+
+ var root = doc.Root;
+ if (root == null)
+ {
+ throw new InvalidDataException("Empty testsuite file.");
+ }
+
+ var suite = new ParserSerializerTestsuite
+ {
+ Name = root.Element("name")?.Value,
+ ProtocolName = root.Element("protocol-name")?.Value,
+ OutputFlavor = root.Element("output-flavor")?.Value,
+ ByteOrder = root.Attribute("byteOrder")?.Value ?? "BIG_ENDIAN",
+ };
+
+ foreach (var caseEl in root.Elements("testcase"))
+ {
+ var testCase = new ParserSerializerTestcase
+ {
+ Name = caseEl.Element("name")?.Value ?? "unnamed",
+ RawHex = caseEl.Element("raw")?.Value,
+ RootType = caseEl.Element("root-type")?.Value,
+ // The expected parsed tree. Leaf elements carry dataType /
+ // bitLength attributes and the value as text; container
+ // elements name the (possibly discriminated) type.
+ ExpectedXml = caseEl.Element("xml")?.Elements().FirstOrDefault(),
+ };
+
+ // Collect expected values from the <parser-arguments> element.
+ var args = caseEl.Element("parser-arguments");
+ if (args != null)
+ {
+ foreach (var arg in args.Elements())
+ {
+ testCase.ParserArguments[arg.Name.LocalName] = arg.Value;
+ }
+ }
+
+ suite.TestCases.Add(testCase);
+ }
+
+ return suite;
+ }
+ }
+
+ /// <summary>
+ /// The typed content of a ParserSerializerTestsuite.xml.
+ /// </summary>
+ public class ParserSerializerTestsuite
+ {
+ public string? Name { get; set; }
+ public string? ProtocolName { get; set; }
+ public string? OutputFlavor { get; set; }
+ public required string ByteOrder { get; set; }
+
+ public List<ParserSerializerTestcase> TestCases { get; set; }
+ = new List<ParserSerializerTestcase>();
+ }
+
+ /// <summary>
+ /// A single test case: raw hex bytes and the expected parsed field values.
+ /// </summary>
+ public class ParserSerializerTestcase
+ {
+ public required string Name { get; set; }
+ public string? RawHex { get; set; }
+ public string? RootType { get; set; }
+
+ /// <summary>The <c><xml></c> body's single child - the expected
+ /// parsed tree.</summary>
+ public XElement? ExpectedXml { get; set; }
+
+ /// <summary>Parser argument name → value.</summary>
+ public Dictionary<string, string> ParserArguments { get; set; }
+ = new Dictionary<string, string>();
+
+ /// <summary>Decodes <see cref="RawHex"/> into bytes.</summary>
+ public byte[] GetRawBytes()
+ {
+ if (string.IsNullOrEmpty(RawHex))
+ {
+ return System.Array.Empty<byte>();
+ }
+ var hex = RawHex.Replace(" ", "").Replace("\n", "").Replace("\r", "");
+ if (hex.Length % 2 != 0)
+ throw new System.FormatException(
+ $"Hex string has odd length ({hex.Length}); must be even. Raw: '{RawHex}'");
+ var bytes = new byte[hex.Length / 2];
+ for (var i = 0; i < bytes.Length; i++)
+ {
+ bytes[i] = System.Convert.ToByte(hex.Substring(i * 2, 2), 16);
+ }
+ return bytes;
+ }
+ }
+}
diff --git a/plc4net/tools/code-gen/Program.cs b/plc4net/tools/code-gen/Program.cs
new file mode 100644
index 0000000000..80f9622986
--- /dev/null
+++ b/plc4net/tools/code-gen/Program.cs
@@ -0,0 +1,139 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using org.apache.plc4net.tools.codegen;
+using org.apache.plc4net.tools.codegen.output;
+
+// The pure-.NET mspec -> C# generator. Replaces the `update-generated-code`
+// Maven profile: no JDK, no freemarker.
+//
+// dotnet run --project plc4net/tools/code-gen -- \
+// <protocol> <mspec-source[;mspec-source...]> <output-dir> [namespace]
+//
+// <output-dir> receives model/*.cs. It is wiped of *.cs under model/ first so
+// a removed mspec type does not leave a stale file behind.
+
+if (args.Length < 3)
+{
+ Console.Error.WriteLine(
+ "usage: plc4net-code-gen <protocol> <mspec-source[;mspec-source...]> <output-dir> [namespace]");
+ return 2;
+}
+
+var protocol = args[0];
+var mspecPath = args[1];
+var outDir = args[2];
+var ns = args.Length > 3
+ ? args[3]
+ : $"org.apache.plc4net.drivers.{protocol.Replace("-", "")}.readwrite";
+
+// A protocol's types can be split across files and directories (knxnetip
+// spans knxnetip.mspec, device-info.mspec and the generated
+// knx-master-data.mspec). The source argument is one or more ';'-separated
+// paths, each a .mspec file or a directory of them; every *.mspec found is
+// compiled as one unit, the way the Java plugin does.
+var mspecFiles = new List<string>();
+foreach (var part in mspecPath.Split(';', StringSplitOptions.RemoveEmptyEntries))
+{
+ if (Directory.Exists(part))
+ {
+ mspecFiles.AddRange(Directory.GetFiles(part, "*.mspec"));
+ }
+ else if (File.Exists(part))
+ {
+ // An explicit file means exactly that file - the siblings are
+ // independent protocols that merely share a directory
+ // (ads.mspec / ads-discovery.mspec both define AmsNetId).
+ mspecFiles.Add(Path.GetFullPath(part));
+ }
+ else
+ {
+ Console.Error.WriteLine($"mspec source not found: {part}");
+ return 2;
+ }
+}
+
+var sorted = mspecFiles.Distinct().OrderBy(p => p, StringComparer.Ordinal).ToArray();
+if (sorted.Length == 0)
+{
+ Console.Error.WriteLine($"no .mspec files found for '{mspecPath}'");
+ return 2;
+}
+var model = MspecModelBuilder.BuildFiles(sorted);
+foreach (var diagnostic in model.Diagnostics)
+{
+ Console.Error.WriteLine($"warning: {diagnostic}");
+}
+var files = new CSharpGenerator(model, protocol, ns).Generate();
+
+// Stage every source file before replacing the current model directory. This
+// keeps a previously generated driver intact if a disk/write failure occurs.
+Directory.CreateDirectory(outDir);
+var stageRoot = Path.Combine(outDir, $".plc4net-code-gen-{Guid.NewGuid():N}");
+var stageModelDir = Path.Combine(stageRoot, "model");
+var modelDir = Path.Combine(outDir, "model");
+var backupDir = Path.Combine(outDir, $".plc4net-code-gen-backup-{Guid.NewGuid():N}");
+
+try
+{
+ foreach (var (relativePath, source) in files)
+ {
+ var target = Path.Combine(stageRoot, relativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ File.WriteAllText(target, source);
+ }
+
+ if (Directory.Exists(modelDir))
+ {
+ Directory.Move(modelDir, backupDir);
+ }
+ try
+ {
+ Directory.Move(stageModelDir, modelDir);
+ }
+ catch
+ {
+ if (Directory.Exists(backupDir) && !Directory.Exists(modelDir))
+ {
+ Directory.Move(backupDir, modelDir);
+ }
+ throw;
+ }
+
+ if (Directory.Exists(backupDir))
+ {
+ Directory.Delete(backupDir, recursive: true);
+ }
+}
+finally
+{
+ if (Directory.Exists(stageRoot))
+ {
+ Directory.Delete(stageRoot, recursive: true);
+ }
+}
+
+Console.WriteLine(
+ $"{protocol}: {model.Types.Count} types, {model.Enums.Count} enums, " +
+ $"{model.DataIos.Count} dataIo, {files.Count} files -> {outDir}");
+return 0;
diff --git a/plc4net/tools/code-gen/README.md b/plc4net/tools/code-gen/README.md
new file mode 100644
index 0000000000..05be862473
--- /dev/null
+++ b/plc4net/tools/code-gen/README.md
@@ -0,0 +1,101 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+# plc4net-code-gen
+
+The pure-.NET `.mspec` → C# generator. Parses `.mspec` with the checked-in
+ANTLR-for-C# lexer/parser, walks the tree into a type-model IR, and emits the
+model class, `StaticParse`, `Serialize` and `GetLengthInBits` per type — the
+replacement for the Java freemarker `model-template` / `io-template` (whose
+C# `io-template` was never migrated, so it only ever produced data classes).
+
+## Run
+
+```bash
+dotnet run --project plc4net/tools/code-gen -c Release -- \
+ <protocol> <mspec-source[;mspec-source...]> <output-dir> [namespace]
+```
+
+Each source can be one `.mspec` file or a directory containing `.mspec`
+files. Separate multiple sources with semicolons.
+
+Generation is staged and then replaces `<output-dir>/model`, so a failed
+write leaves the previous generated model intact. Regenerating these protocol model files is an explicit
+driver-maintenance step; the driver slices that consume them commit the
+result. This is separate from the checked-in ANTLR parser artifacts below,
+whose reproducibility is enforced by CI.
+
+## Supported subset and fail-fast behavior
+
+The generator only emits a protocol when every field has a known C# wire
+mapping. It deliberately fails before writing output for unsupported field
+keywords, terminated arrays, unsupported data-IO shapes, unsupported temporal
+primitives, non-big-endian byte order, and unimplemented `STATIC_CALL`
+targets. This prevents a successful generation from silently changing a wire
+layout or deferring a missing implementation to production.
+
+Counted and byte-length arrays are supported; byte arrays with a byte-length
+use the buffer bulk-read path. Modbus `rtuCrcCheck` and `asciiLrcCheck` are
+generated as concrete helpers. Other protocol-specific static helpers remain
+an explicit generator gap and cause a diagnostic failure until implemented.
+
+## Grammars and the checked-in parsers
+
+`src/generated/` holds the ANTLR 4.13.2 output (lexer, parser, listener and
+`.tokens` side-cars) for the two mspec grammars in
+`code-generation/protocol-base-mspec/src/main/antlr4/org/apache/plc4x/plugins/codegenerator/language/mspec/`:
+`MSpec.g4` and `expression/Expression.g4`. plc4net keeps no copy of them; they
+are used exactly as they are upstream, the same files the Java toolchain
+compiles. The output is **checked in**, not generated at build time —
+day-to-day work needs no JDK and no ANTLR tool.
+
+ANTLR pastes the text of a semantic predicate into the generated code
+unchanged, and the lexer's `EmptyLine` rule spells its predicate the way the
+Java runtime does, `{getCharPositionInLine() == 0}?`. The generated
+`MSpecLexer` is a `partial` class, so `src/MSpecLexer.Predicates.cs` supplies
+that method (it returns the C# runtime's `Column`) and the grammar needs no
+C# port; without that file the generated lexer does not compile.
+`MSpecLexerTests` pins the behaviour.
+
+### Regenerating the parsers
+
+This requires Python 3 and a Java runtime. The script downloads the pinned
+ANTLR archive; it does not require a manually installed ANTLR command.
+
+```bash
+python plc4net/tools/code-gen/generate_parsers.py
+python plc4net/tools/code-gen/generate_parsers.py --check
+```
+
+The script downloads the pinned ANTLR archive into the user cache, verifies
+its SHA-256 checksum, applies the ASF header and repository line-ending rules,
+and omits ANTLR's debug-only `.interp` files. The `--check` mode regenerates in
+a temporary directory and fails when the checked-in artifacts drift. The .NET
+workflow runs that check whenever PLC4NET or either shared grammar changes.
+
+## Layout
+
+| path | role |
+|------------------------------|--------------------------------------------------|
+| `MspecReader.cs` | text → ANTLR parse tree |
+| `MspecModelBuilder.cs` | parse tree → `model/` IR |
+| `MspecExpressionParser.cs` | a quoted mspec expression → `model/terms/` tree |
+| `model/` | the type-model IR |
+| `output/CSharpGenerator.cs` | IR → C# (class + parse / serialize / length) |
+| `output/CSharpExpressionRenderer.cs` | `Term` → a C# expression |
+| `Program.cs` | the CLI |
diff --git a/plc4net/tools/code-gen/code-gen.csproj b/plc4net/tools/code-gen/code-gen.csproj
index c72ab2768b..7aad9c679e 100644
--- a/plc4net/tools/code-gen/code-gen.csproj
+++ b/plc4net/tools/code-gen/code-gen.csproj
@@ -20,7 +20,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <OutputType>Library</OutputType>
+ <OutputType>Exe</OutputType>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<AssemblyName>plc4net-code-gen</AssemblyName>
<Title>PLC4Net: Code Generation</Title>
diff --git a/plc4net/tools/code-gen/model/Definitions.cs b/plc4net/tools/code-gen/model/Definitions.cs
new file mode 100644
index 0000000000..0ec990074f
--- /dev/null
+++ b/plc4net/tools/code-gen/model/Definitions.cs
@@ -0,0 +1,199 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.model
+{
+ /// <summary>A top-level named type in an mspec file.</summary>
+ public abstract class TypeDefinition
+ {
+ public required string Name { get; set; }
+
+ /// <summary>Attributes on the type header, e.g.
+ /// <c>byteOrder='"BIG_ENDIAN"'</c>.</summary>
+ public Dictionary<string, Term> Attributes { get; } = new Dictionary<string, Term>();
+
+ public Term? GetAttribute(string name) =>
+ Attributes.TryGetValue(name, out var v) ? v : null;
+ }
+
+ /// <summary>A parser argument in a type header: <c>(bit response)</c>.</summary>
+ public sealed class Argument
+ {
+ public required string Name { get; set; }
+ public required TypeReference Type { get; set; }
+ }
+
+ /// <summary>
+ /// A structured type - a <c>[type]</c> or a <c>[discriminatedType]</c>.
+ /// A discriminated parent is the one whose fields include a
+ /// <see cref="fields.TypeSwitchField"/>; its children are also
+ /// <see cref="ComplexTypeDefinition"/> instances, linked by
+ /// <see cref="ParentName"/> / <see cref="DiscriminatorValues"/>.
+ /// </summary>
+ public sealed class ComplexTypeDefinition : TypeDefinition
+ {
+ public List<Argument> Arguments { get; } = new List<Argument>();
+
+ public List<fields.Field> Fields { get; } = new List<fields.Field>();
+
+ // ── discriminated-type wiring ────────────────────────────
+
+ /// <summary>Set on a child type: the parent it extends.</summary>
+ public string? ParentName { get; set; }
+
+ /// <summary>Set on a child type: the case values, positionally matched
+ /// against the parent's <see cref="fields.TypeSwitchField.Discriminators"/>.
+ /// A null entry is a wildcard (the case listed fewer values).</summary>
+ public IReadOnlyList<Term?>? DiscriminatorValues { get; set; }
+
+ public bool IsDiscriminatedParent =>
+ Fields.Any(f => f is fields.TypeSwitchField);
+
+ public bool IsDiscriminatedChild => ParentName != null;
+
+ public fields.TypeSwitchField? TypeSwitch =>
+ Fields.OfType<fields.TypeSwitchField>().FirstOrDefault();
+
+ /// <summary>The fields that hold a value and therefore get a property
+ /// and a constructor parameter (everything except const / implicit /
+ /// reserved / virtual / typeSwitch and the abstract markers).</summary>
+ public IEnumerable<fields.Field> PropertyFields =>
+ Fields.Where(f => f.IsProperty);
+
+ /// <summary>
+ /// On a discriminated parent: the property fields declared before the
+ /// <c>typeSwitch</c>. A child inherits these - the parent reads and
+ /// writes them, and passes them to the child's <c>StaticParse</c> and
+ /// on to <c>base(...)</c>.
+ /// </summary>
+ public IEnumerable<fields.Field> PrefixPropertyFields =>
+ Fields.TakeWhile(f => f is not fields.TypeSwitchField).Where(f => f.IsProperty);
+
+ /// <summary>
+ /// On a discriminated parent: the property fields declared after the
+ /// <c>typeSwitch</c> - a shared trailer (S7's COTPPacket parameters /
+ /// payload). The child reads and writes these, after its own body.
+ /// </summary>
+ public IEnumerable<fields.Field> SuffixPropertyFields =>
+ Fields.SkipWhile(f => f is not fields.TypeSwitchField).Skip(1).Where(f => f.IsProperty);
+
+ /// <summary>
+ /// On a discriminated parent: the pre-<c>typeSwitch</c> fields that
+ /// carry a value a child might reference in an expression - property
+ /// fields plus discriminators, implicit and const fields. The parent
+ /// reads them and passes them to the child's <c>StaticParse</c>.
+ /// </summary>
+ public IEnumerable<fields.Field> PrefixContextFields =>
+ Fields.TakeWhile(f => f is not fields.TypeSwitchField)
+ .Where(f => f.Name != null
+ && f is not fields.ReservedField);
+
+ /// <summary>Pre-<c>typeSwitch</c> implicit fields - a child recomputes
+ /// these at the top of its serialize / length.</summary>
+ public IEnumerable<fields.ImplicitField> PrefixImplicitFields =>
+ Fields.TakeWhile(f => f is not fields.TypeSwitchField).OfType<fields.ImplicitField>();
+
+ /// <summary>All fields up to and including the <c>typeSwitch</c> - what
+ /// a discriminated parent's own <c>StaticParse</c> / <c>Serialize</c>
+ /// covers.</summary>
+ public IEnumerable<fields.Field> FieldsThroughSwitch
+ {
+ get
+ {
+ foreach (var f in Fields)
+ {
+ yield return f;
+ if (f is fields.TypeSwitchField)
+ {
+ yield break;
+ }
+ }
+ }
+ }
+
+ /// <summary>The fields after the <c>typeSwitch</c> in declaration
+ /// order (a superset of <see cref="SuffixPropertyFields"/> - includes
+ /// reserved / const trailers too).</summary>
+ public IEnumerable<fields.Field> FieldsAfterSwitch =>
+ Fields.SkipWhile(f => f is not fields.TypeSwitchField).Skip(1);
+
+ public IEnumerable<fields.ConstField> ConstFields => Fields.OfType<fields.ConstField>();
+ }
+
+ /// <summary>An <c>[enum]</c> type.</summary>
+ public sealed class EnumTypeDefinition : TypeDefinition
+ {
+ /// <summary>The wire type: <c>uint 8</c> for
+ /// <c>[enum uint 8 ModbusErrorCode ...]</c>. Null for a bare
+ /// <c>[enum Name ...]</c> (rare; treated as an int).</summary>
+ public SimpleTypeReference? BaseType { get; set; }
+
+ /// <summary>Per-constant parameters: <c>(uint 8 dataTypeSize)</c>.</summary>
+ public List<Argument> Arguments { get; } = new List<Argument>();
+
+ public List<EnumValue> Values { get; } = new List<EnumValue>();
+
+ /// <summary><c>[enum PlcValueType external='true']</c>: the type lives
+ /// outside the mspec (hand-written in the SPI). The generator emits no
+ /// file for it and skips any accessor that would return it.</summary>
+ public bool IsExternal =>
+ GetAttribute("external") is terms.BooleanLiteral { Value: true }
+ || GetAttribute("external") is terms.StringLiteral { Value: "true" };
+ }
+
+ /// <summary>One constant in an enum.</summary>
+ public sealed class EnumValue
+ {
+ public required string Name { get; set; }
+
+ /// <summary>The wire value, e.g. <c>0x02</c>. Null when the mspec
+ /// left it out (the constant then just names a slot).</summary>
+ public Term? Value { get; set; }
+
+ /// <summary>Values for the enum's <see cref="EnumTypeDefinition.Arguments"/>,
+ /// positionally: <c>['1' BOOL ['2']]</c> gives <c>dataTypeSize = 2</c>.</summary>
+ public IReadOnlyList<Term> ConstantValues { get; set; } = System.Array.Empty<Term>();
+ }
+
+ /// <summary>
+ /// A <c>[dataIo]</c> type - parses to / serializes from an
+ /// <c>IPlcValue</c> rather than a generated class. The <c>typeSwitch</c>
+ /// picks the wire layout from the parser arguments (S7's <c>DataItem</c>
+ /// keys on <c>dataProtocolId</c>); each case yields one <c>PlcValue</c>.
+ /// </summary>
+ public sealed class DataIoTypeDefinition : TypeDefinition
+ {
+ public List<Argument> Arguments { get; } = new List<Argument>();
+
+ /// <summary>Set only when the dataIo actually has a <c>typeSwitch</c>
+ /// (always true in practice, but the builder sets this after
+ /// construction, so it is not <c>required</c>).</summary>
+ public fields.TypeSwitchField? TypeSwitch { get; set; }
+
+ /// <summary>The <c>typeSwitch</c> cases, in declaration order. Unlike a
+ /// discriminated type's children these are not lifted to top-level
+ /// types - the case names repeat (<c>CHAR</c>, <c>STRING</c>, <c>TIME</c>
+ /// each appear twice) and a case is a value shape, not a class.</summary>
+ public List<ComplexTypeDefinition> Cases { get; } = new List<ComplexTypeDefinition>();
+ }
+}
diff --git a/plc4net/tools/code-gen/model/Fields.cs b/plc4net/tools/code-gen/model/Fields.cs
new file mode 100644
index 0000000000..12aee9bffb
--- /dev/null
+++ b/plc4net/tools/code-gen/model/Fields.cs
@@ -0,0 +1,184 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.model.fields
+{
+ /// <summary>Base for the mspec field keywords.</summary>
+ public abstract class Field
+ {
+ public required string Name { get; set; }
+
+ /// <summary>The field's declared type. Null for <c>typeSwitch</c>.</summary>
+ public TypeReference? Type { get; set; }
+
+ public Dictionary<string, Term> Attributes { get; } = new Dictionary<string, Term>();
+
+ /// <summary>Whether the field holds a value the message carries (a
+ /// property + constructor parameter). False for the fields that exist
+ /// only for the wire format.</summary>
+ public virtual bool IsProperty => false;
+
+ /// <summary>The mspec keyword, for diagnostics and switch templates.</summary>
+ public string Keyword => GetType().Name.Replace("Field", "").ToLowerInvariant();
+ }
+
+ /// <summary><c>[simple uint 16 startingAddress]</c></summary>
+ public sealed class SimpleField : Field
+ {
+ public override bool IsProperty => true;
+ }
+
+ /// <summary><c>[const uint 16 protocolIdentifier 0x0000]</c> - written on
+ /// serialize, checked on parse, exposed as a C# <c>const</c>.</summary>
+ public sealed class ConstField : Field
+ {
+ public required Term ReferenceValue { get; set; }
+ }
+
+ /// <summary><c>[implicit uint 16 length 'pdu.lengthInBytes + 1']</c> -
+ /// computed on serialize, read-and-kept-as-a-local on parse.</summary>
+ public sealed class ImplicitField : Field
+ {
+ public required Term SerializeExpression { get; set; }
+ }
+
+ /// <summary><c>[reserved uint 15 '0x0000']</c> - no name, no property.</summary>
+ public sealed class ReservedField : Field
+ {
+ public required Term ReferenceValue { get; set; }
+ }
+
+ /// <summary><c>[virtual uint 16 foo 'a + b']</c> - never on the wire, a
+ /// computed accessor.</summary>
+ public sealed class VirtualField : Field
+ {
+ public required Term ValueExpression { get; set; }
+ public override bool IsProperty => false;
+ }
+
+ /// <summary>
+ /// <c>[discriminator uint 7 functionFlag]</c> - read first on the parent so
+ /// the type switch can pick the child. Exposed as an (abstract on the
+ /// parent, override on each child) accessor, not a stored property.
+ /// </summary>
+ public sealed class DiscriminatorField : Field
+ {
+ }
+
+ /// <summary><c>[enum TransportSize transportSize code]</c> - an enum-typed
+ /// field whose wire value is keyed on one of the enum's attributes rather
+ /// than the enum constant itself. <see cref="Field.Type"/> is an
+ /// <see cref="EnumTypeReference"/>.</summary>
+ public sealed class EnumField : Field
+ {
+ /// <summary>The enum attribute the wire value maps through - <c>code</c>
+ /// in <c>[enum TransportSize transportSize code]</c>. Null for the bare
+ /// two-token form, where the wire value is the enum constant itself.</summary>
+ public string? KeyAccessor { get; set; }
+
+ public override bool IsProperty => true;
+ }
+
+ /// <summary><c>[array byte value count 'byteCount']</c></summary>
+ public sealed class ArrayField : Field
+ {
+ public enum Loop { Count, Length, Terminated }
+
+ public Loop LoopType { get; set; }
+
+ /// <summary>The count / byte-length / terminator expression.</summary>
+ public required Term LoopExpression { get; set; }
+
+ public override bool IsProperty => true;
+ }
+
+ /// <summary><c>[checksum uint 16 crc 'STATIC_CALL("rtuCrcCheck", ...)']</c></summary>
+ public sealed class ChecksumField : Field
+ {
+ public required Term ChecksumExpression { get; set; }
+ }
+
+ /// <summary>
+ /// <c>[typeSwitch driverType, response ['MODBUS_TCP' ModbusTcpADU [...]]]</c>
+ /// - the discriminated dispatch. The case sub-types are lifted to top-level
+ /// <see cref="ComplexTypeDefinition"/>s by the builder; the names are kept
+ /// here in declaration order for the parse/serialize switch.
+ /// </summary>
+ public sealed class TypeSwitchField : Field
+ {
+ /// <summary>The discriminator expressions to switch on, e.g.
+ /// <c>errorFlag</c>, <c>functionFlag</c>, <c>response</c>.</summary>
+ public IReadOnlyList<Term> Discriminators { get; set; } = System.Array.Empty<Term>();
+
+ /// <summary>Child type names in declaration order.</summary>
+ public List<string> CaseNames { get; } = new List<string>();
+ }
+
+ /// <summary>
+ /// <c>[optional uint 16 errorCode 'condition']</c> - present on the wire
+ /// only when the condition holds. The property is nullable.
+ /// </summary>
+ public sealed class OptionalField : Field
+ {
+ /// <summary>The presence condition, or null (always present - rare).</summary>
+ public Term? Condition { get; set; }
+
+ public override bool IsProperty => true;
+ }
+
+ /// <summary>
+ /// <c>[manual Type name 'parse' 'serialize' 'length']</c> - the codec is
+ /// three hand-supplied expressions (usually <c>STATIC_CALL</c>s that take
+ /// <c>readBuffer</c> / <c>writeBuffer</c> / <c>_value</c>).
+ /// </summary>
+ public sealed class ManualField : Field
+ {
+ public required Term ParseExpression { get; set; }
+ public required Term SerializeExpression { get; set; }
+ public required Term LengthExpression { get; set; }
+
+ public override bool IsProperty => true;
+ }
+
+ /// <summary>
+ /// <c>[padding uint 8 pad '0x00' 'timesPadding']</c> - writes the padding
+ /// value <c>timesPadding</c> times; on parse, reads and drops that many.
+ /// </summary>
+ public sealed class PaddingField : Field
+ {
+ public required Term PaddingValue { get; set; }
+ public required Term TimesPadding { get; set; }
+ }
+
+ /// <summary>
+ /// A field keyword the generator does not handle yet
+ /// (<c>abstract</c>, <c>assert</c>, <c>manualArray</c>, <c>peek</c>,
+ /// <c>unknown</c>, <c>validation</c>). Kept in the model so a type that
+ /// uses one still round-trips through the builder and the gap is visible
+ /// in the output.
+ /// </summary>
+ public sealed class UnsupportedField : Field
+ {
+ public required string MspecKeyword { get; set; }
+ public required string RawText { get; set; }
+ }
+}
diff --git a/plc4net/tools/code-gen/model/Protocol.cs b/plc4net/tools/code-gen/model/Protocol.cs
new file mode 100644
index 0000000000..4137cbed06
--- /dev/null
+++ b/plc4net/tools/code-gen/model/Protocol.cs
@@ -0,0 +1,66 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.model
+{
+ /// <summary>
+ /// The whole type model for one mspec file: the complex types (including
+ /// the children lifted out of every <c>typeSwitch</c>), the enums, the
+ /// <c>dataIo</c> types, and the top-level <c>constants</c> block.
+ /// </summary>
+ public sealed class Protocol
+ {
+ public List<ComplexTypeDefinition> Types { get; } = new List<ComplexTypeDefinition>();
+
+ public List<EnumTypeDefinition> Enums { get; } = new List<EnumTypeDefinition>();
+
+ public List<DataIoTypeDefinition> DataIos { get; } = new List<DataIoTypeDefinition>();
+
+ public List<ConstantDeclaration> Constants { get; } = new List<ConstantDeclaration>();
+
+ /// <summary>Non-fatal notes collected while building - e.g. attribute
+ /// expressions that failed to parse and were kept as string literals.
+ /// The CLI prints these to stderr so the silent fallbacks stay
+ /// visible instead of surfacing as far-away generated-code errors.</summary>
+ public List<string> Diagnostics { get; } = new List<string>();
+
+ public ComplexTypeDefinition? FindType(string? name)
+ => Types.FirstOrDefault(t => t.Name == name);
+
+ public EnumTypeDefinition? FindEnum(string? name)
+ => Enums.FirstOrDefault(e => e.Name == name);
+
+ /// <summary>The concrete children of a discriminated parent, in
+ /// declaration order.</summary>
+ public IEnumerable<ComplexTypeDefinition> ChildrenOf(string parentName)
+ => Types.Where(t => t.ParentName == parentName);
+ }
+
+ /// <summary>One entry in the top-level <c>[constants]</c> block.</summary>
+ public sealed class ConstantDeclaration
+ {
+ public required string Name { get; set; }
+ public required TypeReference Type { get; set; }
+ public required Term Value { get; set; }
+ }
+}
diff --git a/plc4net/tools/code-gen/model/TypeReference.cs b/plc4net/tools/code-gen/model/TypeReference.cs
new file mode 100644
index 0000000000..d6b1561f9b
--- /dev/null
+++ b/plc4net/tools/code-gen/model/TypeReference.cs
@@ -0,0 +1,107 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.model
+{
+ /// <summary>
+ /// The type of a field or argument as written in the mspec: either a
+ /// <c>dataType</c> (<c>uint 16</c>, <c>bit</c>, <c>string 8</c>) or a
+ /// reference to another named type, optionally with parser arguments
+ /// (<c>ModbusPDU('response')</c>).
+ /// </summary>
+ public abstract class TypeReference
+ {
+ }
+
+ /// <summary>An mspec <c>dataType</c> - a primitive on the wire.</summary>
+ public sealed class SimpleTypeReference : TypeReference
+ {
+ public enum Base
+ {
+ Bit,
+ Byte,
+ UInt,
+ Int,
+ Float,
+ UFloat,
+ String,
+ VString,
+ Time,
+ Date,
+ DateTime,
+ }
+
+ public Base BaseType { get; set; }
+
+ /// <summary>Width in bits. 1 for <c>bit</c>, 8 for <c>byte</c>, the
+ /// declared size for <c>uint N</c> etc. Zero for a <c>vstring</c> whose
+ /// length is an expression (see <see cref="LengthExpression"/>).</summary>
+ public int SizeInBits { get; set; }
+
+ /// <summary>For <c>vstring</c>: the run-time length expression.</summary>
+ public Term? LengthExpression { get; set; }
+
+ /// <summary>The <c>stringEncoding</c> attribute on the field, if any
+ /// (e.g. <c>"UTF8"</c>, <c>"UTF16BE"</c>).</summary>
+ public string? Encoding { get; set; }
+
+ public bool IsIntegerLike =>
+ BaseType is Base.Bit or Base.Byte or Base.UInt or Base.Int;
+
+ public bool IsSigned => BaseType == Base.Int;
+
+ public bool IsByteBased => BaseType == Base.Byte && SizeInBits == 8;
+
+ public override string ToString() =>
+ BaseType == Base.Bit ? "bit"
+ : BaseType == Base.Byte ? "byte"
+ : $"{BaseType.ToString().ToLowerInvariant()} {SizeInBits}";
+ }
+
+ /// <summary>A reference to another <c>[type]</c> / <c>[discriminatedType]</c>.</summary>
+ public sealed class ComplexTypeReference : TypeReference
+ {
+ public required string Name { get; set; }
+
+ /// <summary>Parser arguments passed to the referenced type, e.g. the
+ /// <c>'response'</c> in <c>ModbusPDU('response')</c>.</summary>
+ public IReadOnlyList<Term> Arguments { get; set; } = System.Array.Empty<Term>();
+
+ public override string ToString() =>
+ Arguments.Count == 0 ? Name : $"{Name}({string.Join(", ", Arguments)})";
+ }
+
+ /// <summary>
+ /// A reference resolved to an <c>[enum]</c> type. mspec does not spell
+ /// these differently from a complex reference - the builder promotes a
+ /// <see cref="ComplexTypeReference"/> to this once the enum types are known.
+ /// </summary>
+ public sealed class EnumTypeReference : TypeReference
+ {
+ public required string Name { get; set; }
+
+ /// <summary>The enum's underlying wire type (<c>uint 8</c> etc.).</summary>
+ public required SimpleTypeReference BaseType { get; set; }
+
+ public override string ToString() => Name;
+ }
+}
diff --git a/plc4net/tools/code-gen/model/terms/Term.cs b/plc4net/tools/code-gen/model/terms/Term.cs
index 5bc7eeccdc..ae83001175 100644
--- a/plc4net/tools/code-gen/model/terms/Term.cs
+++ b/plc4net/tools/code-gen/model/terms/Term.cs
@@ -17,7 +17,6 @@
// under the License.
//
-using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -33,7 +32,12 @@ public sealed class IntegerLiteral(long value, string? text = null) : Term
{
public long Value { get; } = value;
public string Text { get; } = text ?? value.ToString(CultureInfo.InvariantCulture);
- public bool IsHex => Text.StartsWith("0x", StringComparison.OrdinalIgnoreCase);
+ public override string ToString() => Text;
+}
+
+public sealed class HexadecimalLiteral(string text) : Term
+{
+ public string Text { get; } = text;
public override string ToString() => Text;
}
@@ -105,7 +109,8 @@ public sealed class IndexExpression(Term target, IReadOnlyList<Term> indexes) :
public Term Target { get; } = target;
public IReadOnlyList<Term> Indexes { get; } = indexes;
public override string ToString() =>
- Target + string.Concat(Indexes.Select(item => $"[{item}]"));
+ (Target is UnaryExpression ? $"({Target})" : Target.ToString()) +
+ string.Concat(Indexes.Select(item => $"[{item}]"));
}
public sealed class TernaryExpression(Term condition, Term whenTrue, Term whenFalse) : Term
@@ -114,4 +119,4 @@ public sealed class TernaryExpression(Term condition, Term whenTrue, Term whenFa
public Term WhenTrue { get; } = whenTrue;
public Term WhenFalse { get; } = whenFalse;
public override string ToString() => $"({Condition} ? {WhenTrue} : {WhenFalse})";
-}
\ No newline at end of file
+}
diff --git a/plc4net/tools/code-gen/output/CSharpExpressionRenderer.cs b/plc4net/tools/code-gen/output/CSharpExpressionRenderer.cs
new file mode 100644
index 0000000000..e45010d3c4
--- /dev/null
+++ b/plc4net/tools/code-gen/output/CSharpExpressionRenderer.cs
@@ -0,0 +1,211 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.output;
+
+/// <summary>Resolves MSpec names in the context of generated C# code.</summary>
+public interface IExpressionScope
+{
+ string? ResolveReference(string name);
+
+ string? ResolveCount(string name) => null;
+
+ string? ResolveMember(string name) => null;
+}
+
+/// <summary>Renders an MSpec expression tree as a C# expression.</summary>
+public sealed class CSharpExpressionRenderer(IExpressionScope? scope = null)
+{
+ private static readonly IReadOnlyDictionary<string, string> MemberAccessors =
+ new Dictionary<string, string>
+ {
+ ["lengthInBytes"] = "GetLengthInBytes()",
+ ["lengthInBits"] = "GetLengthInBits()"
+ };
+
+ public string StaticHelperClass { get; set; } = "StaticHelper";
+
+ public string Render(Term? term) => term switch
+ {
+ null => "null",
+ IntegerLiteral literal => literal.Text,
+ HexadecimalLiteral literal => literal.Text,
+ FloatLiteral literal => RenderFloat(literal),
+ BooleanLiteral literal => literal.Value ? "true" : "false",
+ NullLiteral => "null",
+ StringLiteral literal => RenderString(literal.Value),
+ UnaryExpression expression => RenderUnary(expression),
+ IndexExpression expression => RenderIndex(expression),
+ TernaryExpression expression =>
+ $"({Render(expression.Condition)} ? {Render(expression.WhenTrue)} : {Render(expression.WhenFalse)})",
+ BinaryExpression expression => RenderBinary(expression),
+ VariableLiteral variable => RenderVariable(variable),
+ _ => throw new NotSupportedException($"Cannot render term {term.GetType().Name}")
+ };
+
+ private static string RenderFloat(FloatLiteral literal)
+ {
+ string text = literal.Value.ToString("R", CultureInfo.InvariantCulture);
+ return text.IndexOfAny(['.', 'e', 'E']) < 0 ? text + ".0" : text;
+ }
+
+ private static string RenderString(string value) =>
+ $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
+
+ private string RenderUnary(UnaryExpression expression)
+ {
+ string operand = Render(expression.Operand);
+ return expression.Operator +
+ (expression.Operand is UnaryExpression ? $"({operand})" : operand);
+ }
+
+ private string RenderIndex(IndexExpression expression)
+ {
+ string target = Render(expression.Target);
+ if (expression.Target is UnaryExpression)
+ {
+ target = $"({target})";
+ }
+ return target + string.Concat(expression.Indexes.Select(index => $"[{Render(index)}]"));
+ }
+
+ private string RenderBinary(BinaryExpression expression) =>
+ expression.Operator == "^"
+ ? $"System.Math.Pow({Render(expression.Left)}, {Render(expression.Right)})"
+ : $"({Render(expression.Left)} {expression.Operator} {Render(expression.Right)})";
+
+ private string RenderVariable(VariableLiteral variable)
+ {
+ if (variable.Name == MspecReader.EmptyStringSentinel &&
+ !variable.IsCall && variable.Index.Count == 0 && variable.Child == null)
+ {
+ return "\"\"";
+ }
+
+ string head;
+ if (variable.IsCall && RenderBuiltin(variable) is { } builtin)
+ {
+ head = builtin;
+ }
+ else if (variable.IsCall)
+ {
+ head = (Resolve(variable.Name) ?? variable.Name) +
+ $"({RenderArguments(variable.Args)})";
+ }
+ else
+ {
+ head = Resolve(variable.Name) ?? variable.Name;
+ }
+
+ head += RenderIndexes(variable.Index);
+ for (VariableLiteral? child = variable.Child; child != null; child = child.Child)
+ {
+ head += "." + RenderMember(child);
+ }
+ return head;
+ }
+
+ private string RenderMember(VariableLiteral member)
+ {
+ string value;
+ if (member.IsCall)
+ {
+ value = $"{Pascal(member.Name)}({RenderArguments(member.Args)})";
+ }
+ else if (MemberAccessors.TryGetValue(member.Name, out string? accessor))
+ {
+ value = accessor;
+ }
+ else
+ {
+ value = scope?.ResolveMember(member.Name) ?? Pascal(member.Name);
+ }
+ return value + RenderIndexes(member.Index);
+ }
+
+ private string RenderIndexes(IReadOnlyList<Term> indexes) =>
+ string.Concat(indexes.Select(index => $"[{Render(index)}]"));
+
+ private string RenderArguments(IReadOnlyList<Term>? arguments) =>
+ string.Join(", ", (arguments ?? []).Select(Render));
+
+ private string? RenderBuiltin(VariableLiteral variable)
+ {
+ if (variable.Args is not { } arguments)
+ {
+ return null;
+ }
+ return variable.Name switch
+ {
+ "COUNT" when arguments.Count == 1 => RenderCount(arguments[0]),
+ "CEIL" when arguments.Count == 1 =>
+ $"(int) System.Math.Ceiling((double) ({Render(arguments[0])}))",
+ "ARRAY_SIZE_IN_BYTES" when arguments.Count == 1 =>
+ $"{StaticHelperClass}.ArraySizeInBytes({Render(arguments[0])})",
+ "STATIC_CALL" when arguments.Count >= 1 && arguments[0] is StringLiteral function =>
+ $"{StaticHelperClass}.{Pascal(function.Value)}({string.Join(", ", arguments.Skip(1).Select(Render))})",
+ "CAST" when arguments.Count == 2 && RenderCastType(arguments[1]) is { } type =>
+ $"(({type}) {Render(arguments[0])})",
+ _ => null
+ };
+ }
+
+ private string RenderCount(Term argument)
+ {
+ if (argument is VariableLiteral { Child: null, Args: null } variable &&
+ scope?.ResolveCount(variable.Name) is { } count)
+ {
+ return count;
+ }
+ return Render(argument) + ".Count";
+ }
+
+ private static string? RenderCastType(Term term) => term switch
+ {
+ StringLiteral literal => literal.Value,
+ VariableLiteral variable => RenderTypeName(variable),
+ _ => null
+ };
+
+ private static string? RenderTypeName(VariableLiteral variable)
+ {
+ var parts = new List<string>();
+ for (VariableLiteral? part = variable; part != null; part = part.Child)
+ {
+ if (part.IsCall || part.Index.Count != 0)
+ {
+ return null;
+ }
+ parts.Add(part.Name);
+ }
+ return string.Join(".", parts);
+ }
+
+ private string? Resolve(string name) => scope?.ResolveReference(name);
+
+ private static string Pascal(string name) => string.IsNullOrEmpty(name)
+ ? name
+ : char.ToUpperInvariant(name[0]) + name[1..];
+}
diff --git a/plc4net/tools/code-gen/output/CSharpGenerator.cs b/plc4net/tools/code-gen/output/CSharpGenerator.cs
new file mode 100644
index 0000000000..c26affe287
--- /dev/null
+++ b/plc4net/tools/code-gen/output/CSharpGenerator.cs
@@ -0,0 +1,2435 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model;
+using org.apache.plc4net.tools.codegen.model.fields;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.output
+{
+ /// <summary>
+ /// Emits C# for an mspec <see cref="Protocol"/>: one file per complex type
+ /// (class + <c>StaticParse</c> + <c>Serialize</c> + length) and per enum,
+ /// plus a constants class. This is the pure-.NET replacement for the Java
+ /// freemarker <c>model-template</c> / <c>io-template</c>.
+ /// </summary>
+ public sealed class CSharpGenerator
+ {
+ private readonly Protocol _protocol;
+ private readonly string _namespace;
+ private readonly string _protocolName;
+
+ public CSharpGenerator(Protocol protocol, string protocolName, string namespaceName)
+ {
+ _protocol = protocol;
+ _protocolName = protocolName;
+ _namespace = namespaceName;
+ }
+
+ /// <summary>Generates every file; key is a relative path.</summary>
+ /// <summary>Fails generation on model states that would silently
+ /// produce wrong or non-compiling drivers: a non-big-endian
+ /// <c>byteOrder</c> (no C# read/write path emits the swaps yet) and
+ /// enum fields keyed on an accessor the enum does not expose as a
+ /// non-external argument (the reverse lookup would never be
+ /// emitted, and generated code would not compile).</summary>
+ private void ValidateModel()
+ {
+ foreach (var t in _protocol.Types.Cast<TypeDefinition>()
+ .Concat<TypeDefinition>(_protocol.Enums)
+ .Concat(_protocol.DataIos))
+ {
+ if (t.Attributes.TryGetValue("byteOrder", out var order)
+ && order is StringLiteral o && o.Value != "BIG_ENDIAN")
+ {
+ throw new NotSupportedException(
+ $"type '{t.Name}' declares byteOrder '{o.Value}'; "
+ + "little-endian generation is not implemented yet");
+ }
+ }
+
+ foreach (var t in _protocol.Types)
+ {
+ foreach (var f in t.Fields)
+ {
+ if (f is UnsupportedField unsupported)
+ {
+ throw new NotSupportedException(
+ $"type '{t.Name}' contains unsupported mspec field "
+ + $"'{unsupported.MspecKeyword}': {unsupported.RawText}");
+ }
+ if (f is ArrayField { LoopType: ArrayField.Loop.Terminated })
+ {
+ throw new NotSupportedException(
+ $"type '{t.Name}' contains a terminated array '{f.Name}'; "
+ + "terminated arrays are not implemented and cannot be emitted as count arrays");
+ }
+ if (f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.Time
+ or SimpleTypeReference.Base.Date or SimpleTypeReference.Base.DateTime })
+ {
+ throw new NotSupportedException(
+ $"type '{t.Name}.{f.Name}' uses '{f.Type}', which has no C# buffer mapping yet");
+ }
+ if (f is not EnumField ef || ef.KeyAccessor == null
+ || ef.Type is not EnumTypeReference et)
+ {
+ continue;
+ }
+ var e = _protocol.FindEnum(et.Name);
+ var ki = e?.Arguments.FindIndex(a => a.Name == ef.KeyAccessor) ?? -1;
+ if (ki < 0 || IsExternalEnum(e!.Arguments[ki].Type))
+ {
+ throw new InvalidOperationException(
+ $"enum field '{t.Name}.{ef.Name}' keys on '{ef.KeyAccessor}', which '{et.Name}' "
+ + "does not declare as a non-external argument; "
+ + "the generated reverse lookup would not exist");
+ }
+ }
+ }
+
+ foreach (var dio in _protocol.DataIos)
+ {
+ foreach (var cs in dio.Cases)
+ {
+ foreach (var f in cs.Fields)
+ {
+ if (f is UnsupportedField unsupported)
+ {
+ throw new NotSupportedException(
+ $"dataIo '{dio.Name}.{cs.Name}' contains unsupported mspec field "
+ + $"'{unsupported.MspecKeyword}': {unsupported.RawText}");
+ }
+ if (f is ArrayField { LoopType: ArrayField.Loop.Terminated })
+ {
+ throw new NotSupportedException(
+ $"dataIo '{dio.Name}.{cs.Name}' contains a terminated array '{f.Name}'; "
+ + "terminated arrays are not implemented");
+ }
+ }
+ if (!IsSupportedDataIoCase(dio, cs))
+ {
+ throw new NotSupportedException(
+ $"dataIo '{dio.Name}' case '{cs.Name}' is not a supported C# value shape");
+ }
+ }
+ }
+
+ var staticCalls = new SortedDictionary<string, string>(StringComparer.Ordinal);
+ CollectStaticCalls(staticCalls);
+ foreach (var name in staticCalls.Keys)
+ {
+ if (!IsGeneratedStaticCall(name))
+ {
+ throw new NotSupportedException(
+ $"{_protocolName}.mspec uses STATIC_CALL '{name}', but no C# implementation is available");
+ }
+ }
+ }
+
+ public Dictionary<string, string> Generate()
+ {
+ ValidateModel();
+
+ var files = new Dictionary<string, string>();
+
+ foreach (var e in _protocol.Enums)
+ {
+ // An external enum (PlcValueType) is hand-written in the SPI;
+ // emitting a stub here would shadow it with an empty type.
+ if (e.IsExternal)
+ {
+ continue;
+ }
+ files[$"model/{e.Name}.cs"] = EmitEnum(e);
+ }
+ foreach (var t in _protocol.Types)
+ {
+ files[$"model/{t.Name}.cs"] = EmitComplexType(t);
+ }
+ foreach (var dio in _protocol.DataIos)
+ {
+ files[$"model/{dio.Name}.cs"] = EmitDataIo(dio);
+ }
+ if (_protocol.Constants.Count > 0)
+ {
+ files[$"model/{Pascal(_protocolName)}Constants.cs"] = EmitConstants();
+ }
+
+ files[$"model/{Pascal(_protocolName)}StaticHelper.cs"] = EmitStaticHelper();
+ return files;
+ }
+
+ // ── complex types ───────────────────────────────────────
+
+ private string EmitComplexType(ComplexTypeDefinition type)
+ {
+ var scope = new FieldScope(_protocol, type);
+ var render = new CSharpExpressionRenderer(scope)
+ {
+ StaticHelperClass = $"{Pascal(_protocolName)}StaticHelper",
+ };
+
+ var c = new CodeWriter();
+ Header(c);
+ c.Line("using System;");
+ c.Line("using System.Linq;");
+ c.Line("using org.apache.plc4net.spi.drivers;");
+ c.Line("using org.apache.plc4net.spi.generation;");
+ c.Line();
+ c.Line($"namespace {_namespace}.model");
+ c.Line("{");
+ c.Indent();
+
+ var parent = type.IsDiscriminatedChild ? $" : {type.ParentName}" : " : IMessage";
+ var mods = type.IsDiscriminatedParent ? "abstract partial" : "partial";
+ c.Line($"public {mods} class {type.Name}{parent}");
+ c.Line("{");
+ c.Indent();
+
+ EmitConstFields(c, type, render);
+ EmitDiscriminatorAccessors(c, type, render);
+ EmitProperties(c, type, render);
+ EmitConstructor(c, type);
+ EmitStaticParse(c, type, render, scope);
+ EmitSerialize(c, type, render, scope);
+ EmitLength(c, type, render, scope);
+
+ c.Outdent();
+ c.Line("}");
+ c.Outdent();
+ c.Line("}");
+ return c.ToString();
+ }
+
+ private void EmitConstFields(CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r)
+ {
+ foreach (var f in type.ConstFields)
+ {
+ c.Line($"public const {CSharpTypeMapper.CSharpType(f.Type)} {Pascal(f.Name)} = {r.Render(f.ReferenceValue)};");
+ }
+ if (type.ConstFields.Any())
+ {
+ c.Line();
+ }
+ }
+
+ private void EmitDiscriminatorAccessors(CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r)
+ {
+ if (type.IsDiscriminatedParent)
+ {
+ foreach (var d in type.Fields.OfType<DiscriminatorField>())
+ {
+ c.Line($"public abstract {CSharpTypeMapper.CSharpType(d.Type)} {Pascal(d.Name)} {{ get; }}");
+ }
+ // A parser-arg discriminator with no matching field becomes an
+ // abstract accessor too, so children can pin their value.
+ foreach (var name in ParserArgDiscriminatorNames(type))
+ {
+ var arg = type.Arguments.First(a => a.Name == name);
+ c.Line($"public abstract {CSharpTypeMapper.CSharpType(arg.Type)} {Pascal(name)} {{ get; }}");
+ }
+ c.Line();
+ }
+
+ if (type.IsDiscriminatedChild && _protocol.FindType(type.ParentName) is { } p)
+ {
+ var values = type.DiscriminatorValues ?? Array.Empty<Term>();
+ var discriminators = DiscriminatorList(p, r);
+ var emitted = false;
+ for (var i = 0; i < discriminators.Count; i++)
+ {
+ var d = discriminators[i];
+ if (d.AccessorName == null)
+ {
+ continue; // property field or dotted path - no accessor
+ }
+ var pinned = values.Count > i && values[i] != null;
+ // An unpinned discriminator (the case listed fewer values,
+ // or a null slot) takes the type's default, matching plc4j:
+ // ModbusPDUError leaves functionFlag / response unspecified
+ // and its accessors return 0 / false.
+ var rhs = pinned
+ ? RenderDiscriminatorValue(values[i], d.Type, r)
+ : $"default({CSharpTypeMapper.CSharpType(d.Type)})";
+ c.Line($"public override {CSharpTypeMapper.CSharpType(d.Type)} {Pascal(d.AccessorName)} => {rhs};");
+ emitted = true;
+ }
+ if (emitted)
+ {
+ c.Line();
+ }
+ }
+ }
+
+ private void EmitProperties(CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer parseRender)
+ {
+ foreach (var f in type.PropertyFields)
+ {
+ c.Line($"public {PropertyType(f)} {Pascal(f.Name)} {{ get; }}");
+ }
+ var serializeRender = new CSharpExpressionRenderer(new FieldScope(_protocol, type, serialize: true))
+ {
+ StaticHelperClass = parseRender.StaticHelperClass,
+ };
+ foreach (var f in type.Fields.OfType<VirtualField>())
+ {
+ c.Line($"public {CSharpTypeMapper.CSharpType(f.Type)} {Pascal(f.Name)} => {WithType(serializeRender.Render(f.ValueExpression), f.Type)};");
+ }
+ if (type.PropertyFields.Any() || type.Fields.OfType<VirtualField>().Any())
+ {
+ c.Line();
+ }
+ }
+
+ private void EmitConstructor(CodeWriter c, ComplexTypeDefinition type)
+ {
+ var all = AllValueFields(type);
+ if (all.Count == 0)
+ {
+ return;
+ }
+
+ var own = type.PropertyFields.ToList();
+ var inherited = ParentPrefixFields(type).Concat(ParentSuffixFields(type)).ToList();
+
+ var pars = string.Join(", ", all.Select(f => $"{PropertyType(f)} {Camel(f.Name)}"));
+ var mod = type.IsDiscriminatedParent ? "protected" : "public";
+ var baseCall = inherited.Count > 0
+ ? " : base(" + string.Join(", ", inherited.Select(f => Camel(f.Name))) + ")"
+ : "";
+
+ c.Line($"{mod} {type.Name}({pars}){baseCall}");
+ c.Line("{");
+ c.Indent();
+ foreach (var f in own)
+ {
+ c.Line($"{Pascal(f.Name)} = {Camel(f.Name)};");
+ }
+ c.Outdent();
+ c.Line("}");
+ c.Line();
+ }
+
+ // ── StaticParse ─────────────────────────────────────────
+
+ private void EmitStaticParse(
+ CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r, FieldScope scope)
+ {
+ var args = ParseSignatureArgs(type);
+ // `new` only when the child's StaticParse has the same signature as
+ // the base's (no extra context parameters) - otherwise it neither
+ // hides anything (CS0109) nor needs to.
+ var hide = type.IsDiscriminatedChild && ParentContextFields(type).Count == 0
+ ? "new "
+ : "";
+ c.Line($"public static {hide}{type.Name} StaticParse(ReadBuffer readBuffer{Comma(args)})");
+ c.Line("{");
+ c.Indent();
+
+ // `curPos` is "bytes since this type's parse began". A child that
+ // needs it receives _startPos from the parent (the byte count is
+ // measured from where the parent started, before the discriminators);
+ // everyone else captures it here.
+ if (NeedsStartPos(type) && !(type.IsDiscriminatedChild && UsesCurPos(type)))
+ {
+ c.Line("var _startPos = readBuffer.GetPos();");
+ }
+
+ foreach (var f in FieldsToEmit(type))
+ {
+ EmitFieldParse(c, type, f, r, scope);
+ }
+
+ if (!type.IsDiscriminatedParent)
+ {
+ // prefix (passed in), own + suffix (read above), in wire order.
+ var ctorArgs = string.Join(", ", AllValueFields(type).Select(f => Camel(f.Name)));
+ c.Line($"return new {type.Name}({ctorArgs});");
+ }
+
+ c.Outdent();
+ c.Line("}");
+ c.Line();
+ }
+
+ private void EmitFieldParse(
+ CodeWriter c, ComplexTypeDefinition type, Field f, CSharpExpressionRenderer r, FieldScope scope)
+ {
+ switch (f)
+ {
+ case DiscriminatorField d:
+ c.Line($"var {Camel(d.Name)} = {CSharpTypeMapper.ReadCall(d.Type, d.Name)};");
+ break;
+
+ case SimpleField:
+ c.Line($"var {Camel(f.Name)} = {ReadFieldValue(f, r)};");
+ break;
+
+ case EnumField ef:
+ c.Line($"var {Camel(ef.Name)} = {EnumFieldRead(ef, r)};");
+ break;
+
+ case ConstField cf:
+ c.Line($"var {Camel(cf.Name)} = {CSharpTypeMapper.ReadCall(cf.Type, cf.Name)};");
+ c.Line($"if (!Equals({Camel(cf.Name)}, {WithType(r.Render(cf.ReferenceValue), cf.Type)}))");
+ c.Indent();
+ c.Line($"throw new ParseException($\"Expected constant {{{Pascal(cf.Name)}}} for '{cf.Name}' but got {{{Camel(cf.Name)}}}\");");
+ c.Outdent();
+ break;
+
+ case ImplicitField imf:
+ c.Line($"var {Camel(imf.Name)} = {CSharpTypeMapper.ReadCall(imf.Type, imf.Name)};");
+ break;
+
+ case ReservedField rf:
+ c.Line("{");
+ c.Indent();
+ c.Line($"var reserved = {CSharpTypeMapper.ReadCall(rf.Type, "reserved")};");
+ c.Line($"if (!Equals(reserved, {WithType(r.Render(rf.ReferenceValue), rf.Type)})) {{ /* mspec reserved: value differs from the spec default */ }}");
+ c.Outdent();
+ c.Line("}");
+ break;
+
+ case ArrayField af:
+ EmitArrayParse(c, af, r, scope);
+ break;
+
+ case ChecksumField ck:
+ c.Line($"var {Camel(ck.Name)} = {CSharpTypeMapper.ReadCall(ck.Type, ck.Name)};");
+ c.Line($"var expected{Pascal(ck.Name)} = {WithType(r.Render(ck.ChecksumExpression), ck.Type)};");
+ c.Line($"if (!Equals({Camel(ck.Name)}, expected{Pascal(ck.Name)}))");
+ c.Indent();
+ c.Line($"throw new ParseException($\"Checksum '{ck.Name}' does not match: expected {{expected{Pascal(ck.Name)}}} but got {{{Camel(ck.Name)}}}\");");
+ c.Outdent();
+ break;
+
+ case OptionalField opt:
+ c.Line($"{PropertyType(opt)} {Camel(opt.Name)} = null;");
+ if (opt.Condition != null)
+ {
+ c.Line($"if ({r.Render(opt.Condition)})");
+ c.Line("{");
+ c.Indent();
+ }
+ c.Line($"{Camel(opt.Name)} = {ReadFieldValue(opt, r)};");
+ if (opt.Condition != null)
+ {
+ c.Outdent();
+ c.Line("}");
+ }
+ break;
+
+ case ManualField mf:
+ c.Line($"var {Camel(mf.Name)} = ({CSharpTypeMapper.CSharpType(mf.Type)}) ({r.Render(mf.ParseExpression)});");
+ break;
+
+ case PaddingField pad:
+ c.Line("{");
+ c.Indent();
+ c.Line($"var _timesPadding = (int) ({r.Render(pad.TimesPadding)});");
+ c.Line("while (_timesPadding-- > 0)");
+ c.Line("{");
+ c.Indent();
+ c.Line($"{CSharpTypeMapper.ReadCall(pad.Type, "padding")};");
+ c.Outdent();
+ c.Line("}");
+ c.Outdent();
+ c.Line("}");
+ break;
+
+ case TypeSwitchField ts:
+ EmitTypeSwitchParse(c, type, ts, r);
+ break;
+
+ case VirtualField vf:
+ c.Line($"var {Camel(vf.Name)} = {WithType(r.Render(vf.ValueExpression), vf.Type)};");
+ break;
+
+ case UnsupportedField uf:
+ c.Line($"// TODO unsupported mspec field '{uf.MspecKeyword}': {uf.RawText}");
+ break;
+ }
+ }
+
+ private void EmitArrayParse(CodeWriter c, ArrayField af, CSharpExpressionRenderer r, FieldScope scope)
+ {
+ var element = ElementType(af);
+ var (elemType, isByteArray) = CSharpTypeMapper.ArrayType(element);
+ var name = Camel(af.Name);
+
+ if (isByteArray && af.LoopType is ArrayField.Loop.Count or ArrayField.Loop.Length)
+ {
+ c.Line($"var {name} = readBuffer.ReadByteArray(\"{af.Name}\", (int) ({r.Render(af.LoopExpression)}) * 8);");
+ return;
+ }
+
+ c.Line($"var {name} = new {elemType}();");
+ // Underscore-prefixed locals: mspec field names never start with one,
+ // so a loop's bookkeeping cannot collide with an implicit / simple
+ // field of the same base name (e.g. an `itemsCount` implicit beside
+ // an `items count 'itemsCount'` array).
+ // `_lastItem` is only knowable ahead of time for a counted loop.
+ var lastItem = af.LoopType == ArrayField.Loop.Count && ElementUsesLastItem(af)
+ ? $"_{name}I == _{name}Cnt - 1"
+ : null;
+ if (af.LoopType == ArrayField.Loop.Length)
+ {
+ c.Line($"var _{name}End = readBuffer.GetPos() + (int) ({r.Render(af.LoopExpression)}) * 8;");
+ c.Line($"while (readBuffer.GetPos() < _{name}End)");
+ }
+ else
+ {
+ c.Line($"var _{name}Cnt = (int) ({r.Render(af.LoopExpression)});");
+ c.Line($"for (var _{name}I = 0; _{name}I < _{name}Cnt; _{name}I++)");
+ }
+ c.Line("{");
+ c.Indent();
+ var read = CSharpTypeMapper.ReadCall(element, af.Name, ElementArgs(element, r, lastItem));
+ c.Line($"{name}.Add({read});");
+ c.Outdent();
+ c.Line("}");
+ }
+
+ private void EmitTypeSwitchParse(
+ CodeWriter c, ComplexTypeDefinition parent, TypeSwitchField ts, CSharpExpressionRenderer r)
+ {
+ var discriminators = DiscriminatorList(parent, r);
+
+ foreach (var childName in ts.CaseNames)
+ {
+ // Every case name in ts.CaseNames was added to _protocol.Types
+ // by BuildTypeSwitch in the same pass that recorded the name.
+ var child = _protocol.FindType(childName)
+ ?? throw new InvalidOperationException($"Type switch case '{childName}' has no matching type definition.");
+ var values = child.DiscriminatorValues ?? Array.Empty<Term>();
+ var conds = new List<string>();
+ for (var i = 0; i < discriminators.Count && i < values.Count; i++)
+ {
+ if (values[i] == null)
+ {
+ continue;
+ }
+ var d = discriminators[i];
+ conds.Add($"Equals({d.DispatchExpr}, {RenderDiscriminatorValue(values[i], d.Type, r)})");
+ }
+ var test = conds.Count == 0 ? "true" : string.Join(" && ", conds);
+ var childArgs = ChildParseArgNames(child)
+ .Concat(ParentContextFields(child).Select(f => Camel(f.Name)));
+ if (UsesCurPos(child))
+ {
+ childArgs = childArgs.Append("_startPos");
+ }
+ c.Line($"if ({test})");
+ c.Line("{");
+ c.Indent();
+ c.Line($"return {childName}.StaticParse(readBuffer{Comma(childArgs)});");
+ c.Outdent();
+ c.Line("}");
+ }
+ c.Line($"throw new ParseException(\"No matching subtype found for {parent.Name}\");");
+ }
+
+ // ── Serialize ───────────────────────────────────────────
+
+ private void EmitSerialize(
+ CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r, FieldScope scope)
+ {
+ if (type.IsDiscriminatedChild)
+ {
+ c.Line($"protected override void SerializeChild(WriteBuffer writeBuffer)");
+ }
+ else if (UsesLastItem(type))
+ {
+ c.Line("public void Serialize(WriteBuffer writeBuffer) => Serialize(writeBuffer, false);");
+ c.Line();
+ c.Line("public void Serialize(WriteBuffer writeBuffer, bool _lastItem)");
+ }
+ else
+ {
+ c.Line("public void Serialize(WriteBuffer writeBuffer)");
+ }
+ c.Line("{");
+ c.Indent();
+
+ var serScope = scope.ForSerialize();
+ var serRender = new CSharpExpressionRenderer(serScope) { StaticHelperClass = r.StaticHelperClass };
+
+ HoistParentImplicits(c, type, serRender, serScope);
+
+ foreach (var f in FieldsToEmit(type))
+ {
+ // `_value` inside a manual field's serialize expression is that
+ // field's own value.
+ serScope.CurrentValue = f is ManualField ? Pascal(f.Name) : null;
+ EmitFieldSerialize(c, type, f, serRender);
+ }
+ serScope.CurrentValue = null;
+ serScope.ClearLocals();
+ if (type.IsDiscriminatedParent)
+ {
+ c.Line("SerializeChild(writeBuffer);");
+ }
+
+ c.Outdent();
+ c.Line("}");
+ c.Line();
+
+ if (type.IsDiscriminatedParent)
+ {
+ c.Line("protected abstract void SerializeChild(WriteBuffer writeBuffer);");
+ c.Line();
+ }
+ }
+
+ private void EmitFieldSerialize(
+ CodeWriter c, ComplexTypeDefinition type, Field f, CSharpExpressionRenderer r)
+ {
+ switch (f)
+ {
+ case DiscriminatorField d:
+ c.Line($"{CSharpTypeMapper.WriteCall(d.Type, d.Name, Pascal(d.Name))};");
+ break;
+
+ case SimpleField when f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } len }:
+ c.Line($"writeBuffer.WriteString(\"{f.Name}\", (int) ({r.Render(len)}), \"{VStringEncoding(f)}\", {Pascal(f.Name)});");
+ break;
+
+ case SimpleField:
+ c.Line($"{CSharpTypeMapper.WriteCall(f.Type, f.Name, Pascal(f.Name))};");
+ break;
+
+ case EnumField ef:
+ c.Line($"{EnumFieldWrite(ef)};");
+ break;
+
+ case ConstField cf:
+ c.Line($"{CSharpTypeMapper.WriteCall(cf.Type, cf.Name, Pascal(cf.Name))};");
+ break;
+
+ case ImplicitField imf:
+ var val = $"({CSharpTypeMapper.CSharpType(imf.Type)}) ({r.Render(imf.SerializeExpression)})";
+ c.Line($"{CSharpTypeMapper.WriteCall(imf.Type, imf.Name, val)};");
+ break;
+
+ case ReservedField rf:
+ c.Line($"{CSharpTypeMapper.WriteCall(rf.Type, "reserved", WithType(r.Render(rf.ReferenceValue), rf.Type))};");
+ break;
+
+ case ArrayField af:
+ EmitArraySerialize(c, af, r);
+ break;
+
+ case ChecksumField ck:
+ var cv = $"({CSharpTypeMapper.CSharpType(ck.Type)}) ({r.Render(ck.ChecksumExpression)})";
+ c.Line($"{CSharpTypeMapper.WriteCall(ck.Type, ck.Name, cv)};");
+ break;
+
+ case OptionalField opt:
+ // .Value for a nullable value type; the reference itself
+ // for a nullable class.
+ var optRef = opt.Type is ComplexTypeReference
+ ? Pascal(opt.Name)
+ : $"{Pascal(opt.Name)}.Value";
+ c.Line($"if ({Pascal(opt.Name)} != null)");
+ c.Line("{");
+ c.Indent();
+ c.Line($"{CSharpTypeMapper.WriteCall(opt.Type, opt.Name, optRef)};");
+ c.Outdent();
+ c.Line("}");
+ break;
+
+ case ManualField mf:
+ c.Line($"{r.Render(mf.SerializeExpression)};");
+ break;
+
+ case PaddingField pad:
+ c.Line("{");
+ c.Indent();
+ c.Line($"var _timesPadding = (int) ({r.Render(pad.TimesPadding)});");
+ c.Line("while (_timesPadding-- > 0)");
+ c.Line("{");
+ c.Indent();
+ c.Line($"{CSharpTypeMapper.WriteCall(pad.Type, "padding", WithType(r.Render(pad.PaddingValue), pad.Type))};");
+ c.Outdent();
+ c.Line("}");
+ c.Outdent();
+ c.Line("}");
+ break;
+
+ case TypeSwitchField:
+ case VirtualField:
+ case UnsupportedField:
+ break;
+ }
+ }
+
+ private void EmitArraySerialize(CodeWriter c, ArrayField af, CSharpExpressionRenderer r)
+ {
+ var element = ElementType(af);
+ var (_, isByteArray) = CSharpTypeMapper.ArrayType(element);
+ var prop = Pascal(af.Name);
+
+ if (isByteArray)
+ {
+ c.Line($"writeBuffer.WriteByteArray(\"{af.Name}\", {prop});");
+ return;
+ }
+
+ if (ElementUsesLastItem(af))
+ {
+ c.Line($"for (var _i = 0; _i < {prop}.Count; _i++)");
+ c.Line("{");
+ c.Indent();
+ c.Line($"{prop}[_i].Serialize(writeBuffer, _i == {prop}.Count - 1);");
+ c.Outdent();
+ c.Line("}");
+ return;
+ }
+
+ c.Line($"foreach (var _e in {prop})");
+ c.Line("{");
+ c.Indent();
+ c.Line($"{CSharpTypeMapper.WriteCall(element, af.Name, "_e")};");
+ c.Outdent();
+ c.Line("}");
+ }
+
+ // ── length ──────────────────────────────────────────────
+
+ private void EmitLength(
+ CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r, FieldScope scope)
+ {
+ var serScope = scope.ForSerialize();
+ var serRender = new CSharpExpressionRenderer(serScope) { StaticHelperClass = r.StaticHelperClass };
+
+ var childMethod = type.IsDiscriminatedChild;
+ var lastItem = !childMethod && UsesLastItem(type);
+ if (lastItem)
+ {
+ c.Line("public int GetLengthInBits() => GetLengthInBits(false);");
+ c.Line();
+ c.Line("public int GetLengthInBits(bool _lastItem)");
+ }
+ else
+ {
+ c.Line(childMethod
+ ? "protected override int GetLengthInBitsChild()"
+ : "public int GetLengthInBits()");
+ }
+ c.Line("{");
+ c.Indent();
+ c.Line("var lengthInBits = 0;");
+ HoistParentImplicits(c, type, serRender, serScope);
+ foreach (var f in FieldsToEmit(type))
+ {
+ var term = LengthContribution(f, serRender);
+ if (term != null)
+ {
+ c.Line($"lengthInBits += {term};");
+ }
+ }
+ serScope.ClearLocals();
+ if (type.IsDiscriminatedParent)
+ {
+ c.Line("lengthInBits += GetLengthInBitsChild();");
+ }
+ c.Line("return lengthInBits;");
+ c.Outdent();
+ c.Line("}");
+ c.Line();
+
+ if (type.IsDiscriminatedParent)
+ {
+ c.Line("protected abstract int GetLengthInBitsChild();");
+ c.Line();
+ }
+ if (!childMethod)
+ {
+ // Round up: a type that is not byte-aligned still occupies a
+ // whole trailing byte on the wire (plc4j and the dataIo path
+ // both ceil).
+ c.Line($"public int GetLengthInBytes() => (GetLengthInBits({(lastItem ? "false" : "")}) + 7) / 8;");
+ }
+ }
+
+ /// <summary>
+ /// A discriminated child's serialize / length can reference the
+ /// parent's pre-typeSwitch implicit fields, which the parent (not the
+ /// child) writes. Recompute the ones it names as locals at the top so
+ /// the expressions resolve.
+ /// </summary>
+ private void HoistParentImplicits(
+ CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r, FieldScope scope)
+ {
+ if (!type.IsDiscriminatedChild || _protocol.FindType(type.ParentName) is not { } p)
+ {
+ return;
+ }
+ var referenced = new HashSet<string>();
+ foreach (var f in FieldsToEmit(type))
+ {
+ foreach (var t in SerializeTerms(f))
+ {
+ CollectNames(t, referenced);
+ }
+ }
+ foreach (var imf in p.PrefixImplicitFields.Where(i => referenced.Contains(i.Name)))
+ {
+ scope.MarkLocal(imf.Name);
+ c.Line($"var {Camel(imf.Name)} = ({CSharpTypeMapper.CSharpType(imf.Type)}) ({r.Render(imf.SerializeExpression)});");
+ }
+ }
+
+ /// <summary>The expression trees a field contributes to
+ /// <c>Serialize</c> / <c>GetLengthInBits</c>. Excludes the parse-only
+ /// ones: an optional's condition, an array's loop expression, a manual
+ /// field's parse expression, and a complex field's constructor
+ /// arguments (those are passed to <c>StaticParse</c>, never re-evaluated
+ /// on the way out).</summary>
+ private static IEnumerable<Term> SerializeTerms(Field f)
+ {
+ switch (f)
+ {
+ case ImplicitField i: yield return i.SerializeExpression; break;
+ case ChecksumField ck: yield return ck.ChecksumExpression; break;
+ case ReservedField r: yield return r.ReferenceValue; break;
+ case VirtualField v: yield return v.ValueExpression; break;
+ case ManualField m:
+ yield return m.SerializeExpression;
+ yield return m.LengthExpression;
+ break;
+ case PaddingField p:
+ yield return p.PaddingValue;
+ yield return p.TimesPadding;
+ break;
+ }
+ }
+
+ private string? LengthContribution(Field f, CSharpExpressionRenderer r)
+ {
+ switch (f)
+ {
+ case SimpleField or ConstField or DiscriminatorField or ImplicitField
+ or ReservedField or ChecksumField or EnumField:
+ var bits = CSharpTypeMapper.FixedBitLength(f.Type);
+ if (bits >= 0)
+ {
+ return bits.ToString();
+ }
+ if (f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } vlen })
+ {
+ return $"({r.Render(vlen)})";
+ }
+ if (f.Type is ComplexTypeReference)
+ {
+ return $"{Pascal(f.Name)}.GetLengthInBits()";
+ }
+ return null; // vstring without a length / unmodelled - not computed
+
+ case VirtualField:
+ return null;
+
+ case ArrayField af:
+ var element = ElementType(af);
+ var (_, isByteArray) = CSharpTypeMapper.ArrayType(element);
+ var prop = Pascal(af.Name);
+ if (isByteArray)
+ {
+ return $"({prop}.Length * 8)";
+ }
+ var eb = CSharpTypeMapper.FixedBitLength(element);
+ if (eb >= 0)
+ {
+ return $"({prop}.Count * {eb})";
+ }
+ return ElementUsesLastItem(af)
+ ? $"System.Linq.Enumerable.Range(0, {prop}.Count).Sum(_i => {prop}[_i].GetLengthInBits(_i == {prop}.Count - 1))"
+ : $"{prop}.Sum(_e => _e.GetLengthInBits())";
+
+ case OptionalField opt:
+ var name = Pascal(opt.Name);
+ var ob = CSharpTypeMapper.FixedBitLength(opt.Type);
+ if (ob >= 0)
+ {
+ return $"({name} != null ? {ob} : 0)";
+ }
+ return opt.Type is ComplexTypeReference
+ ? $"({name}?.GetLengthInBits() ?? 0)"
+ : null;
+
+ case ManualField mf:
+ return $"(({r.Render(mf.LengthExpression)}) * 8)";
+
+ case PaddingField pad:
+ var pb = CSharpTypeMapper.FixedBitLength(pad.Type);
+ return $"(({r.Render(pad.TimesPadding)}) * {(pb > 0 ? pb : 8)})";
+
+ default:
+ return null;
+ }
+ }
+
+ // ── enums ───────────────────────────────────────────────
+
+ private string EmitEnum(EnumTypeDefinition e)
+ {
+ var r = new CSharpExpressionRenderer(FieldScope.EnumConstantsOnly(_protocol));
+ var baseType = e.BaseType != null
+ ? CSharpTypeMapper.SimpleCSharpType(e.BaseType)
+ : "int";
+
+ var c = new CodeWriter();
+ Header(c);
+ c.Line($"namespace {_namespace}.model");
+ c.Line("{");
+ c.Indent();
+ c.Line($"public enum {e.Name} : {baseType}");
+ c.Line("{");
+ c.Indent();
+ foreach (var v in e.Values)
+ {
+ var val = v.Value != null ? r.Render(v.Value) : null;
+ c.Line(val != null ? $"{v.Name} = {val}," : $"{v.Name},");
+ }
+ c.Outdent();
+ c.Line("}");
+
+ if (e.Arguments.Count > 0)
+ {
+ c.Line();
+ c.Line($"public static class {e.Name}Extensions");
+ c.Line("{");
+ c.Indent();
+ for (var ai = 0; ai < e.Arguments.Count; ai++)
+ {
+ var arg = e.Arguments[ai];
+ // The accessor would return an external enum whose members
+ // the generator cannot see; nothing consumes it, so skip it.
+ if (IsExternalEnum(arg.Type))
+ {
+ continue;
+ }
+ var rows = e.Values.Where(v => v.ConstantValues.Count > ai).ToList();
+ var nullable = IsValueType(arg.Type)
+ && rows.Any(v => v.ConstantValues[ai] is NullLiteral);
+ var retType = CSharpTypeMapper.CSharpType(arg.Type) + (nullable ? "?" : "");
+ // `Get` prefix: keeps the method name off the enum type name
+ // it may return (`DataTransportSize`), and matches the
+ // `x.attr` -> `x.GetAttr()` the expression renderer emits.
+ c.Line($"public static {retType} Get{Pascal(arg.Name)}(this {e.Name} value) => value switch");
+ c.Line("{");
+ c.Indent();
+ // C# rejects duplicate switch patterns when two enum
+ // members share the same underlying value (S7's
+ // TransportSize has COUNTER and DATE_AND_TIME at 0x1C).
+ // Keep every enum member in the declaration, but make the
+ // accessor deterministic by keeping the first member for
+ // each underlying enum value.
+ EmitEnumArms(
+ c,
+ rows,
+ v => RenderEnumUnderlyingValue(v, r),
+ v => $"{e.Name}.{v.Name}",
+ v => RenderEnumParamValue(v.ConstantValues[ai], arg.Type, r));
+ c.Line("_ => default!,");
+ c.Outdent();
+ c.Line("};");
+ }
+ foreach (var key in EnumFieldKeys(e.Name))
+ {
+ var ki = e.Arguments.FindIndex(a => a.Name == key);
+ if (ki < 0)
+ {
+ continue;
+ }
+ var keyType = e.Arguments[ki].Type;
+ var rows = e.Values.Where(v => v.ConstantValues.Count > ki).ToList();
+ c.Line($"public static {e.Name}? FirstEnumForField{Pascal(key)}({CSharpTypeMapper.CSharpType(keyType)} {Camel(key)}) => {Camel(key)} switch");
+ c.Line("{");
+ c.Indent();
+ // "first" lookup: the earliest constant wins each key value.
+ string PatternFor(EnumValue v) => RenderEnumParamValue(v.ConstantValues[ki], keyType, r);
+ EmitEnumArms(c, rows, PatternFor, PatternFor, v => $"{e.Name}.{v.Name}");
+ // An unmapped key is null, not a bogus `(T) 0` — the caller
+ // (a parse path) then fails loudly with a real value in hand.
+ c.Line("_ => null,");
+ c.Outdent();
+ c.Line("};");
+ }
+ c.Outdent();
+ c.Line("}");
+ }
+
+ c.Outdent();
+ c.Line("}");
+ return c.ToString();
+ }
+
+ /// <summary>Emits <c>pattern => result,</c> switch arms, skipping any
+ /// whose <paramref name="dedupKey"/> repeats an earlier arm - two enum
+ /// constants can share an underlying value, and an enum can map several
+ /// constants to the same attribute value.</summary>
+ private static void EmitEnumArms(
+ CodeWriter c, IEnumerable<EnumValue> rows,
+ System.Func<EnumValue, string> dedupKey,
+ System.Func<EnumValue, string> pattern,
+ System.Func<EnumValue, string> result)
+ {
+ var seen = new HashSet<string>();
+ foreach (var v in rows)
+ {
+ if (seen.Add(dedupKey(v)))
+ {
+ c.Line($"{pattern(v)} => {result(v)},");
+ }
+ }
+ }
+
+ /// <summary>Renders one entry of an enum's parameter table, coerced to
+ /// the parameter's declared type: a bare word is a string literal for a
+ /// <c>vstring</c> parameter, a member of the target enum for an
+ /// enum-typed one, and a char literal for a one-character integer.</summary>
+ private string RenderEnumParamValue(Term term, TypeReference target, CSharpExpressionRenderer r)
+ {
+ switch (target)
+ {
+ case EnumTypeReference et:
+ if (term is NullLiteral)
+ {
+ return "null";
+ }
+ if (term is VariableLiteral { Child: null, IsCall: false } ev)
+ {
+ return $"{et.Name}.{ev.Name}";
+ }
+ return r.Render(term);
+
+ case SimpleTypeReference { BaseType: SimpleTypeReference.Base.String
+ or SimpleTypeReference.Base.VString }:
+ if (term is NullLiteral)
+ {
+ return "null";
+ }
+ if (term is VariableLiteral { Child: null, IsCall: false } sv)
+ {
+ return sv.Name == MspecReader.EmptyStringSentinel
+ ? "\"\""
+ : "\"" + sv.Name + "\"";
+ }
+ // A string-typed parameter table entry is always a literal, but
+ // an unquoted one is parsed as an expression: a bare number
+ // ('409') as an IntegerLiteral, a hyphenated id ('DPST-1-1') as
+ // a '-' chain. Fold both back to the string they spell.
+ if (term is StringLiteral strLit)
+ {
+ return "\"" + strLit.Value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
+ }
+ if (term is IntegerLiteral or FloatLiteral or BooleanLiteral)
+ {
+ return "\"" + term + "\"";
+ }
+ if (TryFlattenHyphenatedId(term, out var id))
+ {
+ return "\"" + id + "\"";
+ }
+ return r.Render(term);
+
+ case SimpleTypeReference { IsIntegerLike: true } it:
+ if (term is VariableLiteral { Child: null, IsCall: false, Name.Length: 1 } cv)
+ {
+ return $"({CSharpTypeMapper.SimpleCSharpType(it)}) '{cv.Name}'";
+ }
+ return r.Render(term);
+
+ default:
+ return r.Render(term);
+ }
+ }
+
+ /// <summary>An mspec enum id like <c>'DPST-1-1'</c> lexes as
+ /// <c>DPST - 1 - 1</c>: the grammar has no hyphen in an identifier and no
+ /// bare string. Rebuild the original text from a subtraction chain whose
+ /// leaves are all bare names or integers - the only way a string-typed
+ /// parameter can carry a <c>-</c>.</summary>
+ private static bool TryFlattenHyphenatedId(Term term, out string? id)
+ {
+ var parts = new List<string>();
+ if (!Collect(term) || parts.Count < 2)
+ {
+ id = null;
+ return false;
+ }
+ id = string.Join("-", parts);
+ return true;
+
+ bool Collect(Term t)
+ {
+ switch (t)
+ {
+ case BinaryExpression { Operator: "-" } b:
+ return Collect(b.Left) && Collect(b.Right);
+ case VariableLiteral { Child: null, IsCall: false, Index.Count: 0 } v:
+ parts.Add(v.Name);
+ return true;
+ case IntegerLiteral i:
+ parts.Add(i.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ return true;
+ default:
+ return false;
+ }
+ }
+ }
+
+ private bool IsExternalEnum(TypeReference t) =>
+ t is EnumTypeReference et
+ && _protocol.Enums.Any(e => e.Name == et.Name && e.IsExternal);
+
+ private static bool IsValueType(TypeReference t) =>
+ t is EnumTypeReference
+ || t is SimpleTypeReference s
+ && s.BaseType is not (SimpleTypeReference.Base.String or SimpleTypeReference.Base.VString);
+
+ /// <summary>The distinct <c>KeyAccessor</c>s of every <c>enum</c> field
+ /// in the protocol that is typed as <paramref name="enumName"/> - each
+ /// needs a <c>FirstEnumForField…</c> reverse lookup.</summary>
+ private IEnumerable<string> EnumFieldKeys(string enumName) =>
+ _protocol.Types
+ .SelectMany(t => t.Fields)
+ .OfType<EnumField>()
+ .Where(f => f.KeyAccessor != null
+ && (f.Type as EnumTypeReference)?.Name == enumName)
+ .Select(f => f.KeyAccessor!)
+ .Distinct();
+
+ private string EmitConstants()
+ {
+ var r = new CSharpExpressionRenderer(FieldScope.EnumConstantsOnly(_protocol));
+ var c = new CodeWriter();
+ Header(c);
+ c.Line($"namespace {_namespace}.model");
+ c.Line("{");
+ c.Indent();
+ c.Line($"public static class {Pascal(_protocolName)}Constants");
+ c.Line("{");
+ c.Indent();
+ foreach (var k in _protocol.Constants)
+ {
+ c.Line($"public const {CSharpTypeMapper.CSharpType(k.Type)} {Pascal(k.Name)} = {r.Render(k.Value)};");
+ }
+ c.Outdent();
+ c.Line("}");
+ c.Outdent();
+ c.Line("}");
+ return c.ToString();
+ }
+
+ // ── dataIo (parses to / from an IPlcValue) ──────────────
+
+ /// <summary>IEC value wrappers whose plc4net type is exactly
+ /// <c>Plc<caseName></c> and which round-trip through a single
+ /// scalar accessor.</summary>
+ private static readonly HashSet<string> ScalarPlcValueCases = new HashSet<string>
+ {
+ "BOOL", "BYTE", "WORD", "DWORD", "LWORD",
+ "SINT", "USINT", "INT", "UINT", "DINT", "UDINT", "LINT", "ULINT",
+ "REAL", "LREAL", "CHAR", "WCHAR", "STRING", "WSTRING",
+ };
+
+ /// <summary>The TIA date / time <c>DataItem</c> cases - each maps to a
+ /// <c>Plc{caseName}</c> built through a factory / segment call, and the
+ /// S7 date helpers (<c>parseS5Time</c>, <c>parseTiaDate</c>,
+ /// <c>parseSiemensYear</c>, BCD). Only the multi-value
+ /// <c>numberOfValues > 1</c> list cases stay a throwing stub now.</summary>
+ private static readonly HashSet<string> TemporalPlcValueCases = new HashSet<string>
+ {
+ "TIME", "LTIME", "DATE", "TIME_OF_DAY", "LTIME_OF_DAY",
+ "DATE_AND_TIME", "DATE_AND_LTIME",
+ };
+
+ private string EmitDataIo(DataIoTypeDefinition dio)
+ {
+ var helper = $"{Pascal(_protocolName)}StaticHelper";
+
+ var c = new CodeWriter();
+ Header(c);
+ c.Line("using System;");
+ c.Line("using org.apache.plc4net.api.value;");
+ c.Line("using org.apache.plc4net.spi.generation;");
+ c.Line("using org.apache.plc4net.spi.model.values;");
+ c.Line();
+ c.Line($"namespace {_namespace}.model");
+ c.Line("{");
+ c.Indent();
+ c.Line("/// <summary>");
+ c.Line($"/// mspec <c>[dataIo {dio.Name}]</c> - reads and writes one");
+ c.Line("/// <see cref=\"IPlcValue\"/> whose wire layout the parser arguments pick.");
+ c.Line("/// </summary>");
+ c.Line($"public static class {dio.Name}");
+ c.Line("{");
+ c.Indent();
+
+ var argDecls = dio.Arguments
+ .Select(a => $"{CSharpTypeMapper.CSharpType(a.Type)} {Camel(a.Name)}")
+ .ToList();
+ var argNames = dio.Arguments.Select(a => Camel(a.Name)).ToList();
+
+ // A dataIo case with no discriminator values (KNX's KnxProperty) is
+ // the catch-all: it compiles to `else { … }`, and the trailing
+ // fallback `return` after it would be unreachable.
+ var caughtAll = dio.Cases.Count > 0 && IsWildcardCase(dio.Cases[^1]);
+
+ c.Line($"public static IPlcValue StaticParse(ReadBuffer readBuffer{Comma(argDecls)})");
+ c.Line("{");
+ c.Indent();
+ for (var i = 0; i < dio.Cases.Count; i++)
+ {
+ EmitDataIoCase(c, dio, dio.Cases[i], helper, DataIoMode.Parse, i == 0, i == dio.Cases.Count - 1);
+ }
+ if (!caughtAll)
+ {
+ c.Line("return new PlcNULL();");
+ }
+ c.Outdent();
+ c.Line("}");
+ c.Line();
+
+ c.Line($"public static void StaticSerialize(WriteBuffer writeBuffer, IPlcValue _value{Comma(argDecls)})");
+ c.Line("{");
+ c.Indent();
+ for (var i = 0; i < dio.Cases.Count; i++)
+ {
+ EmitDataIoCase(c, dio, dio.Cases[i], helper, DataIoMode.Serialize, i == 0, i == dio.Cases.Count - 1);
+ }
+ c.Outdent();
+ c.Line("}");
+ c.Line();
+
+ c.Line($"public static int GetLengthInBytes(IPlcValue _value{Comma(argDecls)}) =>");
+ c.Indent();
+ c.Line($"(GetLengthInBits(_value{Comma(argNames)}) + 7) / 8;");
+ c.Outdent();
+ c.Line();
+ c.Line($"public static int GetLengthInBits(IPlcValue _value{Comma(argDecls)})");
+ c.Line("{");
+ c.Indent();
+ c.Line("var lengthInBits = 0;");
+ for (var i = 0; i < dio.Cases.Count; i++)
+ {
+ EmitDataIoCase(c, dio, dio.Cases[i], helper, DataIoMode.Length, i == 0, i == dio.Cases.Count - 1);
+ }
+ c.Line("return lengthInBits;");
+ c.Outdent();
+ c.Line("}");
+
+ c.Outdent();
+ c.Line("}");
+ c.Outdent();
+ c.Line("}");
+ return c.ToString();
+ }
+
+ private enum DataIoMode { Parse, Serialize, Length }
+
+ /// <summary>A dataIo case that lists no discriminator values - the
+ /// <c>[* …]</c> catch-all. KNX's <c>KnxProperty</c> ends with one.</summary>
+ private static bool IsWildcardCase(ComplexTypeDefinition cs) =>
+ cs.DiscriminatorValues == null
+ || cs.DiscriminatorValues.Count == 0
+ || cs.DiscriminatorValues.All(v => v == null);
+
+ private void EmitDataIoCase(
+ CodeWriter c, DataIoTypeDefinition dio, ComplexTypeDefinition cs, string helper, DataIoMode mode, bool first, bool last = false)
+ {
+ var serialize = mode != DataIoMode.Parse;
+ var synthetic = new ComplexTypeDefinition { Name = dio.Name };
+ synthetic.Arguments.AddRange(dio.Arguments);
+ synthetic.Fields.AddRange(cs.Fields);
+ var scope = new FieldScope(_protocol, synthetic, serialize);
+ // A dataIo's parser arguments are method parameters in every method,
+ // never properties - keep them camelCase even while serializing.
+ foreach (var a in dio.Arguments)
+ {
+ scope.MarkLocal(a.Name);
+ }
+ var r = new CSharpExpressionRenderer(scope) { StaticHelperClass = helper };
+
+ // The catch-all case compiles to `else { … }` (or just `{ … }` when
+ // it is the only case) so the compiler sees no unreachable fallback.
+ var test = DataIoCaseTest(dio, cs, r);
+ if (last && test == "true" && !first)
+ {
+ c.Line("else");
+ }
+ else if (last && test == "true")
+ {
+ // sole unconditional case: no wrapper needed
+ }
+ else
+ {
+ c.Line($"{(first ? "if" : "else if")} ({test})");
+ }
+ c.Line("{");
+ c.Indent();
+ c.Line($"// {cs.Name}");
+
+ var props = cs.PropertyFields.ToList();
+ var valueField = props.Count == 1 ? props[0] : null;
+ var listField = valueField as ArrayField;
+ // The temporal bodies are a transcription of plc4j's S7 DataItem date
+ // logic - they only fit S7's field layout, so gate them on S7's
+ // discriminator. KNX's KnxDatapoint has DATE / TIME cases too, with a
+ // completely different (segmented, struct-valued) shape.
+ var temporal = TemporalPlcValueCases.Contains(cs.Name)
+ && dio.Arguments.Any(a => a.Name == "dataProtocolId")
+ && props.All(f => f is not ArrayField);
+ var scalar = valueField is not (null or ArrayField) && ScalarPlcValueCases.Contains(cs.Name);
+ // A KnxDatapoint / KnxProperty composite case ([… Struct …]): each
+ // field is a plain simple field or a fixed-count byte array, so it
+ // maps to a PlcStruct keyed by name.
+ var structCase = cs.Name == "Struct"
+ && props.Count > 0
+ && props.All(f => f is SimpleField { Type: SimpleTypeReference }
+ || f is ArrayField
+ {
+ Type: SimpleTypeReference { BaseType: SimpleTypeReference.Base.Byte },
+ LoopType: ArrayField.Loop.Count,
+ });
+
+ switch (mode)
+ {
+ case DataIoMode.Parse when temporal:
+ EmitDataIoTemporalParse(c, cs, r);
+ break;
+ case DataIoMode.Parse when structCase:
+ EmitDataIoStructParse(c, cs, r);
+ break;
+ case DataIoMode.Parse when listField != null:
+ EmitDataIoListParse(c, listField, r);
+ break;
+ case DataIoMode.Parse:
+ foreach (var f in cs.Fields)
+ {
+ EmitDataIoFieldParse(c, f, r);
+ }
+ c.Line($"return {DataIoReturnValue(cs, valueField)};");
+ break;
+
+ case DataIoMode.Serialize when temporal:
+ EmitDataIoTemporalSerialize(c, cs, helper);
+ break;
+ case DataIoMode.Serialize when structCase:
+ EmitDataIoStructSerialize(c, cs, r);
+ break;
+ case DataIoMode.Serialize when listField != null:
+ EmitDataIoListSerialize(c, listField);
+ break;
+ case DataIoMode.Serialize:
+ foreach (var f in cs.Fields)
+ {
+ EmitDataIoFieldSerialize(c, cs, f, valueField, r);
+ }
+ break;
+
+ case DataIoMode.Length when listField != null:
+ c.Line($"lengthInBits += _value.GetLength() * {CSharpTypeMapper.FixedBitLength(listField.Type)};");
+ break;
+ case DataIoMode.Length:
+ foreach (var f in cs.Fields)
+ {
+ var bits = DataIoFieldLength(f, r);
+ if (bits != null)
+ {
+ c.Line($"lengthInBits += {bits};");
+ }
+ }
+ break;
+ }
+
+ c.Outdent();
+ c.Line("}");
+ }
+
+ /// <summary>Parses one TIA date / time case to its <c>Plc…</c> wrapper.
+ /// Mirrors plc4j's <c>DataItem</c>: a factory / segment call, with the S7
+ /// helpers doing S5TIME, the Siemens epoch and BCD.</summary>
+ private void EmitDataIoTemporalParse(CodeWriter c, ComplexTypeDefinition cs, CSharpExpressionRenderer r)
+ {
+ var field = cs.PropertyFields.FirstOrDefault();
+ switch (cs.Name)
+ {
+ case "TIME":
+ c.Line(field is ManualField mf
+ ? $"var milliseconds = {r.Render(RewriteTypeEncoding(mf.ParseExpression, "UTF8"))};"
+ : "var milliseconds = readBuffer.ReadUint(\"milliseconds\", 32);");
+ c.Line("return PlcTIME.OfMilliseconds(milliseconds);");
+ break;
+ case "LTIME":
+ c.Line("var nanoseconds = readBuffer.ReadUlong(\"nanoseconds\", 64);");
+ c.Line("return PlcLTIME.OfNanoseconds(nanoseconds);");
+ break;
+ case "DATE":
+ // Unlike TIME above, DATE has no non-ManualField fallback:
+ // every mspec DATE dataIo case is modeled as manual.
+ c.Line($"var daysSinceEpoch = {r.Render(RewriteTypeEncoding(((ManualField) field!).ParseExpression, "UTF8"))};");
+ c.Line("return PlcDATE.OfDaysSinceEpoch(daysSinceEpoch);");
+ break;
+ case "TIME_OF_DAY":
+ c.Line("var millisecondsSinceMidnight = readBuffer.ReadUint(\"millisecondsSinceMidnight\", 32);");
+ c.Line("return PlcTIME_OF_DAY.OfMillisecondsSinceMidnight(millisecondsSinceMidnight);");
+ break;
+ case "LTIME_OF_DAY":
+ c.Line("var nanosecondsSinceMidnight = readBuffer.ReadUlong(\"nanosecondsSinceMidnight\", 64);");
+ c.Line("return PlcLTIME_OF_DAY.OfNanosecondsSinceMidnight(nanosecondsSinceMidnight);");
+ break;
+ case "DATE_AND_TIME":
+ c.Line($"var year = {r.StaticHelperClass}.ParseSiemensYear(readBuffer);");
+ foreach (var f in new[] { "month", "day", "hour", "minutes", "seconds" })
+ {
+ c.Line($"var {f} = {r.StaticHelperClass}.BcdToBin(readBuffer.ReadByte(\"{f}\", 8));");
+ }
+ c.Line($"var millisecondsOfSecond = {r.StaticHelperClass}.BcdToBin12(readBuffer.ReadUshort(\"millisecondsOfSecond\", 12));");
+ c.Line("readBuffer.ReadByte(\"dayOfWeek\", 4);");
+ c.Line("return PlcDATE_AND_TIME.OfSegments(year, month, day, hour, minutes, seconds, millisecondsOfSecond * 1000000);");
+ break;
+ case "DATE_AND_LTIME" when cs.PropertyFields.Count() == 1:
+ c.Line("var nanosecondsSinceEpoch = readBuffer.ReadUlong(\"nanosecondsSinceEpoch\", 64);");
+ c.Line("return PlcDATE_AND_LTIME.OfNanosecondsSinceEpoch(nanosecondsSinceEpoch);");
+ break;
+ case "DATE_AND_LTIME": // DTL - plain-binary segments, widths from the mspec
+ foreach (var f in cs.Fields.OfType<SimpleField>())
+ {
+ var read = DataIoSimpleRead(f, r);
+ // dayOfWeek is recomputed from the DateTime on serialize.
+ c.Line(f.Name == "dayOfWeek" ? $"{read};" : $"var {Camel(f.Name)} = {read};");
+ }
+ c.Line("return PlcDATE_AND_LTIME.OfSegments(year, month, day, hour, minutes, seconds, nanosecondsOfSecond);");
+ break;
+ }
+ }
+
+ private void EmitDataIoTemporalSerialize(CodeWriter c, ComplexTypeDefinition cs, string helper)
+ {
+ var field = cs.PropertyFields.FirstOrDefault();
+ // S7 DT / DTL number the week Sunday=1 .. Saturday=7 (per s7.mspec's
+ // own DTL comment and the Siemens DTL doc). This is a DELIBERATE
+ // departure from plc4j, whose DataItem writes ISO-8601
+ // Monday=1 .. Sunday=7 (getDayOfWeek().getValue()) - the two never
+ // agree on any weekday. Both discard the field on parse (the
+ // DateTime carries its own weekday), so it only shows up when a
+ // device consumes the serialized frame, or when diffing plc4net vs
+ // plc4j serializer output.
+ const string dow = "(byte) ((int) _dt.DayOfWeek + 1)";
+ switch (cs.Name)
+ {
+ case "TIME" when field is ManualField:
+ c.Line($"{helper}.SerializeS5Time(writeBuffer, _value);");
+ break;
+ case "TIME":
+ c.Line("writeBuffer.WriteUint(\"milliseconds\", 32, (uint) _value.GetDuration().TotalMilliseconds);");
+ break;
+ case "LTIME":
+ c.Line("writeBuffer.WriteUlong(\"nanoseconds\", 64, ((PlcLTIME) _value).GetNanoseconds());");
+ break;
+ case "DATE":
+ c.Line($"{helper}.SerializeTiaDate(writeBuffer, _value);");
+ break;
+ case "TIME_OF_DAY":
+ c.Line("writeBuffer.WriteUint(\"millisecondsSinceMidnight\", 32, (uint) _value.GetTime().ToTimeSpan().TotalMilliseconds);");
+ break;
+ case "LTIME_OF_DAY":
+ c.Line("writeBuffer.WriteUlong(\"nanosecondsSinceMidnight\", 64, ((PlcLTIME_OF_DAY) _value).GetNanosecondsSinceMidnight());");
+ break;
+ case "DATE_AND_TIME":
+ c.Line("var _dt = _value.GetDateTime();");
+ c.Line($"{helper}.SerializeSiemensYear(writeBuffer, _value);");
+ c.Line($"writeBuffer.WriteByte(\"month\", 8, {helper}.BinToBcd(_dt.Month));");
+ c.Line($"writeBuffer.WriteByte(\"day\", 8, {helper}.BinToBcd(_dt.Day));");
+ c.Line($"writeBuffer.WriteByte(\"hour\", 8, {helper}.BinToBcd(_dt.Hour));");
+ c.Line($"writeBuffer.WriteByte(\"minutes\", 8, {helper}.BinToBcd(_dt.Minute));");
+ c.Line($"writeBuffer.WriteByte(\"seconds\", 8, {helper}.BinToBcd(_dt.Second));");
+ c.Line($"writeBuffer.WriteUshort(\"millisecondsOfSecond\", 12, {helper}.BinToBcd12(_dt.Millisecond));");
+ c.Line($"writeBuffer.WriteByte(\"dayOfWeek\", 4, {dow});");
+ break;
+ case "DATE_AND_LTIME" when cs.PropertyFields.Count() == 1:
+ c.Line("writeBuffer.WriteUlong(\"nanosecondsSinceEpoch\", 64, ((PlcDATE_AND_LTIME) _value).GetNanosecondsSinceEpoch());");
+ break;
+ case "DATE_AND_LTIME":
+ c.Line("var _dtl = (PlcDATE_AND_LTIME) _value;");
+ c.Line("var _dt = _dtl.GetDateTime();");
+ c.Line("writeBuffer.WriteUshort(\"year\", 16, (ushort) _dt.Year);");
+ c.Line("writeBuffer.WriteByte(\"month\", 8, (byte) _dt.Month);");
+ c.Line("writeBuffer.WriteByte(\"day\", 8, (byte) _dt.Day);");
+ c.Line($"writeBuffer.WriteByte(\"dayOfWeek\", 8, {dow});");
+ c.Line("writeBuffer.WriteByte(\"hour\", 8, (byte) _dt.Hour);");
+ c.Line("writeBuffer.WriteByte(\"minutes\", 8, (byte) _dt.Minute);");
+ c.Line("writeBuffer.WriteByte(\"seconds\", 8, (byte) _dt.Second);");
+ c.Line("writeBuffer.WriteUint(\"nanosecondsOfSecond\", 32, _dtl.GetNanosecondsOfSecond());");
+ break;
+ }
+ }
+
+ /// <summary>A <c>numberOfValues > 1</c> dataIo case (Modbus's
+ /// <c>DataItem</c> List cases) - a counted array of one primitive,
+ /// wrapped element-by-element into a <see cref="PlcList"/>.</summary>
+ private void EmitDataIoListParse(CodeWriter c, ArrayField af, CSharpExpressionRenderer r)
+ {
+ // An array field's element type is always given in the grammar.
+ var element = (SimpleTypeReference) af.Type!;
+ var wrap = DataIoPlcWrapper(element);
+ c.Line("var value = new System.Collections.Generic.List<IPlcValue>();");
+ c.Line($"var _valueCnt = (int) ({r.Render(af.LoopExpression)});");
+ c.Line("for (var _i = 0; _i < _valueCnt; _i++)");
+ c.Line("{");
+ c.Indent();
+ if (element.BaseType is SimpleTypeReference.Base.String)
+ {
+ c.Line($"var _e = readBuffer.ReadString(\"value\", {element.SizeInBits}, {CSharpTypeMapper.EncodingExpr(element.Encoding)});");
+ c.Line($"value.Add(new {wrap}(_e.Length > 0 ? _e[0] : '\\0'));");
+ }
+ else
+ {
+ c.Line($"value.Add(new {wrap}({CSharpTypeMapper.ReadCall(element, "value")}));");
+ }
+
+ c.Outdent();
+ c.Line("}");
+ c.Line("return new PlcList(value);");
+ }
+
+ private void EmitDataIoListSerialize(CodeWriter c, ArrayField af)
+ {
+ // An array field's element type is always given in the grammar.
+ var element = (SimpleTypeReference) af.Type!;
+ c.Line("foreach (var _e in _value.GetList())");
+ c.Line("{");
+ c.Indent();
+ var item = element.BaseType is SimpleTypeReference.Base.String
+ ? "_e.GetString()"
+ : $"({CSharpTypeMapper.SimpleCSharpType(element)}) _e.{PlcValueGetter(element)}";
+ c.Line(element.BaseType is SimpleTypeReference.Base.String
+ ? $"writeBuffer.WriteString(\"value\", {element.SizeInBits}, \"{element.Encoding ?? "UTF8"}\", {item});"
+ : $"{CSharpTypeMapper.WriteCall(element, "value", item)};");
+ c.Outdent();
+ c.Line("}");
+ }
+
+ // ── dataIo struct cases (KnxDatapoint composites) ───────
+
+ /// <summary>Parses a <c>[… Struct …]</c> case: read each field, then
+ /// bundle the simple ones into a <c>PlcStruct</c> keyed by field name.
+ /// Mirrors plc4j's KNX <c>KnxDatapoint</c> struct branch.</summary>
+ private void EmitDataIoStructParse(CodeWriter c, ComplexTypeDefinition cs, CSharpExpressionRenderer r)
+ {
+ foreach (var f in cs.Fields)
+ {
+ switch (f)
+ {
+ case ArrayField af:
+ c.Line($"var {Camel(af.Name)} = readBuffer.ReadByteArray(\"{af.Name}\", (int) ({r.Render(af.LoopExpression)}) * 8);");
+ break;
+ default:
+ EmitDataIoFieldParse(c, f, r);
+ break;
+ }
+ }
+ c.Line("var _map = new System.Collections.Generic.Dictionary<string, IPlcValue>();");
+ foreach (var f in cs.Fields)
+ {
+ switch (f)
+ {
+ case ArrayField af:
+ c.Line($"_map[\"{af.Name}\"] = new PlcRawByteArray({Camel(af.Name)});");
+ break;
+ case SimpleField sf when sf.Type is SimpleTypeReference st:
+ c.Line($"_map[\"{sf.Name}\"] = new {DataIoPlcWrapper(st)}(({CSharpTypeMapper.SimpleCSharpType(st)}) {Camel(sf.Name)});");
+ break;
+ }
+ }
+ c.Line("return new PlcStruct(_map);");
+ }
+
+ private void EmitDataIoStructSerialize(CodeWriter c, ComplexTypeDefinition cs, CSharpExpressionRenderer r)
+ {
+ foreach (var f in cs.Fields)
+ {
+ switch (f)
+ {
+ case ReservedField rf:
+ c.Line($"{CSharpTypeMapper.WriteCall(rf.Type, "reserved", WithType(r.Render(rf.ReferenceValue), rf.Type))};");
+ break;
+ case ArrayField af:
+ c.Line($"writeBuffer.WriteByteArray(\"{af.Name}\", _value.GetValue(\"{af.Name}\")!.GetRaw());");
+ break;
+ case SimpleField sf when sf.Type is SimpleTypeReference st:
+ var v = $"({CSharpTypeMapper.SimpleCSharpType(st)}) _value.GetValue(\"{sf.Name}\")!.{PlcValueGetter(st)}";
+ c.Line($"{CSharpTypeMapper.WriteCall(sf.Type, sf.Name, v)};");
+ break;
+ }
+ }
+ }
+
+ /// <summary>The <c>Plc…</c> wrapper for one element of a dataIo list,
+ /// keyed on the wire type (<c>bit</c> → <c>PlcBOOL</c>, <c>int 16</c> →
+ /// <c>PlcINT</c>, <c>string 16</c> → <c>PlcWCHAR</c>, …).</summary>
+ private static string DataIoPlcWrapper(SimpleTypeReference s) => s.BaseType switch
+ {
+ SimpleTypeReference.Base.Bit => "PlcBOOL",
+ SimpleTypeReference.Base.Byte => "PlcUSINT",
+ SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? "PlcUSINT"
+ : s.SizeInBits <= 16 ? "PlcUINT"
+ : s.SizeInBits <= 32 ? "PlcUDINT" : "PlcULINT",
+ SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? "PlcSINT"
+ : s.SizeInBits <= 16 ? "PlcINT"
+ : s.SizeInBits <= 32 ? "PlcDINT" : "PlcLINT",
+ SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+ s.SizeInBits <= 32 ? "PlcREAL" : "PlcLREAL",
+ SimpleTypeReference.Base.String or SimpleTypeReference.Base.VString =>
+ s.SizeInBits <= 8 ? "PlcCHAR" : "PlcWCHAR",
+ _ => "PlcNULL",
+ };
+
+ private string DataIoCaseTest(
+ DataIoTypeDefinition dio, ComplexTypeDefinition cs, CSharpExpressionRenderer r)
+ {
+ // Every dataIo carries a typeSwitch; see DataIoTypeDefinition.TypeSwitch.
+ var discs = dio.TypeSwitch!.Discriminators.OfType<VariableLiteral>().ToList();
+ var values = cs.DiscriminatorValues ?? Array.Empty<Term>();
+ var conds = new List<string>();
+ for (var i = 0; i < discs.Count && i < values.Count; i++)
+ {
+ if (values[i] == null)
+ {
+ continue;
+ }
+ var argType = dio.Arguments.FirstOrDefault(a => a.Name == discs[i].Name)?.Type;
+ conds.Add($"Equals({Camel(discs[i].Name)}, {RenderDiscriminatorValue(values[i], argType, r)})");
+ }
+ return conds.Count == 0 ? "true" : string.Join(" && ", conds);
+ }
+
+ private void EmitDataIoFieldParse(CodeWriter c, Field f, CSharpExpressionRenderer r)
+ {
+ switch (f)
+ {
+ case ReservedField rf:
+ c.Line("{");
+ c.Indent();
+ c.Line($"var reserved = {CSharpTypeMapper.ReadCall(rf.Type, "reserved")};");
+ c.Line($"if (!Equals(reserved, {WithType(r.Render(rf.ReferenceValue), rf.Type)})) {{ /* mspec reserved: value differs from the spec default */ }}");
+ c.Outdent();
+ c.Line("}");
+ break;
+
+ case ManualField mf:
+ var parse = RewriteTypeEncoding(mf.ParseExpression, FieldEncoding(mf));
+ c.Line($"var {Camel(mf.Name)} = ({CSharpTypeMapper.CSharpType(mf.Type)}) ({r.Render(parse)});");
+ break;
+
+ case SimpleField sf:
+ c.Line($"var {Camel(sf.Name)} = {DataIoSimpleRead(sf, r)};");
+ break;
+ }
+ }
+
+ private void EmitDataIoFieldSerialize(
+ CodeWriter c, ComplexTypeDefinition cs, Field f, Field? valueField, CSharpExpressionRenderer r)
+ {
+ switch (f)
+ {
+ case ReservedField rf:
+ c.Line($"{CSharpTypeMapper.WriteCall(rf.Type, "reserved", WithType(r.Render(rf.ReferenceValue), rf.Type))};");
+ break;
+
+ case ManualField mf:
+ c.Line($"{r.Render(RewriteTypeEncoding(mf.SerializeExpression, FieldEncoding(mf)))};");
+ break;
+
+ case SimpleField sf when sf.Type is SimpleTypeReference st:
+ var getter = st.BaseType is SimpleTypeReference.Base.String or SimpleTypeReference.Base.VString
+ ? "_value.GetString()"
+ : $"({CSharpTypeMapper.SimpleCSharpType(st)}) _value.{PlcValueGetter(st)}";
+ if (st.BaseType is SimpleTypeReference.Base.String)
+ {
+ c.Line($"writeBuffer.WriteString(\"{sf.Name}\", {st.SizeInBits}, \"{FieldEncoding(sf)}\", {getter});");
+ }
+ else if (st.BaseType is SimpleTypeReference.Base.VString && st.LengthExpression is { } vlen)
+ {
+ c.Line($"writeBuffer.WriteString(\"{sf.Name}\", (int) ({r.Render(RewriteTypeEncoding(vlen, FieldEncoding(sf)))}), \"{FieldEncoding(sf)}\", {getter});");
+ }
+ else
+ {
+ c.Line($"{CSharpTypeMapper.WriteCall(sf.Type, sf.Name, getter)};");
+ }
+ break;
+ }
+ }
+
+ private string? DataIoFieldLength(Field f, CSharpExpressionRenderer r)
+ {
+ switch (f)
+ {
+ case SimpleField when f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } vlen }:
+ // vstring: the length expression is already in bits (`stringLength * 8`).
+ return $"({r.Render(RewriteTypeEncoding(vlen, FieldEncoding((SimpleField) f)))})";
+ case ReservedField or SimpleField:
+ var bits = CSharpTypeMapper.FixedBitLength(f.Type);
+ return bits >= 0 ? bits.ToString() : null;
+ case ArrayField { LoopType: ArrayField.Loop.Count } af:
+ // a fixed-count byte array in a struct case
+ return $"(({r.Render(af.LoopExpression)}) * 8)";
+ case ManualField mf:
+ // A dataIo manual length expression is already in bits
+ // (plc4j's DataItem: `(stringLength * 8) + 16`).
+ return $"({r.Render(RewriteTypeEncoding(mf.LengthExpression, FieldEncoding(mf)))})";
+ default:
+ return null;
+ }
+ }
+
+ /// <summary>The <c>return new Plc…(value)</c> for a scalar
+ /// <c>DataItem</c> case. The case name is the plc4net value type.</summary>
+ private string DataIoReturnValue(ComplexTypeDefinition cs, Field? valueField)
+ {
+ // Only reached for the scalar branch, gated on valueField being
+ // exactly one non-array property field (see the `scalar` check
+ // in EmitDataIoCase).
+ if (valueField == null)
+ {
+ throw new InvalidOperationException($"DataIo case '{cs.Name}' has no scalar value field.");
+ }
+ var v = Camel(valueField.Name);
+ switch (cs.Name)
+ {
+ case "CHAR":
+ case "WCHAR":
+ // read as a 1- or 2-byte string; Plc(W)CHAR wraps a char.
+ return $"new Plc{cs.Name}({v}.Length > 0 ? {v}[0] : '\\0')";
+ default:
+ // BOOL / BYTE / … / STRING / WSTRING - Plc<caseName>(value).
+ return $"new Plc{cs.Name}({v})";
+ }
+ }
+
+ private string DataIoSimpleRead(SimpleField sf, CSharpExpressionRenderer r)
+ {
+ switch (sf.Type)
+ {
+ case SimpleTypeReference { BaseType: SimpleTypeReference.Base.String } s:
+ return $"readBuffer.ReadString(\"{sf.Name}\", {s.SizeInBits}, {CSharpTypeMapper.EncodingExpr(FieldEncoding(sf))})";
+ // vstring: the bit length is a run-time expression (modbus DataItem's
+ // `vstring 'stringLength * 8'`), already in bits.
+ case SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } len }:
+ return $"readBuffer.ReadString(\"{sf.Name}\", (int) ({r.Render(RewriteTypeEncoding(len, FieldEncoding(sf)))}), {CSharpTypeMapper.EncodingExpr(FieldEncoding(sf))})";
+ default:
+ return CSharpTypeMapper.ReadCall(sf.Type, sf.Name);
+ }
+ }
+
+ /// <summary>The <c>IPlcValue</c> accessor that yields a value of this
+ /// wire type, for a dataIo serialize.</summary>
+ private static string PlcValueGetter(SimpleTypeReference s) => s.BaseType switch
+ {
+ SimpleTypeReference.Base.Bit => "GetBool()",
+ SimpleTypeReference.Base.Byte => "GetByte()",
+ SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? "GetByte()"
+ : s.SizeInBits <= 16 ? "GetUshort()"
+ : s.SizeInBits <= 32 ? "GetUint()" : "GetUlong()",
+ SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? "GetSbyte()"
+ : s.SizeInBits <= 16 ? "GetShort()"
+ : s.SizeInBits <= 32 ? "GetInt()" : "GetLong()",
+ SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+ s.SizeInBits <= 32 ? "GetFloat()" : "GetDouble()",
+ _ => "GetString()",
+ };
+
+ /// <summary>The string encoding for a dataIo value field: the
+ /// <c>encoding</c> / <c>stringEncoding</c> attribute (s7.mspec spells it
+ /// the first way, modbus.mspec the second), or the type's own resolved
+ /// encoding, defaulting to UTF-8.</summary>
+ private static string FieldEncoding(Field f)
+ {
+ if (f.Type is SimpleTypeReference { Encoding: { } typeEncoding })
+ {
+ return typeEncoding;
+ }
+ foreach (var key in new[] { "encoding", "stringEncoding" })
+ {
+ if (f.Attributes.TryGetValue(key, out var t) && t is StringLiteral s)
+ {
+ return s.Value;
+ }
+ }
+ return "UTF8";
+ }
+
+ /// <summary>Inlines <c>_type.encoding</c> (a dataIo case's own encoding
+ /// attribute) as a string literal, the way plc4j's <c>DataItem</c> does -
+ /// a dataIo has no instance for <c>_type</c> to resolve against.</summary>
+ private static Term RewriteTypeEncoding(Term t, string encoding)
+ {
+ switch (t)
+ {
+ case VariableLiteral v when v.Name == "_type"
+ && v.Child is { Name: "encoding", Child: null }:
+ return new StringLiteral(encoding);
+ case VariableLiteral v:
+ return new VariableLiteral(
+ v.Name,
+ v.Args?.Select(a => RewriteTypeEncoding(a, encoding)).ToList(),
+ v.Index?.Select(i => RewriteTypeEncoding(i, encoding)).ToList(),
+ v.Child);
+ case BinaryExpression b:
+ return new BinaryExpression(
+ RewriteTypeEncoding(b.Left, encoding), b.Operator, RewriteTypeEncoding(b.Right, encoding));
+ case UnaryExpression u:
+ return new UnaryExpression(u.Operator, RewriteTypeEncoding(u.Operand, encoding));
+ case TernaryExpression x:
+ return new TernaryExpression(
+ RewriteTypeEncoding(x.Condition, encoding),
+ RewriteTypeEncoding(x.WhenTrue, encoding),
+ RewriteTypeEncoding(x.WhenFalse, encoding));
+ default:
+ return t;
+ }
+ }
+
+ // ── static helper (STATIC_CALL targets + array sizing) ──
+
+ private string EmitStaticHelper()
+ {
+ // name -> the checksum/field C# type the call has to return
+ var staticCalls = new SortedDictionary<string, string>(StringComparer.Ordinal);
+ CollectStaticCalls(staticCalls);
+
+ var cls = $"{Pascal(_protocolName)}StaticHelper";
+ var c = new CodeWriter();
+ Header(c);
+ c.Line("using System;");
+ c.Line("using System.Collections.Generic;");
+ c.Line("using System.Linq;");
+ c.Line("using org.apache.plc4net.spi.drivers;");
+ c.Line("using org.apache.plc4net.spi.generation;");
+ c.Line();
+ c.Line($"namespace {_namespace}.model");
+ c.Line("{");
+ c.Indent();
+ c.Line("/// <summary>");
+ c.Line("/// Shared helpers required by the generated protocol model.");
+ c.Line("/// </summary>");
+ c.Line($"public static partial class {cls}");
+ c.Line("{");
+ c.Indent();
+ c.Line("/// <summary>Serialized byte length of a sequence of messages.</summary>");
+ c.Line("public static int ArraySizeInBytes<T>(IEnumerable<T> items) where T : IMessage");
+ c.Indent();
+ c.Line("=> items?.Sum(i => i.GetLengthInBytes()) ?? 0;");
+ c.Outdent();
+ c.Line();
+ c.Line("public static byte BcdToBin(byte value) => (byte) ((value >> 4) * 10 + (value & 0x0F));");
+ c.Line("public static ushort BcdToBin12(ushort value) => (ushort) (((value >> 8) & 0x0F) * 100 + ((value >> 4) & 0x0F) * 10 + (value & 0x0F));");
+ c.Line("public static byte BinToBcd(int value) => (byte) ((value / 10) * 16 + (value % 10));");
+ c.Line("public static ushort BinToBcd12(int value) => (ushort) ((value / 100) * 256 + ((value / 10) % 10) * 16 + (value % 10));");
+ foreach (var (name, retType) in staticCalls)
+ {
+ c.Line();
+ EmitStaticCall(c, name, retType);
+ }
+ c.Outdent();
+ c.Line("}");
+ c.Outdent();
+ c.Line("}");
+ return c.ToString();
+ }
+
+ private static string RenderEnumUnderlyingValue(EnumValue value, CSharpExpressionRenderer r) =>
+ value.Value == null ? value.Name : r.Render(value.Value);
+
+ private void EmitStaticCall(CodeWriter c, string name, string retType)
+ {
+ if (_protocolName.Equals("modbus", StringComparison.OrdinalIgnoreCase)
+ && name == "AsciiLrcCheck")
+ {
+ c.Line("public static ushort AsciiLrcCheck(byte address, IMessage pdu)");
+ c.Line("{");
+ c.Indent();
+ c.Line("var buffer = new WriteBuffer();");
+ c.Line("buffer.WriteByte(\"address\", 8, address);");
+ c.Line("pdu.Serialize(buffer);");
+ c.Line("var sum = 0;");
+ c.Line("foreach (var b in buffer.GetBytes()) sum = (sum + b) & 0xFF;");
+ c.Line("return (ushort) ((-sum) & 0xFF);");
+ c.Outdent();
+ c.Line("}");
+ return;
+ }
+ if (_protocolName.Equals("modbus", StringComparison.OrdinalIgnoreCase)
+ && name == "RtuCrcCheck")
+ {
+ c.Line("public static ushort RtuCrcCheck(byte address, IMessage pdu)");
+ c.Line("{");
+ c.Indent();
+ c.Line("var buffer = new WriteBuffer();");
+ c.Line("buffer.WriteByte(\"address\", 8, address);");
+ c.Line("pdu.Serialize(buffer);");
+ c.Line("ushort crc = 0xFFFF;");
+ c.Line("foreach (var b in buffer.GetBytes())");
+ c.Line("{");
+ c.Indent();
+ c.Line("crc ^= b;");
+ c.Line("for (var bit = 0; bit < 8; bit++) crc = (crc & 1) != 0 ? (ushort) ((crc >> 1) ^ 0xA001) : (ushort) (crc >> 1);");
+ c.Outdent();
+ c.Line("}");
+ // ReadBuffer/WriteBuffer are MSB-first. Modbus RTU transports
+ // the low CRC byte first, so return the swapped 16-bit value.
+ c.Line("return (ushort) ((crc << 8) | (crc >> 8));");
+ c.Outdent();
+ c.Line("}");
+ return;
+ }
+ throw new InvalidOperationException($"No C# implementation is registered for STATIC_CALL '{name}' ({retType}).");
+ }
+
+ private void CollectStaticCalls(IDictionary<string, string> sink)
+ {
+ foreach (var type in _protocol.Types)
+ {
+ foreach (var f in type.Fields)
+ {
+ var retType = f.Type != null ? CSharpTypeMapper.CSharpType(f.Type) : "object";
+ foreach (var term in FieldTerms(f))
+ {
+ WalkStaticCalls(term, retType, sink);
+ }
+ }
+ }
+ foreach (var dio in _protocol.DataIos)
+ {
+ foreach (var c in dio.Cases)
+ {
+ foreach (var f in c.Fields)
+ {
+ var retType = f.Type != null ? CSharpTypeMapper.CSharpType(f.Type) : "object";
+ foreach (var term in FieldTerms(f))
+ {
+ WalkStaticCalls(term, retType, sink);
+ }
+ }
+ }
+ }
+
+ }
+
+ private bool IsGeneratedStaticCall(string name) =>
+ _protocolName.Equals("modbus", StringComparison.OrdinalIgnoreCase)
+ && (name == "RtuCrcCheck" || name == "AsciiLrcCheck");
+
+ private static bool IsSupportedDataIoCase(DataIoTypeDefinition dio, ComplexTypeDefinition cs)
+ {
+ var props = cs.PropertyFields.ToList();
+ var valueField = props.Count == 1 ? props[0] : null;
+ var listField = valueField as ArrayField;
+ var temporal = TemporalPlcValueCases.Contains(cs.Name)
+ && dio.Arguments.Any(a => a.Name == "dataProtocolId")
+ && props.All(f => f is not ArrayField);
+ var scalar = valueField is not (null or ArrayField) && ScalarPlcValueCases.Contains(cs.Name);
+ var structCase = cs.Name == "Struct"
+ && props.Count > 0
+ && props.All(f => f is SimpleField { Type: SimpleTypeReference }
+ || f is ArrayField
+ {
+ Type: SimpleTypeReference { BaseType: SimpleTypeReference.Base.Byte },
+ LoopType: ArrayField.Loop.Count,
+ });
+ return temporal || scalar || structCase
+ || listField?.Type is SimpleTypeReference && listField.LoopType == ArrayField.Loop.Count;
+ }
+
+ private static void WalkStaticCalls(Term t, string retType, IDictionary<string, string> sink)
+ {
+ switch (t)
+ {
+ case VariableLiteral { Name: "STATIC_CALL" } v
+ when v.Args?.Count >= 1 && v.Args[0] is StringLiteral s:
+ sink[Pascal(s.Value)] = retType;
+ break;
+ case VariableLiteral v:
+ foreach (var a in v.Args ?? Enumerable.Empty<Term>())
+ {
+ WalkStaticCalls(a, retType, sink);
+ }
+ if (v.Child != null)
+ {
+ WalkStaticCalls(v.Child, retType, sink);
+ }
+ break;
+ case BinaryExpression b:
+ WalkStaticCalls(b.Left, retType, sink);
+ WalkStaticCalls(b.Right, retType, sink);
+ break;
+ case UnaryExpression u:
+ WalkStaticCalls(u.Operand, retType, sink);
+ break;
+ case TernaryExpression tt:
+ WalkStaticCalls(tt.Condition, retType, sink);
+ WalkStaticCalls(tt.WhenTrue, retType, sink);
+ WalkStaticCalls(tt.WhenFalse, retType, sink);
+ break;
+ }
+ }
+
+ private static IEnumerable<Term> FieldTerms(Field f)
+ {
+ switch (f)
+ {
+ case ImplicitField i: yield return i.SerializeExpression; break;
+ case ChecksumField ck: yield return ck.ChecksumExpression; break;
+ case ArrayField a: yield return a.LoopExpression; break;
+ case VirtualField v: yield return v.ValueExpression; break;
+ case ConstField c2: yield return c2.ReferenceValue; break;
+ case ReservedField r: yield return r.ReferenceValue; break;
+ case OptionalField o when o.Condition != null: yield return o.Condition; break;
+ case ManualField m:
+ yield return m.ParseExpression;
+ yield return m.SerializeExpression;
+ yield return m.LengthExpression;
+ break;
+ case PaddingField p:
+ yield return p.PaddingValue;
+ yield return p.TimesPadding;
+ break;
+ }
+ }
+
+ // ── helpers ─────────────────────────────────────────────
+
+ private string ReadFieldValue(Field f, CSharpExpressionRenderer r) =>
+ f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } len }
+ ? $"readBuffer.ReadString(\"{f.Name}\", (int) ({r.Render(len)}), {CSharpTypeMapper.EncodingExpr(VStringEncoding(f))})"
+ : CSharpTypeMapper.ReadCall(f.Type, f.Name, ComplexArgs(f.Type, r));
+
+ private static string VStringEncoding(Field f) =>
+ (f.Type as SimpleTypeReference)?.Encoding
+ ?? (f.Attributes.TryGetValue("encoding", out var e) && e is StringLiteral s ? s.Value : "UTF8");
+
+ /// <summary>Reads an <c>enum</c> field. With a key accessor
+ /// (<c>[enum TransportSize transportSize code]</c>) the wire value is
+ /// the key, mapped back through the generated reverse lookup.</summary>
+ private string EnumFieldRead(EnumField ef, CSharpExpressionRenderer r)
+ {
+ if (ef.Type is EnumTypeReference et && ef.KeyAccessor != null)
+ {
+ var baseRead = CSharpTypeMapper.ReadCall(et.BaseType, ef.Name);
+ return $"({et.Name}Extensions.FirstEnumForField{Pascal(ef.KeyAccessor)}({baseRead}) "
+ + $"?? throw new ParseException(\"unrecognised {et.Name} {ef.KeyAccessor} on the wire\"))";
+ }
+ return ReadFieldValue(ef, r);
+ }
+
+ private string EnumFieldWrite(EnumField ef)
+ {
+ if (ef.Type is EnumTypeReference et && ef.KeyAccessor != null)
+ {
+ var baseCs = CSharpTypeMapper.SimpleCSharpType(et.BaseType);
+ var val = $"({baseCs}) {Pascal(ef.Name)}.Get{Pascal(ef.KeyAccessor)}()";
+ return CSharpTypeMapper.WriteCall(et.BaseType, ef.Name, val);
+ }
+ return CSharpTypeMapper.WriteCall(ef.Type, ef.Name, Pascal(ef.Name));
+ }
+
+ /// <summary><see cref="ComplexArgs"/> plus the <c>_lastItem</c> flag when
+ /// the array loop knows it.</summary>
+ private string ElementArgs(TypeReference type, CSharpExpressionRenderer r, string? lastItem)
+ {
+ var args = ComplexArgs(type, r);
+ if (lastItem == null)
+ {
+ return args;
+ }
+ return args.Length == 0 ? lastItem : $"{args}, {lastItem}";
+ }
+
+ private string ComplexArgs(TypeReference? type, CSharpExpressionRenderer r)
+ {
+ if (type is not ComplexTypeReference c)
+ {
+ return "";
+ }
+ // Cast each argument to the callee's declared parameter type: an
+ // mspec count / position expression is `int`, but a `uint 8`
+ // parameter is `byte`.
+ var callee = _protocol.FindType(c.Name);
+ return string.Join(", ", c.Arguments.Select((a, i) =>
+ {
+ var rendered = r.Render(a);
+ var pType = callee != null && i < callee.Arguments.Count
+ ? callee.Arguments[i].Type
+ : null;
+ return pType != null ? WithType(rendered, pType) : rendered;
+ }));
+ }
+
+ // An array field's element type is always given in the grammar.
+ private static TypeReference ElementType(ArrayField af) => af.Type!;
+
+ private string PropertyType(Field f)
+ {
+ if (f is ArrayField af)
+ {
+ var (t, _) = CSharpTypeMapper.ArrayType(af.Type);
+ return t;
+ }
+ var t2 = CSharpTypeMapper.CSharpType(f.Type);
+ // An optional field is absent when its condition is false.
+ return f is OptionalField ? t2 + "?" : t2;
+ }
+
+ /// <summary>One switch discriminator, resolved against the parent.</summary>
+ private sealed class Discriminator
+ {
+ /// <summary>The C# accessor name a child would override, or null
+ /// when the value already lives in a property or is a dotted path
+ /// (both cases: no accessor is generated).</summary>
+ public string? AccessorName { get; init; }
+
+ public required TypeReference Type { get; init; }
+
+ /// <summary>The C# that reads the value during dispatch - a local
+ /// (a discriminator or simple field the parent already read), a
+ /// parser-argument name, or a rendered dotted path.</summary>
+ public required string DispatchExpr { get; init; }
+ }
+
+ private List<Discriminator> DiscriminatorList(
+ ComplexTypeDefinition parent, CSharpExpressionRenderer? render = null)
+ {
+ var ts = parent.TypeSwitch;
+ var result = new List<Discriminator>();
+ if (ts == null)
+ {
+ return result;
+ }
+
+ foreach (var term in ts.Discriminators.OfType<VariableLiteral>())
+ {
+ if (term.Child != null || term.IsCall)
+ {
+ // e.g. parameter.parameterType - used for dispatch, never an accessor.
+ var r = render ?? new CSharpExpressionRenderer(new FieldScope(_protocol, parent));
+ result.Add(new Discriminator
+ {
+ AccessorName = null,
+ Type = new SimpleTypeReference { BaseType = SimpleTypeReference.Base.UInt, SizeInBits = 8 },
+ DispatchExpr = r.Render(term),
+ });
+ continue;
+ }
+
+ var name = term.Name;
+ var field = parent.Fields.FirstOrDefault(x => x.Name == name);
+ var arg = parent.Arguments.FirstOrDefault(a => a.Name == name);
+ var type = field?.Type ?? arg?.Type
+ ?? new SimpleTypeReference { BaseType = SimpleTypeReference.Base.UInt, SizeInBits = 8 };
+
+ result.Add(new Discriminator
+ {
+ // A property field carries the value itself; only a bare
+ // `discriminator` / parser-arg gets an accessor.
+ AccessorName = field is { IsProperty: true } or VirtualField ? null : name,
+ Type = type,
+ DispatchExpr = Camel(name),
+ });
+ }
+ return result;
+ }
+
+ private IEnumerable<string> ParserArgDiscriminatorNames(ComplexTypeDefinition parent) =>
+ DiscriminatorList(parent)
+ .Where(d => d.AccessorName != null
+ && parent.Arguments.Any(a => a.Name == d.AccessorName)
+ && parent.Fields.All(f => f.Name != d.AccessorName))
+ .Select(d => d.AccessorName!);
+
+ private IReadOnlyList<Field> ParentPrefixFields(ComplexTypeDefinition child) =>
+ child.ParentName != null && _protocol.FindType(child.ParentName) is { } p
+ ? p.PrefixPropertyFields.ToList()
+ : Array.Empty<Field>();
+
+ private IReadOnlyList<Field> ParentSuffixFields(ComplexTypeDefinition child) =>
+ child.ParentName != null && _protocol.FindType(child.ParentName) is { } p
+ ? p.SuffixPropertyFields.ToList()
+ : Array.Empty<Field>();
+
+ /// <summary>The parent's pre-typeSwitch value-carrying fields that this
+ /// child actually names in one of its own or trailing-field
+ /// expressions - the child's <c>StaticParse</c> takes these as extra
+ /// parameters so those expressions resolve. Filtered to what is
+ /// referenced so an unrelated protocol (Modbus) gets no spurious
+ /// parameters.</summary>
+ private IReadOnlyList<Field> ParentContextFields(ComplexTypeDefinition child)
+ {
+ if (child.ParentName == null || _protocol.FindType(child.ParentName) is not { } p)
+ {
+ return Array.Empty<Field>();
+ }
+ var referenced = new HashSet<string>();
+ foreach (var f in child.Fields.Concat(p.FieldsAfterSwitch))
+ {
+ foreach (var t in AllFieldTerms(f))
+ {
+ CollectNames(t, referenced);
+ }
+ }
+ // Prefix property fields are always needed - the child's constructor
+ // takes them (see AllValueFields). Prefix discriminators / implicits
+ // / consts come along only when an expression names them.
+ return p.PrefixContextFields
+ .Where(f => f.IsProperty || referenced.Contains(f.Name))
+ .ToList();
+ }
+
+ private static IEnumerable<Term> AllFieldTerms(Field f)
+ {
+ foreach (var t in FieldTerms(f))
+ {
+ yield return t;
+ }
+ if (f.Type is ComplexTypeReference c)
+ {
+ foreach (var a in c.Arguments)
+ {
+ yield return a;
+ }
+ }
+ }
+
+ private static void CollectNames(Term? t, HashSet<string> sink)
+ {
+ switch (t)
+ {
+ case VariableLiteral v:
+ if (v.Name != null)
+ {
+ sink.Add(v.Name);
+ }
+ foreach (var a in v.Args ?? Enumerable.Empty<Term>())
+ {
+ CollectNames(a, sink);
+ }
+ foreach (var ix in v.Index ?? Enumerable.Empty<Term>())
+ {
+ CollectNames(ix, sink);
+ }
+ CollectNames(v.Child, sink);
+ break;
+ case BinaryExpression b:
+ CollectNames(b.Left, sink);
+ CollectNames(b.Right, sink);
+ break;
+ case UnaryExpression u:
+ CollectNames(u.Operand, sink);
+ break;
+ case TernaryExpression tt:
+ CollectNames(tt.Condition, sink);
+ CollectNames(tt.WhenTrue, sink);
+ CollectNames(tt.WhenFalse, sink);
+ break;
+ }
+ }
+
+ /// <summary>Constructor parameters and property fields for a type, in
+ /// wire order: inherited prefix, own, inherited suffix.</summary>
+ private List<Field> AllValueFields(ComplexTypeDefinition type) =>
+ ParentPrefixFields(type)
+ .Concat(type.PropertyFields)
+ .Concat(ParentSuffixFields(type))
+ .ToList();
+
+ /// <summary>The fields this type's own parse / serialize / length
+ /// covers. A discriminated parent stops at the typeSwitch; a child
+ /// picks up the parent's trailing fields after its own body.</summary>
+ private IReadOnlyList<Field> FieldsToEmit(ComplexTypeDefinition type)
+ {
+ if (type.IsDiscriminatedParent)
+ {
+ return type.FieldsThroughSwitch.ToList();
+ }
+ if (type.IsDiscriminatedChild && _protocol.FindType(type.ParentName) is { } p)
+ {
+ return type.Fields.Concat(p.FieldsAfterSwitch).ToList();
+ }
+ return type.Fields;
+ }
+
+ private string RenderDiscriminatorValue(Term? value, TypeReference? dType, CSharpExpressionRenderer r)
+ {
+ if (dType is EnumTypeReference e && value is VariableLiteral v && v.Child == null && !v.IsCall)
+ {
+ return $"{e.Name}.{v.Name}";
+ }
+ return WithType(r.Render(value), dType);
+ }
+
+ private static string WithType(string rendered, TypeReference? type)
+ {
+ if (type is SimpleTypeReference s && s.IsIntegerLike && s.BaseType != SimpleTypeReference.Base.Bit)
+ {
+ return $"({CSharpTypeMapper.SimpleCSharpType(s)}) ({rendered})";
+ }
+ return rendered;
+ }
+
+ private List<string> ParseSignatureArgs(ComplexTypeDefinition type)
+ {
+ // A child inherits the parent's parser arguments, and also takes
+ // the parent's prefix fields (read by the parent, passed down so
+ // the child can build a complete object).
+ // A discriminated child's own ParentName always resolves - the
+ // builder only sets it while lifting the child out of its parent's
+ // typeSwitch.
+ var owner = type.IsDiscriminatedChild
+ ? _protocol.FindType(type.ParentName)
+ ?? throw new InvalidOperationException($"Type '{type.ParentName}' (parent of '{type.Name}') was not found.")
+ : type;
+ var args = owner.Arguments
+ .Select(a => $"{CSharpTypeMapper.CSharpType(a.Type)} {Camel(a.Name)}")
+ .ToList();
+ args.AddRange(ParentContextFields(type)
+ .Select(f => $"{PropertyType(f)} {Camel(f.Name)}"));
+ if (type.IsDiscriminatedChild && UsesCurPos(type))
+ {
+ args.Add("int _startPos");
+ }
+ if (UsesLastItem(type))
+ {
+ args.Add("bool _lastItem = false");
+ }
+ return args;
+ }
+
+ /// <summary>Whether an expression in this type reads <c>_lastItem</c> -
+ /// true only for a type used as an array element whose last element is
+ /// laid out differently (S7's <c>S7VarPayloadDataItem</c> padding).</summary>
+ private bool UsesLastItem(ComplexTypeDefinition type)
+ {
+ var names = new HashSet<string>();
+ foreach (var f in FieldsToEmit(type))
+ {
+ foreach (var t in AllFieldTerms(f))
+ {
+ CollectNames(t, names);
+ }
+ }
+ return names.Contains("_lastItem");
+ }
+
+ /// <summary>Whether <paramref name="af"/>'s element type threads
+ /// <c>_lastItem</c> - the loop then has to pass the per-iteration flag
+ /// and use an index-aware serialize / length.</summary>
+ private bool ElementUsesLastItem(ArrayField af) =>
+ ElementType(af) is ComplexTypeReference c
+ && _protocol.FindType(c.Name) is { } et
+ && UsesLastItem(et);
+
+ /// <summary>Whether this type's own parse emits a field whose expression
+ /// reads <c>curPos</c> - it then needs <c>_startPos</c> in scope.</summary>
+ private bool UsesCurPos(ComplexTypeDefinition type)
+ {
+ var names = new HashSet<string>();
+ foreach (var f in FieldsToEmit(type))
+ {
+ foreach (var t in AllFieldTerms(f))
+ {
+ CollectNames(t, names);
+ }
+ }
+ return names.Contains("curPos") || names.Contains("_curPos");
+ }
+
+ /// <summary>A discriminated parent that itself does not read
+ /// <c>curPos</c> still captures <c>_startPos</c> when a child needs it,
+ /// to pass down.</summary>
+ private bool NeedsStartPos(ComplexTypeDefinition type) =>
+ UsesCurPos(type)
+ || type.IsDiscriminatedParent
+ && type.TypeSwitch is { } ts
+ && ts.CaseNames
+ .Select(_protocol.FindType)
+ .OfType<ComplexTypeDefinition>()
+ .Any(UsesCurPos);
+
+ private IEnumerable<string> ChildParseArgNames(ComplexTypeDefinition child)
+ {
+ // Every discriminated child's ParentName resolves - see the
+ // comment in ParseSignatureArgs.
+ var parent = _protocol.FindType(child.ParentName)
+ ?? throw new InvalidOperationException($"Type '{child.ParentName}' (parent of '{child.Name}') was not found.");
+ return parent.Arguments.Select(a => Camel(a.Name));
+ }
+
+ private static string Comma(IEnumerable<string> parts)
+ {
+ var list = parts.ToList();
+ return list.Count == 0 ? "" : ", " + string.Join(", ", list);
+ }
+
+ private static string Comma(string s) => string.IsNullOrEmpty(s) ? "" : ", " + s;
+
+ private void Header(CodeWriter c)
+ {
+ foreach (var line in LicenseHeader)
+ {
+ c.Line(line);
+ }
+ c.Line();
+ c.Line("// Code generated from the mspec by plc4net-code-gen. DO NOT EDIT.");
+ c.Line();
+ }
+
+ private static readonly string[] LicenseHeader =
+ {
+ "//",
+ "// Licensed to the Apache Software Foundation (ASF) under one",
+ "// or more contributor license agreements. See the NOTICE file",
+ "// distributed with this work for additional information",
+ "// regarding copyright ownership. The ASF licenses this file",
+ "// to you under the Apache License, Version 2.0 (the",
+ "// \"License\"); you may not use this file except in compliance",
+ "// with the License. You may obtain a copy of the License at",
+ "//",
+ "// https://www.apache.org/licenses/LICENSE-2.0",
+ "//",
+ "// Unless required by applicable law or agreed to in writing,",
+ "// software distributed under the License is distributed on an",
+ "// \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY",
+ "// KIND, either express or implied. See the License for the",
+ "// specific language governing permissions and limitations",
+ "// under the License.",
+ "//",
+ };
+
+ internal static string Pascal(string s) =>
+ string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s.Substring(1);
+
+ internal static string Camel(string s) =>
+ string.IsNullOrEmpty(s) ? s : char.ToLowerInvariant(s[0]) + s.Substring(1);
+ }
+}
diff --git a/plc4net/tools/code-gen/output/CSharpTypeMapper.cs b/plc4net/tools/code-gen/output/CSharpTypeMapper.cs
new file mode 100644
index 0000000000..c5761be75a
--- /dev/null
+++ b/plc4net/tools/code-gen/output/CSharpTypeMapper.cs
@@ -0,0 +1,192 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using org.apache.plc4net.tools.codegen.model;
+
+namespace org.apache.plc4net.tools.codegen.output
+{
+ /// <summary>
+ /// Maps an mspec <see cref="TypeReference"/> to the C# type, and to the
+ /// <c>ReadBuffer</c> / <c>WriteBuffer</c> call that reads or writes it.
+ /// </summary>
+ /// <remarks>
+ /// Targets plc4net's hand-written buffer API: <c>ReadUshort("name", 16)</c>
+ /// with the width passed explicitly, no context stack and no byte-order
+ /// argument (the buffers are MSB-first / big-endian, which is what every
+ /// mspec-described protocol here uses).
+ /// </remarks>
+ public static class CSharpTypeMapper
+ {
+ /// <summary>The C# type used for a field or property of this reference.
+ /// Null (a typeSwitch pseudo-field) maps to <c>object</c>, same as any
+ /// other unrecognised shape - callers never actually render this for
+ /// a typeSwitch, since typeSwitch has no property/parse/serialize of
+ /// its own.</summary>
+ public static string CSharpType(TypeReference? type) => type switch
+ {
+ EnumTypeReference e => e.Name,
+ ComplexTypeReference c => c.Name,
+ SimpleTypeReference s => SimpleCSharpType(s),
+ _ => "object",
+ };
+
+ public static string SimpleCSharpType(SimpleTypeReference s) => s.BaseType switch
+ {
+ SimpleTypeReference.Base.Bit => "bool",
+ SimpleTypeReference.Base.Byte => "byte",
+ SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? "byte"
+ : s.SizeInBits <= 16 ? "ushort"
+ : s.SizeInBits <= 32 ? "uint" : "ulong",
+ SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? "sbyte"
+ : s.SizeInBits <= 16 ? "short"
+ : s.SizeInBits <= 32 ? "int" : "long",
+ SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+ s.SizeInBits <= 32 ? "float" : "double",
+ SimpleTypeReference.Base.String or SimpleTypeReference.Base.VString => "string",
+ SimpleTypeReference.Base.Time or SimpleTypeReference.Base.Date
+ or SimpleTypeReference.Base.DateTime => "long",
+ _ => "object",
+ };
+
+ /// <summary>
+ /// The C# element type of an array of <paramref name="element"/>, and
+ /// whether the array is <c>byte[]</c> (vs <c>List<T></c>).
+ /// </summary>
+ public static (string Type, bool IsByteArray) ArrayType(TypeReference? element)
+ {
+ if (element is SimpleTypeReference { IsByteBased: true })
+ {
+ return ("byte[]", true);
+ }
+ return ($"System.Collections.Generic.List<{CSharpType(element)}>", false);
+ }
+
+ /// <summary>Bit width of a fixed-size simple type; -1 if run-time sized
+ /// or the type is null (typeSwitch has none).</summary>
+ public static int FixedBitLength(TypeReference? type) => type switch
+ {
+ SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString } => -1,
+ SimpleTypeReference s => s.SizeInBits,
+ EnumTypeReference e => e.BaseType.SizeInBits,
+ _ => -1,
+ };
+
+ /// <summary>
+ /// A <c>ReadBuffer</c> call that reads one value of
+ /// <paramref name="type"/>. <paramref name="logicalName"/> is the field
+ /// name for diagnostics; <paramref name="complexArgs"/> is the rendered
+ /// argument list for a complex type's <c>StaticParse</c>.
+ /// </summary>
+ public static string ReadCall(
+ TypeReference? type, string logicalName, string complexArgs = "")
+ {
+ switch (type)
+ {
+ case EnumTypeReference e:
+ return $"({e.Name}) {ReadCall(e.BaseType, logicalName)}";
+ case ComplexTypeReference c:
+ return $"{c.Name}.StaticParse(readBuffer{Prefix(complexArgs)})";
+ case SimpleTypeReference s:
+ return SimpleReadCall(s, logicalName);
+ default:
+ throw new NotSupportedException($"No read for {type}");
+ }
+ }
+
+ private static string SimpleReadCall(SimpleTypeReference s, string n)
+ {
+ var q = $"\"{n}\"";
+ return s.BaseType switch
+ {
+ SimpleTypeReference.Base.Bit => $"readBuffer.ReadBit({q})",
+ SimpleTypeReference.Base.Byte => $"readBuffer.ReadByte({q}, 8)",
+ SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? $"readBuffer.ReadByte({q}, {s.SizeInBits})"
+ : s.SizeInBits <= 16 ? $"readBuffer.ReadUshort({q}, {s.SizeInBits})"
+ : s.SizeInBits <= 32 ? $"readBuffer.ReadUint({q}, {s.SizeInBits})"
+ : $"readBuffer.ReadUlong({q}, {s.SizeInBits})",
+ SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? $"readBuffer.ReadSbyte({q}, {s.SizeInBits})"
+ : s.SizeInBits <= 16 ? $"readBuffer.ReadShort({q}, {s.SizeInBits})"
+ : s.SizeInBits <= 32 ? $"readBuffer.ReadInt({q}, {s.SizeInBits})"
+ : $"readBuffer.ReadLong({q}, {s.SizeInBits})",
+ SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+ s.SizeInBits <= 16 ? $"readBuffer.ReadFloat({q}, 16)"
+ : s.SizeInBits <= 32 ? $"readBuffer.ReadFloat({q}, 32)"
+ : $"readBuffer.ReadDouble({q}, 64)",
+ SimpleTypeReference.Base.String =>
+ $"readBuffer.ReadString({q}, {s.SizeInBits}, {EncodingExpr(s.Encoding)})",
+ _ => throw new NotSupportedException($"No read for {s}"),
+ };
+ }
+
+ /// <summary>
+ /// A <c>WriteBuffer</c> statement that writes <paramref name="valueExpr"/>
+ /// as <paramref name="type"/>.
+ /// </summary>
+ public static string WriteCall(TypeReference? type, string logicalName, string valueExpr)
+ {
+ var q = $"\"{logicalName}\"";
+ switch (type)
+ {
+ case EnumTypeReference e:
+ return WriteCall(e.BaseType, logicalName,
+ $"({SimpleCSharpType(e.BaseType)}) {valueExpr}");
+ case ComplexTypeReference _:
+ return $"{valueExpr}.Serialize(writeBuffer)";
+ case SimpleTypeReference s:
+ return s.BaseType switch
+ {
+ SimpleTypeReference.Base.Bit => $"writeBuffer.WriteBit({q}, {valueExpr})",
+ SimpleTypeReference.Base.Byte => $"writeBuffer.WriteByte({q}, 8, {valueExpr})",
+ SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? $"writeBuffer.WriteByte({q}, {s.SizeInBits}, {valueExpr})"
+ : s.SizeInBits <= 16 ? $"writeBuffer.WriteUshort({q}, {s.SizeInBits}, {valueExpr})"
+ : s.SizeInBits <= 32 ? $"writeBuffer.WriteUint({q}, {s.SizeInBits}, {valueExpr})"
+ : $"writeBuffer.WriteUlong({q}, {s.SizeInBits}, {valueExpr})",
+ SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? $"writeBuffer.WriteSbyte({q}, {s.SizeInBits}, {valueExpr})"
+ : s.SizeInBits <= 16 ? $"writeBuffer.WriteShort({q}, {s.SizeInBits}, {valueExpr})"
+ : s.SizeInBits <= 32 ? $"writeBuffer.WriteInt({q}, {s.SizeInBits}, {valueExpr})"
+ : $"writeBuffer.WriteLong({q}, {s.SizeInBits}, {valueExpr})",
+ SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+ s.SizeInBits <= 16 ? $"writeBuffer.WriteFloat({q}, 16, {valueExpr})"
+ : s.SizeInBits <= 32 ? $"writeBuffer.WriteFloat({q}, 32, {valueExpr})"
+ : $"writeBuffer.WriteDouble({q}, 64, {valueExpr})",
+ SimpleTypeReference.Base.String =>
+ $"writeBuffer.WriteString({q}, {s.SizeInBits}, \"{s.Encoding ?? "UTF8"}\", {valueExpr})",
+ _ => throw new NotSupportedException($"No write for {s}"),
+ };
+ default:
+ throw new NotSupportedException($"No write for {type}");
+ }
+ }
+
+ /// <summary>The <c>System.Text.Encoding</c> expression for an mspec
+ /// string encoding name (<c>"UTF8"</c>, <c>"UTF16BE"</c>, …).</summary>
+ public static string EncodingExpr(string? encoding) =>
+ (encoding ?? "UTF8").Replace("-", "").ToUpperInvariant() switch
+ {
+ "UTF16" or "UTF16LE" => "System.Text.Encoding.Unicode",
+ "UTF16BE" => "System.Text.Encoding.BigEndianUnicode",
+ "ASCII" => "System.Text.Encoding.ASCII",
+ _ => "System.Text.Encoding.UTF8",
+ };
+
+ private static string Prefix(string args) =>
+ string.IsNullOrEmpty(args) ? "" : ", " + args;
+ }
+}
diff --git a/plc4net/tools/code-gen/output/CodeWriter.cs b/plc4net/tools/code-gen/output/CodeWriter.cs
new file mode 100644
index 0000000000..fcb1a02cad
--- /dev/null
+++ b/plc4net/tools/code-gen/output/CodeWriter.cs
@@ -0,0 +1,46 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Text;
+
+namespace org.apache.plc4net.tools.codegen.output
+{
+ /// <summary>A minimal indent-aware source writer.</summary>
+ internal sealed class CodeWriter
+ {
+ private readonly StringBuilder _sb = new StringBuilder();
+ private int _depth;
+
+ public void Indent() => _depth++;
+
+ public void Outdent() => _depth = _depth > 0 ? _depth - 1 : 0;
+
+ public void Line(string text = "")
+ {
+ if (text.Length == 0)
+ {
+ _sb.Append('\n');
+ return;
+ }
+ _sb.Append(new string(' ', _depth * 4)).Append(text).Append('\n');
+ }
+
+ public override string ToString() => _sb.ToString();
+ }
+}
diff --git a/plc4net/tools/code-gen/output/FieldScope.cs b/plc4net/tools/code-gen/output/FieldScope.cs
new file mode 100644
index 0000000000..97bac253ea
--- /dev/null
+++ b/plc4net/tools/code-gen/output/FieldScope.cs
@@ -0,0 +1,198 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model;
+using org.apache.plc4net.tools.codegen.model.fields;
+
+namespace org.apache.plc4net.tools.codegen.output
+{
+ /// <summary>
+ /// Resolves the names in an mspec expression against one complex type's
+ /// fields and parser arguments. During parse, a field name is a local
+ /// holding the value just read; during serialize, it is the property. An
+ /// enum member is <c>Enum.Member</c>; an unknown name is left alone.
+ /// </summary>
+ internal sealed class FieldScope : IExpressionScope
+ {
+ private readonly Dictionary<string, TypeReference?> _byName = new Dictionary<string, TypeReference?>();
+ private readonly HashSet<string> _enumNames;
+ // constant name -> "EnumName.CONSTANT" (first enum wins on a clash)
+ private readonly Dictionary<string, string> _enumConstants;
+ // every enum attribute-accessor name, e.g. `sizeInBits`, `code` - a
+ // member access on one of these is a Get… extension-method call.
+ private readonly HashSet<string> _enumAccessors;
+ private readonly bool _serialize;
+
+ public FieldScope(Protocol protocol, ComplexTypeDefinition type, bool serialize = false)
+ {
+ _serialize = serialize;
+ _hoistedLocals = new HashSet<string>();
+ _enumNames = new HashSet<string>(protocol.Enums.Select(e => e.Name));
+ _enumAccessors = new HashSet<string>(
+ protocol.Enums.SelectMany(e => e.Arguments.Select(a => a.Name)));
+ _enumConstants = new Dictionary<string, string>();
+ foreach (var e in protocol.Enums)
+ {
+ foreach (var v in e.Values)
+ {
+ _enumConstants.TryAdd(v.Name, $"{e.Name}.{v.Name}");
+ }
+ }
+
+ var owner = type.ParentName != null ? protocol.FindType(type.ParentName) : type;
+ if (owner != null)
+ {
+ foreach (var a in owner.Arguments)
+ {
+ _byName[a.Name] = a.Type;
+ }
+ foreach (var f in owner.Fields.Where(f => f.Name != null))
+ {
+ _byName[f.Name] = f.Type;
+ }
+ }
+ foreach (var f in type.Fields.Where(f => f.Name != null))
+ {
+ _byName[f.Name] = f.Type;
+ }
+ }
+
+ private FieldScope(FieldScope src, bool serialize)
+ {
+ _serialize = serialize;
+ _enumNames = src._enumNames;
+ _enumAccessors = src._enumAccessors;
+ _enumConstants = src._enumConstants;
+ _byName = src._byName;
+ _hoistedLocals = src._hoistedLocals;
+ }
+
+ public FieldScope ForSerialize() => new FieldScope(this, serialize: true);
+
+ /// <summary>The C# for <c>_value</c> - the field's own value, set by the
+ /// generator around a <c>manual</c> field's serialize expression.</summary>
+ public string? CurrentValue { get; set; }
+
+ /// <summary>Names the generator has hoisted as a plain local (a
+ /// recomputed implicit field in a child's serialize / length) - these
+ /// resolve to the camelCase local, not a Pascal property. Shared with
+ /// the serialize view so a mark on either is seen by both.</summary>
+ private readonly HashSet<string> _hoistedLocals;
+
+ public void MarkLocal(string name) => _hoistedLocals.Add(name);
+
+ public void ClearLocals() => _hoistedLocals.Clear();
+
+ public string? ResolveReference(string name)
+ {
+ switch (name)
+ {
+ // mspec context variables available inside manual / expression fields
+ case "readBuffer":
+ case "writeBuffer":
+ return name;
+ case "_lastItem":
+ // the generator gives a type that needs it a `_lastItem`
+ // parameter, threaded from the enclosing array loop.
+ return "_lastItem";
+ case "curPos":
+ case "_curPos":
+ // mspec curPos is bytes consumed since this type's parse
+ // began; the generator captures _startPos (bits) at entry.
+ return _serialize
+ ? "((writeBuffer.GetPos() - _startPos) / 8)"
+ : "((readBuffer.GetPos() - _startPos) / 8)";
+ case "_value":
+ return CurrentValue ?? "_value";
+ case "_type":
+ return "this";
+ // the length of the type being defined, used bare inside an
+ // implicit / virtual field's expression
+ case "lengthInBytes":
+ return "GetLengthInBytes()";
+ case "lengthInBits":
+ return "GetLengthInBits()";
+ }
+
+ if (_enumNames.Contains(name))
+ {
+ return name;
+ }
+ if (_hoistedLocals.Contains(name))
+ {
+ return CSharpGenerator.Camel(name);
+ }
+ if (_byName.ContainsKey(name))
+ {
+ return _serialize ? CSharpGenerator.Pascal(name) : CSharpGenerator.Camel(name);
+ }
+ // A bare enum constant used in a discriminator / expression, e.g.
+ // `BYTE_WORD_DWORD` for a `DataTransportSize`-typed const field.
+ if (_enumConstants.TryGetValue(name, out var qualified))
+ {
+ return qualified;
+ }
+ return null;
+ }
+
+ /// <summary>Resolves only bare enum-constant names - for rendering an
+ /// enum's own value expressions and its parameter tables, where there
+ /// are no fields in scope.</summary>
+ public static IExpressionScope EnumConstantsOnly(Protocol protocol)
+ => new EnumScope(protocol);
+
+ private sealed class EnumScope : IExpressionScope
+ {
+ private readonly Dictionary<string, string> _constants = new Dictionary<string, string>();
+
+ public EnumScope(Protocol protocol)
+ {
+ foreach (var e in protocol.Enums)
+ {
+ foreach (var v in e.Values)
+ {
+ _constants.TryAdd(v.Name, $"{e.Name}.{v.Name}");
+ }
+ }
+ }
+
+ public string? ResolveReference(string name)
+ => _constants.TryGetValue(name, out var q) ? q : null;
+ }
+
+ public string? ResolveMember(string name)
+ => _enumAccessors.Contains(name)
+ ? $"Get{CSharpGenerator.Pascal(name)}()"
+ : null;
+
+ public string ResolveCount(string name)
+ {
+ var reference = ResolveReference(name) ?? name;
+ if (_byName.TryGetValue(name, out var type)
+ && type is SimpleTypeReference { IsByteBased: true })
+ {
+ return reference + ".Length";
+ }
+ // A byte-based array field is byte[]; anything else is List<T>.
+ return reference + ".Count";
+ }
+ }
+}