You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by "TomNewChao (via GitHub)" <gi...@apache.org> on 2026/09/30 01:34:26 UTC
[PR] fix(plc4net): preserve expression literal semantics
TomNewChao opened a new pull request, #2785:
URL: https://github.com/apache/plc4x/pull/2785
## Summary
- preserve hexadecimal literals as dedicated AST terms without converting them to signed 64-bit integers
- retain the required parentheses when an index expression targets a unary expression
- verify checked-in C# parser artifacts whenever PLC4NET or the shared MSpec grammars change
## Context
This is a focused follow-up to #2784. The expression model previously converted hexadecimal literals through `long`, which could overflow and lose their original representation. Rendering an indexed unary expression could also drop parentheses and change its meaning.
The CI addition keeps the shared ANTLR grammars and checked-in C# parser artifacts synchronized. It runs once on Ubuntu rather than adding the generation check to every platform in the .NET matrix.
## Validation
- `dotnet build plc4net/plc4net.sln --configuration Debug --no-incremental --nologo -m:1`
- `dotnet test plc4net/plc4net.sln --configuration Debug --no-build --nologo -m:1` (184 SPI tests and 46 code-gen tests passed)
- `python plc4net/tools/code-gen/generate_parsers.py --check`
- ASF license-header check for the pushed files
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@plc4x.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(plc4net): add the MSpec C# generator
Posted by "TomNewChao (via GitHub)" <gi...@apache.org>.
TomNewChao commented on code in PR #2785:
URL: https://github.com/apache/plc4x/pull/2785#discussion_r4141735987
##########
.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
Review Comment:
> why does this need Java here?
`setup-java` is required because [`generate_parsers.py`](https://github.com/apache/plc4x/blob/develop/plc4net/tools/code-gen/generate_parsers.py#L102) invokes the pinned ANTLR JAR through `java -jar`.
The parser-generation job runs the script in `--check` mode: it regenerates the MSpec and expression C# parsers in a temporary directory and compares them with the checked-in artifacts. ANTLR's generator is Java-based, so Java is needed only for this job; the regular .NET build and test job does not depend on it.
If there is a project-preferred way to invoke ANTLR generation, I am happy to align with it.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@plc4x.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(plc4net): add the MSpec C# generator
Posted by "sruehl (via GitHub)" <gi...@apache.org>.
sruehl commented on code in PR #2785:
URL: https://github.com/apache/plc4x/pull/2785#discussion_r4141605562
##########
.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
Review Comment:
why does this need Java here?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@plc4x.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(plc4net): add the MSpec C# generator
Posted by "sruehl (via GitHub)" <gi...@apache.org>.
sruehl commented on code in PR #2785:
URL: https://github.com/apache/plc4x/pull/2785#discussion_r4141784559
##########
.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
Review Comment:
makes sense
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@plc4x.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(plc4net): add the MSpec C# generator
Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #2785:
URL: https://github.com/apache/plc4x/pull/2785#discussion_r4141662824
##########
plc4net/tools/code-gen/MspecReader.cs:
##########
@@ -0,0 +1,109 @@
+//
+// 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 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 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);
Review Comment:
Strict parsing only collects parser diagnostics; lexer errors still go to stderr and are not included in `HasErrors`. Because ANTLR lexers can skip an invalid character and continue, malformed MSpec can therefore return a successful tree in strict mode. Attach an `IAntlrErrorListener<int>` to `MSpecLexer` and combine its errors with the parser errors, as `MspecExpressionParser` already does.
##########
plc4net/tools/code-gen/output/CSharpGenerator.cs:
##########
@@ -0,0 +1,2418 @@
+//
+// 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);
+ 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)
+ {
+ foreach (var f in type.PropertyFields)
+ {
+ c.Line($"public {PropertyType(f)} {Pascal(f.Name)} {{ get; }}");
+ }
+ if (type.PropertyFields.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)};");
+ break;
Review Comment:
Checksum fields are read but never compared with `ChecksumExpression`, so corrupted Modbus RTU/ASCII frames are accepted. The corresponding generated Java path validates the wire value via `readChecksumField`; compute the expected checksum here and throw `ParseException` on mismatch before returning the parsed message.
##########
plc4net/tools/code-gen/output/CSharpGenerator.cs:
##########
@@ -0,0 +1,2418 @@
+//
+// 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);
+ 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)
+ {
+ foreach (var f in type.PropertyFields)
+ {
+ c.Line($"public {PropertyType(f)} {Pascal(f.Name)} {{ get; }}");
+ }
+ if (type.PropertyFields.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)};");
+ 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:
+ break;
Review Comment:
Virtual fields are dropped during parsing. Later fields and type-switch discriminators resolve their names to locals, so an MSpec such as a virtual `commandType` followed by `typeSwitch commandType` generates an undefined identifier; virtual accessors are also absent because `VirtualField.IsProperty` is false. Emit a local from `ValueExpression` here and a computed getter on the generated class.
##########
plc4net/tools/code-gen/output/CSharpGenerator.cs:
##########
@@ -0,0 +1,2418 @@
+//
+// 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);
+ 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)
+ {
+ foreach (var f in type.PropertyFields)
+ {
+ c.Line($"public {PropertyType(f)} {Pascal(f.Name)} {{ get; }}");
+ }
+ if (type.PropertyFields.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)};");
+ 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:
+ 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 parameter value.
+ EmitEnumArms(
+ c,
+ rows,
+ v => RenderEnumParamValue(v.ConstantValues[ai], arg.Type, r),
+ v => $"{e.Name}.{v.Name}",
+ v => RenderEnumParamValue(v.ConstantValues[ai], arg.Type, r));
Review Comment:
The deduplication key is the accessor result, not the enum's underlying value. For S7 `TransportSize.COUNTER` and `DATE_AND_TIME` both equal `0x1C` but have different parameter values, so both switch arms remain and C# reports the second constant pattern as unreachable. Canonicalize and deduplicate by `EnumValue.Value` before emitting member patterns.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@plc4x.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(plc4net): add the MSpec C# generator
Posted by "TomNewChao (via GitHub)" <gi...@apache.org>.
TomNewChao commented on code in PR #2785:
URL: https://github.com/apache/plc4x/pull/2785#discussion_r4141833999
##########
plc4net/tools/code-gen/MspecReader.cs:
##########
@@ -0,0 +1,109 @@
+//
+// 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 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 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);
Review Comment:
Fixed in ca64636b2. MspecReader now attaches an IAntlrErrorListener<int> to MSpecLexer and combines lexer and parser diagnostics before strict-mode validation. Added a regression test for an invalid lexer token.
##########
plc4net/tools/code-gen/output/CSharpGenerator.cs:
##########
@@ -0,0 +1,2418 @@
+//
+// 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);
+ 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)
+ {
+ foreach (var f in type.PropertyFields)
+ {
+ c.Line($"public {PropertyType(f)} {Pascal(f.Name)} {{ get; }}");
+ }
+ if (type.PropertyFields.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)};");
+ break;
Review Comment:
Fixed in ca64636b2. Generated parse code now calculates the expected checksum after reading the wire value and throws ParseException when they differ. Added regression coverage for the emitted validation path.
##########
plc4net/tools/code-gen/output/CSharpGenerator.cs:
##########
@@ -0,0 +1,2418 @@
+//
+// 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);
+ 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)
+ {
+ foreach (var f in type.PropertyFields)
+ {
+ c.Line($"public {PropertyType(f)} {Pascal(f.Name)} {{ get; }}");
+ }
+ if (type.PropertyFields.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)};");
+ 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:
+ break;
Review Comment:
Fixed in ca64636b2. Virtual fields now emit both a parse-time local for later fields and type-switch dispatch, and a computed read-only property on the generated class. Referenced parent virtual fields are also included in child parse context. Added regression coverage for later fields and type-switch dispatch.
##########
plc4net/tools/code-gen/output/CSharpGenerator.cs:
##########
@@ -0,0 +1,2418 @@
+//
+// 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);
+ 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)
+ {
+ foreach (var f in type.PropertyFields)
+ {
+ c.Line($"public {PropertyType(f)} {Pascal(f.Name)} {{ get; }}");
+ }
+ if (type.PropertyFields.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)};");
+ 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:
+ 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 parameter value.
+ EmitEnumArms(
+ c,
+ rows,
+ v => RenderEnumParamValue(v.ConstantValues[ai], arg.Type, r),
+ v => $"{e.Name}.{v.Name}",
+ v => RenderEnumParamValue(v.ConstantValues[ai], arg.Type, r));
Review Comment:
Fixed in ca64636b2. The enum accessor switch now deduplicates arms using EnumValue.Value, the underlying enum value, rather than the accessor result. Added a regression test with duplicate underlying values and distinct parameters.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@plc4x.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(plc4net): add the MSpec C# generator
Posted by "sruehl (via GitHub)" <gi...@apache.org>.
sruehl merged PR #2785:
URL: https://github.com/apache/plc4x/pull/2785
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@plc4x.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org