diff --git a/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs b/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs
new file mode 100644
index 00000000..1157dc54
--- /dev/null
+++ b/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs
@@ -0,0 +1,112 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright 2022-2026 Starion Group S.A.
+//
+// Licensed 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
+//
+// http://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.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace SysML2.NET.CodeGenerator.Extensions
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+
+ ///
+ /// Supplies the target metaclass for KEBNF rules whose name does not match the metaclass they build,
+ /// at generation time.
+ ///
+ ///
+ /// The KEBNF files under Resources/ are OMG source and are never edited, and the generated
+ /// output is never hand-edited either — so a rule that omits a target the generator cannot infer can
+ /// only be corrected here, on the way from the one to the other. The files reproduce the
+ /// textual-notation BNF of the KerML and SysML specifications verbatim, so a defect here is a
+ /// SPECIFICATION defect; OMG has confirmed this class of finding and routes the fix through the
+ /// Revision Task Forces (Systems-Modeling/SysML-v2-Release issue 124).
+ /// The grammar writes an explicit target whenever the rule name differs from the metaclass
+ /// (RequirementKind : RequirementConstraintMembership, SubjectMember : SubjectMembership).
+ /// Every entry below is a rule where that annotation is missing, so the rule name resolves to no
+ /// metaclass at all and the generator falls back to inferring one from the assigned property names —
+ /// which silently selects an unrelated class that happens to declare the same property.
+ /// Scope is deliberately narrow: an entry corrects a rule the generator would otherwise bind to
+ /// the WRONG metaclass. A production that merely admits more than one valid spelling is NOT an
+ /// erratum — choosing between admissible spellings is the writer's business, not a correction to the
+ /// grammar.
+ /// These corrections are expected to become unnecessary as OMG publishes fixes. On a new KEBNF
+ /// release, run the generator and prune whatever reports — an entry
+ /// that no longer matches has been fixed upstream.
+ ///
+ public static class GrammarErrata
+ {
+ ///
+ /// The targets supplied to rules that omit them, keyed by the exact rule name.
+ ///
+ private static readonly GrammarErratum[] Entries =
+ [
+ new("LiteralReal", "LiteralRational",
+ "KerML 8.2.2.24 writes 'LiteralReal = value = RealValue' with no target, but no metaclass named 'LiteralReal' exists — KerML 8.3.4.9 names it 'LiteralRational'. Its sibling literal rules (LiteralBoolean, LiteralString, LiteralInteger, LiteralInfinity) all match a metaclass by name, so only this one is left unresolved.")
+ ];
+
+ ///
+ /// The corrections that have matched at least one rule during this generator run.
+ ///
+ private static readonly HashSet AppliedRuleNames = [];
+
+ ///
+ /// Supplies the target metaclass for a rule when the grammar omits one and an erratum covers it.
+ ///
+ /// The rule name read from the grammar.
+ /// The target the grammar declares, which may be null.
+ ///
+ /// The corrected target, or unchanged when nothing applies.
+ ///
+ ///
+ /// A target the grammar states itself always wins: an erratum only fills a gap, so a rule that OMG
+ /// later annotates upstream stops being corrected here and surfaces via
+ /// .
+ ///
+ public static string ApplyTarget(string ruleName, string targetElementName)
+ {
+ if (!string.IsNullOrWhiteSpace(targetElementName) || string.IsNullOrWhiteSpace(ruleName))
+ {
+ return targetElementName;
+ }
+
+ var erratum = Entries.SingleOrDefault(entry => string.Equals(entry.RuleName, ruleName, StringComparison.Ordinal));
+
+ if (erratum == null)
+ {
+ return targetElementName;
+ }
+
+ AppliedRuleNames.Add(erratum.RuleName);
+
+ return erratum.TargetElementName;
+ }
+
+ ///
+ /// Returns the corrections that matched no rule during this generator run.
+ ///
+ /// The stale entries, which should be pruned from .
+ ///
+ /// Only meaningful once every rule has been read. A stale entry means the grammar no longer carries
+ /// the defect — either OMG annotated the rule, or the rule was renamed or removed.
+ ///
+ public static IReadOnlyList QueryUnappliedErrata()
+ {
+ return [..Entries.Where(erratum => !AppliedRuleNames.Contains(erratum.RuleName))];
+ }
+ }
+}
diff --git a/SysML2.NET.CodeGenerator/Extensions/GrammarErratum.cs b/SysML2.NET.CodeGenerator/Extensions/GrammarErratum.cs
new file mode 100644
index 00000000..b521910f
--- /dev/null
+++ b/SysML2.NET.CodeGenerator/Extensions/GrammarErratum.cs
@@ -0,0 +1,74 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright 2022-2026 Starion Group S.A.
+//
+// Licensed 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
+//
+// http://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.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace SysML2.NET.CodeGenerator.Extensions
+{
+ using System;
+
+ ///
+ /// A single correction applied to a rule carried by the KEBNF grammar.
+ ///
+ public sealed class GrammarErratum
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The exact rule name the grammar carries.
+ /// The metaclass the rule targets, which the grammar omits.
+ /// The evidence that the omission is a defect rather than intent.
+ /// Thrown when any argument is null or whitespace.
+ public GrammarErratum(string ruleName, string targetElementName, string justification)
+ {
+ if (string.IsNullOrWhiteSpace(ruleName))
+ {
+ throw new ArgumentException("The rule name is required.", nameof(ruleName));
+ }
+
+ if (string.IsNullOrWhiteSpace(targetElementName))
+ {
+ throw new ArgumentException("The target element name is required.", nameof(targetElementName));
+ }
+
+ if (string.IsNullOrWhiteSpace(justification))
+ {
+ throw new ArgumentException("A justification is required so the correction can be audited.", nameof(justification));
+ }
+
+ this.RuleName = ruleName;
+ this.TargetElementName = targetElementName;
+ this.Justification = justification;
+ }
+
+ ///
+ /// Gets the exact rule name the grammar carries.
+ ///
+ public string RuleName { get; }
+
+ ///
+ /// Gets the metaclass the rule targets.
+ ///
+ public string TargetElementName { get; }
+
+ ///
+ /// Gets the evidence that the omission is a defect rather than intent.
+ ///
+ public string Justification { get; }
+ }
+}
diff --git a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs
index ece96791..c87d4427 100644
--- a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs
+++ b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs
@@ -112,6 +112,14 @@ public override Task GenerateAsync(XmiReaderResult xmiReaderResult, DirectoryInf
///
public async Task GenerateAsync(XmiReaderResult xmiReaderResult, TextualNotationSpecification textualNotationSpecification, DirectoryInfo outputDirectory)
{
+ // Every rule has now been read, so a correction that matched nothing is stale: the grammar no
+ // longer carries the defect it repairs. Reported rather than thrown, since a fix upstream must
+ // not break generation.
+ foreach (var stale in GrammarErrata.QueryUnappliedErrata())
+ {
+ Console.WriteLine($"[GrammarErrata] STALE — {stale.RuleName} matched no grammar rule and should be pruned. Recorded reason: {stale.Justification}");
+ }
+
await this.GenerateBuilderClasses(xmiReaderResult, textualNotationSpecification, outputDirectory);
await this.GenerateSharedBuilder(xmiReaderResult, textualNotationSpecification, outputDirectory);
// await this.GenerateBuilderFacade(xmiReaderResult, outputDirectory);
diff --git a/SysML2.NET.CodeGenerator/Grammar/TextualNotationSpecificationVisitor.cs b/SysML2.NET.CodeGenerator/Grammar/TextualNotationSpecificationVisitor.cs
index 2a17f4dd..1a234587 100644
--- a/SysML2.NET.CodeGenerator/Grammar/TextualNotationSpecificationVisitor.cs
+++ b/SysML2.NET.CodeGenerator/Grammar/TextualNotationSpecificationVisitor.cs
@@ -23,6 +23,7 @@ namespace SysML2.NET.CodeGenerator.Grammar
using System.Collections.Generic;
using System.Linq;
+ using SysML2.NET.CodeGenerator.Extensions;
using SysML2.NET.CodeGenerator.Grammar.Model;
///
@@ -53,7 +54,11 @@ public override object VisitRule_definition(kebnfParser.Rule_definitionContext c
var rule = new TextualNotationRule()
{
RuleName = context.name.Text,
- TargetElementName = context.target_ast?.Text,
+
+ // A rule whose name differs from the metaclass it builds normally states the target
+ // itself; where the grammar omits it, GrammarErrata supplies it, so every consumer of
+ // EffectiveTarget resolves the same metaclass the annotation would have named.
+ TargetElementName = GrammarErrata.ApplyTarget(context.name.Text, context.target_ast?.Text),
RawRule = context.GetText().Trim()
};
diff --git a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs
index 19712ed8..18e9c9d0 100644
--- a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs
+++ b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs
@@ -534,6 +534,12 @@ internal void ProcessAssignmentElement(EncodedTextWriter writer, IClass umlClass
break;
}
}
+ else if (string.Equals(targetProperty.Type?.Name, "Real", StringComparison.Ordinal))
+ {
+ // A Real maps to double, whose default ToString() is culture-sensitive and drops
+ // the '.' that every RealValue alternative requires — see AppendRealValue.
+ writer.WriteSafeString($"SharedTextualNotationBuilder.AppendRealValue(stringBuilder, poco.{targetPropertyName});");
+ }
else
{
writer.WriteSafeString($"stringBuilder.Append(poco.{targetPropertyName}.ToString());");
diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Expected/08-Requirements/8-Requirements.sysml b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/08-Requirements/8-Requirements.sysml
new file mode 100644
index 00000000..8837273d
--- /dev/null
+++ b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/08-Requirements/8-Requirements.sysml
@@ -0,0 +1,160 @@
+package '8-Requirements' {
+ private import ScalarValues::Real;
+ private import ISQ::*;
+ private import SI::*;
+ public import 'Vehicle Usages'::*;
+ public import 'Vehicle Requirements'::*;
+ package 'Vehicle Definitions' {
+ part def Vehicle {
+ attribute mass: MassValue;
+ attribute fuelLevel: Real;
+ attribute fuelTankCapacity: Real;
+ }
+ part def Engine {
+ port drivePwrPort: DrivePwrPort;
+ perform action 'generate torque': 'Generate Torque';
+ }
+ part def Transmission {
+ port clutchPort: ClutchPort;
+ }
+ port def DrivePwrPort;
+ port def ClutchPort;
+ interface def EngineToTransmissionInterface {
+ end drivePwrPort: DrivePwrPort;
+ end clutchPort: ClutchPort;
+ }
+ action def 'Generate Torque';
+ }
+ package 'Vehicle Usages' {
+ public import 'Vehicle Definitions'::*;
+ action 'provide power' {
+ action 'generate torque' {
+ /* ... */
+ }
+ }
+ part vehicle1_c1: Vehicle {
+ attribute :>> mass = 2000[kg];
+ perform 'provide power';
+ part engine_v1: Engine {
+ port :>> drivePwrPort;
+ perform 'provide power'.'generate torque' :>> 'generate torque';
+ }
+ part transmission: Transmission {
+ port :>> clutchPort;
+ }
+ interface engineToTransmission: EngineToTransmissionInterface connect engine_v1.drivePwrPort to transmission.clutchPort;
+ }
+ part vehicle1_c2: Vehicle {
+ attribute :>> mass = 2500[kg];
+ }
+ }
+ package 'Vehicle Requirements' {
+ public import 'Vehicle Definitions'::*;
+ requirement def <'1'> MassLimitationRequirement {
+ /*
+ * The optional requirement ID of this requirement ('1') is given after the keyword "id" (using name syntax).
+ * Every requirement is parameterized by a "subject". The "subject" of this requirement is implicitly "Anything".
+ */
+ doc
+ /* The actual mass shall be less than or equal to the required mass. */
+ attribute massActual: MassValue;
+ attribute massReqd: MassValue;
+ require constraint { /*
+ * A constraint can be used to formalize a requirement.
+ */
+ massActual <= massReqd }
+ }
+ requirement def <'2'> ReliabilityRequirement;
+ requirement <'1.1'> vehicleMass1: '1' {
+ doc
+ /* The vehicle mass shall be less than or equal to 2000 kg when the fuel tank is full. */
+ subject vehicle: Vehicle {
+ /*
+ * The subject of this requirement is redefined to be a "Vehicle".
+ */
+ }
+ attribute :>> massActual : MassValue = vehicle.mass {
+ /*
+ * This redefinition binds the vehicle mass to the actual mass.
+ */
+ }
+ attribute :>> massReqd = 2000[kg] {
+ /*
+ * This redefinition sets the required mass to 2000 kg.
+ */
+ }
+ assume constraint fuelConstraint { /*
+ * A constraint can also be used to specify an assumption.
+ */
+ doc /* full fuel tank */ vehicle.fuelLevel >= vehicle.fuelTankCapacity }
+ }
+ requirement <'2.1'> vehicleMass2: '1' {
+ doc
+ /* The vehicle mass shall be less than or equal to 2500 kg when the fuel tank is empty. */
+ subject vehicle: Vehicle;
+ attribute :>> massActual : MassValue = vehicle.mass;
+ attribute :>> massReqd = 2500[kg];
+ assume constraint fuelConstraint { doc /* empty fuel tank */ vehicle.fuelLevel == 0.0}
+ }
+ requirement <'2.2'> vehicleReliability2: '2' {
+ subject vehicle: Vehicle;
+ }
+ requirement <'3.1'> drivePowerInterface {
+ doc
+ /* The engine shall transfer its generated torque to the transmission via the clutch interface. */
+ subject drivePwrPort: DrivePwrPort;
+ }
+ requirement <'3.2'> torqueGeneration {
+ doc
+ /* The engine shall generate torque as a function of RPM as shown in Table 1. */
+ subject generateTorque: 'Generate Torque';
+ }
+ }
+ part 'vehicle1_c1 Specification Context' {
+ private import 'vehicle1-c1 Specification'::*;
+ private import 'engine-v1 Specification'::*;
+ requirement 'vehicle1-c1 Specification' {
+ doc
+ /*
+ * This models a "requirement group" as a requirement that references other requirements.
+ */
+
+ subject vehicle: Vehicle;
+ requirement ::> '1.1' {
+ /*
+ * This is a reference to a requirement defined outside the group.
+ * By default, the subject of the requirement is bound to that of the group.
+ */
+ }
+ }
+ requirement 'engine-v1 Specification' {
+ subject engine: Engine;
+ /*
+ * Here the subjects of the referenced requirements are defined to be specific properties of the
+ * subject of the group.
+ */
+ require '3.2' {
+ in :>> generateTorque = engine.'generate torque';
+ }
+ require '3.1' {
+ in :>> drivePwrPort = engine.drivePwrPort;
+ }
+ }
+ assert satisfy 'vehicle1-c1 Specification' by vehicle1_c1 {
+ /*
+ * This asserts that if the assumptions of 'vehicle1-c1 Specification' are true with 'vehicle_c1' as
+ * the subject, then the required constraints are also true.
+ */
+ }
+ assert satisfy 'engine-v1 Specification' by vehicle1_c1.engine_v1;
+ }
+ part 'vehicle1_c2 Specification Context' {
+ private import 'vehicle1-c2 Specification'::*;
+ requirement 'vehicle1-c2 Specification' {
+ subject vehicle: Vehicle;
+ require '2.1';
+ require '2.2';
+ }
+ assert satisfy 'vehicle1-c2 Specification' by vehicle1_c2;
+ }
+}
diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs b/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs
index 93d17874..c93170c9 100644
--- a/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs
+++ b/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs
@@ -104,6 +104,7 @@ public void OneTimeTearDown()
[TestCase("07-Variant Configuration", "7a-Variant Configuration - General Concept.sysmlx")]
[TestCase("07-Variant Configuration", "7a1-Variant Configuration - General Concept-a.sysmlx")]
[TestCase("07-Variant Configuration", "7b-Variant Configurations.sysmlx")]
+ [TestCase("08-Requirements", "8-Requirements.sysmlx")]
public async Task VerifyValidationTextualNotationXmi(string folderName, string fileName)
{
var loggerFactory = LoggerFactory.Create(builder =>
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/LiteralExpressionTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/LiteralExpressionTextualNotationBuilder.cs
index 4bc7beca..7389a70c 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/LiteralExpressionTextualNotationBuilder.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/LiteralExpressionTextualNotationBuilder.cs
@@ -53,12 +53,12 @@ public static void BuildLiteralExpression(SysML2.NET.Core.POCO.Kernel.Expression
case SysML2.NET.Core.POCO.Kernel.Expressions.ILiteralInteger pocoLiteralInteger:
LiteralIntegerTextualNotationBuilder.BuildLiteralInteger(pocoLiteralInteger, writerContext, stringBuilder);
break;
+ case SysML2.NET.Core.POCO.Kernel.Expressions.ILiteralRational pocoLiteralRational:
+ LiteralRationalTextualNotationBuilder.BuildLiteralReal(pocoLiteralRational, writerContext, stringBuilder);
+ break;
case SysML2.NET.Core.POCO.Kernel.Expressions.ILiteralInfinity pocoLiteralInfinity:
LiteralInfinityTextualNotationBuilder.BuildLiteralInfinity(pocoLiteralInfinity, writerContext, stringBuilder);
break;
- case SysML2.NET.Core.POCO.Root.Elements.IElement pocoElement:
- SharedTextualNotationBuilder.BuildLiteralReal((SysML2.NET.Core.POCO.Kernel.FeatureValues.IFeatureValue)pocoElement, writerContext, stringBuilder);
- break;
}
}
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/LiteralRationalTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/LiteralRationalTextualNotationBuilder.cs
new file mode 100644
index 00000000..a9ce3082
--- /dev/null
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/LiteralRationalTextualNotationBuilder.cs
@@ -0,0 +1,53 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright (C) 2022-2026 Starion Group S.A.
+//
+// Licensed 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
+//
+// http://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.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+// ------------------------------------------------------------------------------------------------
+// --------THIS IS AN AUTOMATICALLY GENERATED FILE. ANY MANUAL CHANGES WILL BE OVERWRITTEN!--------
+// ------------------------------------------------------------------------------------------------
+
+namespace SysML2.NET.Serializer.TextualNotation.Writers
+{
+ using System.Linq;
+
+ using SysML2.NET.Core.POCO.Root.Elements;
+
+ ///
+ /// The provides Textual Notation Builder for the element
+ ///
+ public static partial class LiteralRationalTextualNotationBuilder
+ {
+ ///
+ /// Builds the Textual Notation string for the rule LiteralReal
+ /// LiteralReal=value=RealValue
+ ///
+ /// The from which the rule should be build
+ /// The providing the serialization context for the current
+ /// The that accumulates the entire textual notation with indentation
+ public static void BuildLiteralReal(SysML2.NET.Core.POCO.Kernel.Expressions.ILiteralRational poco, TextualNotationWriterContext writerContext, IndentedStringBuilder stringBuilder)
+ {
+ SharedTextualNotationBuilder.AppendRealValue(stringBuilder, poco.Value);
+
+ }
+ }
+}
+
+// ------------------------------------------------------------------------------------------------
+// --------THIS IS AN AUTOMATICALLY GENERATED FILE. ANY MANUAL CHANGES WILL BE OVERWRITTEN!--------
+// ------------------------------------------------------------------------------------------------
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/SharedTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/SharedTextualNotationBuilder.cs
index 8fd3c16a..fa1e1027 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/SharedTextualNotationBuilder.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/SharedTextualNotationBuilder.cs
@@ -147,23 +147,6 @@ public static void BuildFeaturePrefix(SysML2.NET.Core.POCO.Core.Features.IFeatur
}
- ///
- /// Builds the Textual Notation string for the rule LiteralReal
- /// LiteralReal=value=RealValue
- ///
- /// The from which the rule should be build
- /// The providing the serialization context for the current
- /// The that accumulates the entire textual notation with indentation
- public static void BuildLiteralReal(SysML2.NET.Core.POCO.Kernel.FeatureValues.IFeatureValue poco, TextualNotationWriterContext writerContext, IndentedStringBuilder stringBuilder)
- {
-
- if (poco.value != null)
- {
- BuildRealValueHandCoded(poco.value, writerContext, stringBuilder);
- }
-
- }
-
///
/// Builds the Textual Notation string for the rule NonBehaviorBodyItem
/// NonBehaviorBodyItem=ownedRelationship+=Import|ownedRelationship+=AliasMember|ownedRelationship+=DefinitionMember|ownedRelationship+=VariantUsageMember|ownedRelationship+=NonOccurrenceUsageMember|(ownedRelationship+=SourceSuccessionMember)?ownedRelationship+=StructureUsageMember
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/IndentedStringBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/IndentedStringBuilder.cs
index 7f9a6ad3..038f5c8b 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/IndentedStringBuilder.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/IndentedStringBuilder.cs
@@ -233,6 +233,38 @@ public void ExitInlineBlock()
}
}
+ ///
+ /// Suspends inline-block mode entirely, whatever its current depth, and returns that depth
+ /// so can restore it.
+ ///
+ /// The suspended depth, to be handed back to .
+ ///
+ /// For content that CANNOT be rendered on one line without losing information. A block comment
+ /// carrying newlines is the case that motivated this: inline mode turns each line terminator into
+ /// a space, so the comment body read back from the emitted text no longer equals the body the
+ /// model holds. Collapsing a layout is a formatting choice; collapsing a comment's own line
+ /// structure changes the value, so the writer leaves inline mode rather than corrupt it.
+ /// The whole depth is suspended, not one level: the comment must reach column zero even
+ /// when several inline blocks nest around it.
+ ///
+ public int SuspendInlineBlock()
+ {
+ var suspendedDepth = this.inlineBlockDepth;
+
+ this.inlineBlockDepth = 0;
+
+ return suspendedDepth;
+ }
+
+ ///
+ /// Restores the inline-block depth returned by .
+ ///
+ /// The depth returned by the matching call.
+ public void ResumeInlineBlock(int suspendedDepth)
+ {
+ this.inlineBlockDepth = suspendedDepth;
+ }
+
///
/// Appends a single to the underlying buffer, applying the
/// leading-whitespace-at-line-start and consecutive-space-collapse normalisation
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs
index 67395865..495ccce6 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs
@@ -170,19 +170,28 @@ private static void BuildNonBehaviorBodyItemHandCoded(IElement poco, TextualNota
}
///
- /// Builds the Textual Notation string for the rule RealValue (the value of a
- /// LiteralReal), which the grammar expresses as an .
- /// In the unparse direction, the real numeric value is stored as a property on the
- /// POCO; this method simply emits it as a string.
+ /// Appends a real number in the form the RealValue rule accepts.
+ /// RealValue : Real = DECIMAL_VALUE? '.' ( DECIMAL_VALUE | EXPONENTIAL_VALUE )
+ /// | EXPONENTIAL_VALUE
///
- /// The that holds the real value expression
- /// The used to get access to CursorCollection for the current
/// The that contains the entire textual notation
- private static void BuildRealValueHandCoded(IExpression poco, TextualNotationWriterContext _, IndentedStringBuilder stringBuilder)
+ /// The real value to emit
+ ///
+ /// Two things the default ToString() gets wrong here. It is culture-sensitive, so a decimal
+ /// comma would be emitted wherever the host locale uses one, which no reader accepts. And a value
+ /// with no fractional part round-trips as 0 or 2000, which the rule does not admit:
+ /// every alternative requires either a '.' or an exponent, so an integral real is completed
+ /// to 0.0 / 2000.0.
+ ///
+ internal static void AppendRealValue(IndentedStringBuilder stringBuilder, double value)
{
- if (poco is ILiteralRational literalRational)
+ var text = value.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
+
+ stringBuilder.Append(text);
+
+ if (text.IndexOf('.') < 0 && text.IndexOf('E') < 0 && text.IndexOf('e') < 0)
{
- stringBuilder.Append(literalRational.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ stringBuilder.Append(".0");
}
}
@@ -430,68 +439,73 @@ internal static void BuildDefinitionOrInterfaceBodyItemHandCoded(
{
var ownedRelationshipCursor = writerContext.CursorCache.GetOrCreateCursor(poco.Id, OwnedRelationshipPropertyName, poco.OwnedRelationship);
- while (ownedRelationshipCursor.Current != null)
+ // DefinitionBodyItem / InterfaceBodyItem is a SINGLE item: the `*` that repeats it belongs to
+ // DefinitionBody / InterfaceBody, and every caller supplies that loop itself. Draining the
+ // cursor here instead would swallow the whole remaining body — including members that a
+ // SPECIALIZED body rule delegating in (RequirementBodyItem, ViewDefinitionBodyItem,
+ // ViewUsageBodyItem) still has to claim for its own alternatives. That is what silently
+ // rendered a SubjectMembership as a plain `in` parameter and dropped a ConstraintUsage's
+ // ResultExpressionMember once any annotation preceded them in ownedRelationship.
+ switch (ownedRelationshipCursor.Current)
{
- switch (ownedRelationshipCursor.Current)
+ case IVariantMembership variantMembership:
+ VariantMembershipTextualNotationBuilder.BuildVariantUsageMember(variantMembership, writerContext, stringBuilder);
+ ownedRelationshipCursor.Move();
+ break;
+
+ case IFeatureMembership featureMembershipForSuccession when featureMembershipForSuccession.IsValidForSourceSuccessionMember(writerContext):
{
- case IVariantMembership variantMembership:
- VariantMembershipTextualNotationBuilder.BuildVariantUsageMember(variantMembership, writerContext, stringBuilder);
- ownedRelationshipCursor.Move();
- break;
+ var nextElement = ownedRelationshipCursor.GetNext(1);
- case IFeatureMembership featureMembershipForSuccession when featureMembershipForSuccession.IsValidForSourceSuccessionMember(writerContext):
+ // ( ownedRelationship += SourceSuccessionMember )? ownedRelationship += OccurrenceUsageMember
+ // is ONE alternative, so both elements are consumed by this single item.
+ if (nextElement is IFeatureMembership nextFeatureMembership && nextFeatureMembership.IsValidForOccurrenceUsageMember(writerContext))
{
- var nextElement = ownedRelationshipCursor.GetNext(1);
-
- if (nextElement is IFeatureMembership nextFeatureMembership && nextFeatureMembership.IsValidForOccurrenceUsageMember(writerContext))
- {
- FeatureMembershipTextualNotationBuilder.BuildSourceSuccessionMember(featureMembershipForSuccession, writerContext, stringBuilder);
- ownedRelationshipCursor.Move();
- buildOccurrenceUsageMember((IFeatureMembership)ownedRelationshipCursor.Current, writerContext, stringBuilder);
- ownedRelationshipCursor.Move();
- }
- else
- {
- ownedRelationshipCursor.Move();
- }
-
- break;
+ FeatureMembershipTextualNotationBuilder.BuildSourceSuccessionMember(featureMembershipForSuccession, writerContext, stringBuilder);
+ ownedRelationshipCursor.Move();
+ buildOccurrenceUsageMember((IFeatureMembership)ownedRelationshipCursor.Current, writerContext, stringBuilder);
+ ownedRelationshipCursor.Move();
}
-
- case IFeatureMembership featureMembershipForOccurrence when featureMembershipForOccurrence.IsValidForOccurrenceUsageMember(writerContext):
- buildOccurrenceUsageMember(featureMembershipForOccurrence, writerContext, stringBuilder);
+ else
+ {
ownedRelationshipCursor.Move();
- break;
+ }
- case IFeatureMembership featureMembershipForNonOccurrence when featureMembershipForNonOccurrence.IsValidForNonOccurrenceUsageMember(writerContext):
- buildNonOccurrenceUsageMember(featureMembershipForNonOccurrence, writerContext, stringBuilder);
- ownedRelationshipCursor.Move();
- break;
+ break;
+ }
- case IOwningMembership owningMembership when owningMembership.IsValidForDefinitionMember(writerContext):
- OwningMembershipTextualNotationBuilder.BuildDefinitionMember(owningMembership, writerContext, stringBuilder);
- ownedRelationshipCursor.Move();
- break;
+ case IFeatureMembership featureMembershipForOccurrence when featureMembershipForOccurrence.IsValidForOccurrenceUsageMember(writerContext):
+ buildOccurrenceUsageMember(featureMembershipForOccurrence, writerContext, stringBuilder);
+ ownedRelationshipCursor.Move();
+ break;
- case IImport import:
- ImportTextualNotationBuilder.BuildImport(import, writerContext, stringBuilder);
- ownedRelationshipCursor.Move();
- break;
+ case IFeatureMembership featureMembershipForNonOccurrence when featureMembershipForNonOccurrence.IsValidForNonOccurrenceUsageMember(writerContext):
+ buildNonOccurrenceUsageMember(featureMembershipForNonOccurrence, writerContext, stringBuilder);
+ ownedRelationshipCursor.Move();
+ break;
- case IMembership membership when membership is not IOwningMembership and not IFeatureMembership:
- MembershipTextualNotationBuilder.BuildAliasMember(membership, writerContext, stringBuilder);
- ownedRelationshipCursor.Move();
- break;
-
- default:
- // KEBNF DefinitionBodyItem* / InterfaceBodyItem* semantics: terminate the body
- // loop when the cursor's current element matches no alternative — this leaves
- // the element for the parent rule to consume (e.g. PortDefinition's trailing
- // ownedRelationship += ConjugatedPortDefinitionMember). The outer body loop is
- // also guarded by IsValidForDefinitionBodyItem / IsValidForInterfaceBodyItem,
- // so no caller reaches the dispatcher with an unrecognised element.
- return;
- }
+ case IOwningMembership owningMembership when owningMembership.IsValidForDefinitionMember(writerContext):
+ OwningMembershipTextualNotationBuilder.BuildDefinitionMember(owningMembership, writerContext, stringBuilder);
+ ownedRelationshipCursor.Move();
+ break;
+
+ case IImport import:
+ ImportTextualNotationBuilder.BuildImport(import, writerContext, stringBuilder);
+ ownedRelationshipCursor.Move();
+ break;
+
+ case IMembership membership when membership is not IOwningMembership and not IFeatureMembership:
+ MembershipTextualNotationBuilder.BuildAliasMember(membership, writerContext, stringBuilder);
+ ownedRelationshipCursor.Move();
+ break;
+
+ default:
+ // No alternative matches (a null cursor included): leave the element for the parent
+ // rule to consume, e.g. PortDefinition's trailing ownedRelationship +=
+ // ConjugatedPortDefinitionMember. DefinitionBody / InterfaceBody additionally guard
+ // their loop with IsValidForDefinitionBodyItem / IsValidForInterfaceBodyItem, so no
+ // caller reaches the dispatcher with an unrecognised element.
+ break;
}
}
@@ -612,24 +626,41 @@ internal static void AppendRegularComment(IndentedStringBuilder stringBuilder, s
return;
}
- stringBuilder.AppendLine("/*");
+ // A body that carries newlines cannot be emitted on one line without losing them, so an
+ // enclosing inline block (a constraint body, `{ expr }`) is suspended for the duration of the
+ // comment and restored after it. Without this the line terminators below each degrade to a
+ // space and the comment read back from the emitted text no longer equals the modelled body.
+ var suspendedInlineDepth = stringBuilder.SuspendInlineBlock();
- // Only the leading/trailing blank lines are dropped (the body of a block comment always
- // ends with one). Interior blank lines are CONTENT and must survive — filtering every
- // blank line collapses deliberate paragraph breaks in the comment.
- var firstContentIndex = Array.FindIndex(lines, line => !string.IsNullOrWhiteSpace(line));
- var lastContentIndex = Array.FindLastIndex(lines, line => !string.IsNullOrWhiteSpace(line));
-
- foreach (var rawLine in lines[firstContentIndex..(lastContentIndex + 1)])
+ try
{
- var trimmedLine = rawLine.TrimEnd();
- stringBuilder.AppendIndentedLiteral(trimmedLine.Length == 0 ? " *" : " * " + trimmedLine);
+ stringBuilder.AppendLine("/*");
+
+ // Only the leading/trailing blank lines are dropped (the body of a block comment always
+ // ends with one). Interior blank lines are CONTENT and must survive — filtering every
+ // blank line collapses deliberate paragraph breaks in the comment.
+ var firstContentIndex = Array.FindIndex(lines, line => !string.IsNullOrWhiteSpace(line));
+ var lastContentIndex = Array.FindLastIndex(lines, line => !string.IsNullOrWhiteSpace(line));
+
+ foreach (var rawLine in lines[firstContentIndex..(lastContentIndex + 1)])
+ {
+ var trimmedLine = rawLine.TrimEnd();
+ stringBuilder.AppendIndentedLiteral(trimmedLine.Length == 0 ? " *" : " * " + trimmedLine);
+ stringBuilder.AppendLine();
+ }
+
+ stringBuilder.AppendIndentedLiteral(" */");
stringBuilder.AppendLine();
}
+ finally
+ {
+ stringBuilder.ResumeInlineBlock(suspendedInlineDepth);
+ }
- stringBuilder.AppendIndentedLiteral(" */");
- stringBuilder.AppendLine();
-
+ // Emitted after the suspension is lifted, so the trailing separator observes the same enclosing
+ // state as the leading one above: inside an inline block both degrade to a space. Emitting it
+ // while still suspended forced a hard line break on one side only, splitting a `doc` block that
+ // the enclosing rule renders on a single line.
if (surroundWithBlankLines)
{
stringBuilder.AppendLine();
diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/TypeTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/TypeTextualNotationBuilder.cs
index 29bc58ab..2be5fbbe 100644
--- a/SysML2.NET.Serializer.TextualNotation/Writers/TypeTextualNotationBuilder.cs
+++ b/SysML2.NET.Serializer.TextualNotation/Writers/TypeTextualNotationBuilder.cs
@@ -49,8 +49,13 @@ private static void BuildActionBodyItemHandCoded(IType poco, TextualNotationWrit
{
var ownedRelationshipCursor = writerContext.CursorCache.GetOrCreateCursor(poco.Id, OwnedRelationshipCollection, poco.OwnedRelationship);
- while (ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Root.Elements.IRelationship actionBodyItem
- && actionBodyItem.IsValidForActionBodyItem(writerContext))
+ // ActionBodyItem is a SINGLE item; the repetition belongs to the body rules that call it, and
+ // every caller supplies that loop. Draining the cursor here would run past the terminator a
+ // caller is waiting for — CalculationBodyPart stops its own loop at the ResultExpressionMember,
+ // so a greedy sweep consumed the constraint's result expression through the generic member path
+ // and the expression never reached BuildResultExpressionMember.
+ if (ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Root.Elements.IRelationship actionBodyItem
+ && actionBodyItem.IsValidForActionBodyItem(writerContext))
{
switch (ownedRelationshipCursor.Current)
{
@@ -64,6 +69,8 @@ private static void BuildActionBodyItemHandCoded(IType poco, TextualNotationWrit
MembershipTextualNotationBuilder.BuildInitialNodeMemberFromReference(membershipForInitialNode, writerContext, stringBuilder);
ownedRelationshipCursor.Move();
+ // ( ownedRelationship += ActionTargetSuccessionMember )* belongs to THIS alternative,
+ // so the trailing loop stays inside the item.
while (ownedRelationshipCursor.Current is IFeatureMembership targetSuccession && targetSuccession.IsValidForActionTargetSuccessionMember(writerContext))
{
FeatureMembershipTextualNotationBuilder.BuildActionTargetSuccessionMember(targetSuccession, writerContext, stringBuilder);