From 754bf0729bb1512c421a53c4f77472dabf424290 Mon Sep 17 00:00:00 2001 From: atheate Date: Wed, 19 Aug 2026 13:33:31 +0200 Subject: [PATCH] Fix #295 --- .../Extensions/GrammarErrata.cs | 112 +++++++++++ .../Extensions/GrammarErratum.cs | 74 ++++++++ .../UmlCoreTextualNotationBuilderGenerator.cs | 8 + .../TextualNotationSpecificationVisitor.cs | 7 +- .../RuleProcessor.ElementProcessing.cs | 6 + .../08-Requirements/8-Requirements.sysml | 160 ++++++++++++++++ .../TextualNotationValidationTestFixture.cs | 1 + ...LiteralExpressionTextualNotationBuilder.cs | 6 +- .../LiteralRationalTextualNotationBuilder.cs | 53 ++++++ .../SharedTextualNotationBuilder.cs | 17 -- .../Writers/IndentedStringBuilder.cs | 32 ++++ .../Writers/SharedTextualNotationBuilder.cs | 179 ++++++++++-------- .../Writers/TypeTextualNotationBuilder.cs | 11 +- 13 files changed, 569 insertions(+), 97 deletions(-) create mode 100644 SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs create mode 100644 SysML2.NET.CodeGenerator/Extensions/GrammarErratum.cs create mode 100644 SysML2.NET.Serializer.TextualNotation.Tests/Expected/08-Requirements/8-Requirements.sysml create mode 100644 SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/LiteralRationalTextualNotationBuilder.cs diff --git a/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs b/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs new file mode 100644 index 000000000..1157dc542 --- /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 000000000..b521910f5 --- /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 ece96791e..c87d44275 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 2a17f4dd6..1a2345877 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 19712ed83..18e9c9d07 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 000000000..8837273d7 --- /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 93d17874c..c93170c97 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 4bc7becaa..7389a70c9 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 000000000..a9ce3082b --- /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 8fd3c16a7..fa1e10279 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 7f9a6ad3a..038f5c8b2 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 67395865b..495ccce6d 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 29bc58ab4..2be5fbbe1 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);