diff --git a/SysML2.NET.CodeGenerator.Tests/Extensions/ErratumRecordsTestFixture.cs b/SysML2.NET.CodeGenerator.Tests/Extensions/ErratumRecordsTestFixture.cs new file mode 100644 index 00000000..1e3152ea --- /dev/null +++ b/SysML2.NET.CodeGenerator.Tests/Extensions/ErratumRecordsTestFixture.cs @@ -0,0 +1,131 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// 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.Tests.Extensions +{ + using System; + + using NUnit.Framework; + + using SysML2.NET.CodeGenerator.Extensions; + + /// + /// Covers the records that carry a correction or invariant, all of which refuse to be constructed + /// without a justification — the property that keeps the tables auditable. + /// + [TestFixture] + public class ErratumRecordsTestFixture + { + [Test] + public void VerifyOclErratum() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(() => new OclErratum(null, "replacement", "justification"), Throws.TypeOf()); + Assert.That(() => new OclErratum(" ", "replacement", "justification"), Throws.TypeOf()); + Assert.That(() => new OclErratum("original", null, "justification"), Throws.TypeOf()); + Assert.That(() => new OclErratum("original", " ", "justification"), Throws.TypeOf()); + Assert.That(() => new OclErratum("original", "replacement", null), Throws.TypeOf()); + Assert.That(() => new OclErratum("original", "replacement", " "), Throws.TypeOf()); + } + + var erratum = new OclErratum("original", "replacement", "justification"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(erratum.Original, Is.EqualTo("original")); + Assert.That(erratum.Replacement, Is.EqualTo("replacement")); + Assert.That(erratum.Justification, Is.EqualTo("justification")); + } + } + + [Test] + public void VerifyGrammarErratum() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(() => new GrammarErratum(null, "target", "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarErratum(" ", "target", "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarErratum("rule", null, "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarErratum("rule", " ", "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarErratum("rule", "target", null), Throws.TypeOf()); + Assert.That(() => new GrammarErratum("rule", "target", " "), Throws.TypeOf()); + } + + var erratum = new GrammarErratum("rule", "target", "justification"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(erratum.RuleName, Is.EqualTo("rule")); + Assert.That(erratum.TargetElementName, Is.EqualTo("target")); + Assert.That(erratum.Justification, Is.EqualTo("justification")); + } + } + + [Test] + public void VerifyGrammarProductionErratum() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(() => new GrammarProductionErratum(null, "original", "replacement", "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarProductionErratum(" ", "original", "replacement", "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarProductionErratum("rule", null, "replacement", "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarProductionErratum("rule", " ", "replacement", "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarProductionErratum("rule", "original", null, "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarProductionErratum("rule", "original", " ", "justification"), Throws.TypeOf()); + Assert.That(() => new GrammarProductionErratum("rule", "original", "replacement", null), Throws.TypeOf()); + Assert.That(() => new GrammarProductionErratum("rule", "original", "replacement", " "), Throws.TypeOf()); + } + + var erratum = new GrammarProductionErratum("rule", "original", "replacement", "justification"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(erratum.RuleName, Is.EqualTo("rule")); + Assert.That(erratum.Original, Is.EqualTo("original")); + Assert.That(erratum.Replacement, Is.EqualTo("replacement")); + Assert.That(erratum.Justification, Is.EqualTo("justification")); + } + } + + [Test] + public void VerifyNotationInvariant() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(() => new NotationInvariant(null, "metamodelName", "justification"), Throws.TypeOf()); + Assert.That(() => new NotationInvariant(" ", "metamodelName", "justification"), Throws.TypeOf()); + Assert.That(() => new NotationInvariant("name", null, "justification"), Throws.TypeOf()); + Assert.That(() => new NotationInvariant("name", " ", "justification"), Throws.TypeOf()); + Assert.That(() => new NotationInvariant("name", "metamodelName", null), Throws.TypeOf()); + Assert.That(() => new NotationInvariant("name", "metamodelName", " "), Throws.TypeOf()); + } + + var invariant = new NotationInvariant("name", "metamodelName", "justification"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(invariant.Name, Is.EqualTo("name")); + Assert.That(invariant.MetamodelName, Is.EqualTo("metamodelName")); + Assert.That(invariant.Justification, Is.EqualTo("justification")); + } + } + } +} diff --git a/SysML2.NET.CodeGenerator.Tests/Extensions/GrammarErrataTestFixture.cs b/SysML2.NET.CodeGenerator.Tests/Extensions/GrammarErrataTestFixture.cs new file mode 100644 index 00000000..e9d0b657 --- /dev/null +++ b/SysML2.NET.CodeGenerator.Tests/Extensions/GrammarErrataTestFixture.cs @@ -0,0 +1,108 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// 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.Tests.Extensions +{ + using System.Linq; + + using NUnit.Framework; + + using SysML2.NET.CodeGenerator.Extensions; + + [TestFixture] + public class GrammarErrataTestFixture + { + /// + /// The production the CaseBodyItem erratum corrects, quoted exactly as the grammar carries it. + /// + private const string CaseBodyItemOriginal = "CaseBodyItem : Type =\r\n ActionBodyItem"; + + [Test] + public void VerifyApplyProductions() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(GrammarErrata.ApplyProductions(null), Is.Null); + Assert.That(GrammarErrata.ApplyProductions(string.Empty), Is.Empty); + Assert.That(GrammarErrata.ApplyProductions(" "), Is.EqualTo(" ")); + } + + // A grammar carrying none of the corrected productions is returned untouched. + const string unrelated = "Foo : Bar =\r\n Baz"; + + Assert.That(GrammarErrata.ApplyProductions(unrelated), Is.EqualTo(unrelated)); + + var corrected = GrammarErrata.ApplyProductions(CaseBodyItemOriginal); + + using (Assert.EnterMultipleScope()) + { + Assert.That(corrected, Is.Not.EqualTo(CaseBodyItemOriginal)); + Assert.That(corrected, Does.Contain("CalculationBodyItem")); + Assert.That(corrected, Does.Not.Contain(" ActionBodyItem")); + + // Re-applying a correction to already-corrected text is a no-op: the Original stops matching. + Assert.That(GrammarErrata.ApplyProductions(corrected), Is.EqualTo(corrected)); + } + + // The correction is applied verbatim wherever it appears, leaving surrounding text intact. + var embedded = GrammarErrata.ApplyProductions($"// leading\r\n{CaseBodyItemOriginal}\r\n// trailing"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(embedded, Does.StartWith("// leading")); + Assert.That(embedded, Does.EndWith("// trailing")); + Assert.That(embedded, Does.Contain("CalculationBodyItem")); + } + } + + [Test] + public void VerifyQueryUnappliedErrata() + { + // ApplyProductions above marks the CaseBodyItem entry applied, so whatever remains must be + // reportable: every stale entry has to carry the rule name and the reason it was recorded. + var unapplied = GrammarErrata.QueryUnappliedErrata(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(unapplied, Is.Not.Null); + Assert.That(unapplied.All(erratum => !string.IsNullOrWhiteSpace(erratum.RuleName)), Is.True); + Assert.That(unapplied.All(erratum => !string.IsNullOrWhiteSpace(erratum.Justification)), Is.True); + } + } + + [Test] + public void VerifyApplyTarget() + { + using (Assert.EnterMultipleScope()) + { + // A target the grammar states itself always wins — an erratum only fills a gap. + Assert.That(GrammarErrata.ApplyTarget("LiteralReal", "AlreadyStated"), Is.EqualTo("AlreadyStated")); + + // A rule with no erratum keeps its (absent) target. + Assert.That(GrammarErrata.ApplyTarget("NoSuchRule", null), Is.Null); + Assert.That(GrammarErrata.ApplyTarget(null, null), Is.Null); + Assert.That(GrammarErrata.ApplyTarget(" ", null), Is.Null); + + // The KEBNF names this rule LiteralReal, but the metaclass is LiteralRational. + Assert.That(GrammarErrata.ApplyTarget("LiteralReal", null), Is.EqualTo("LiteralRational")); + } + } + } +} diff --git a/SysML2.NET.CodeGenerator.Tests/Extensions/NotationInvariantsTestFixture.cs b/SysML2.NET.CodeGenerator.Tests/Extensions/NotationInvariantsTestFixture.cs new file mode 100644 index 00000000..79361482 --- /dev/null +++ b/SysML2.NET.CodeGenerator.Tests/Extensions/NotationInvariantsTestFixture.cs @@ -0,0 +1,80 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// 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.Tests.Extensions +{ + using System.Linq; + + using NUnit.Framework; + + using SysML2.NET.CodeGenerator.Extensions; + + [TestFixture] + public class NotationInvariantsTestFixture + { + [Test] + public void VerifyQueryMetamodelName() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(NotationInvariants.QueryMetamodelName(NotationInvariants.ResultMemberMetaclass), Is.EqualTo("ReturnParameterMembership")); + Assert.That(NotationInvariants.QueryMetamodelName(NotationInvariants.ImpliedDirectionProperty), Is.EqualTo("direction")); + Assert.That(NotationInvariants.QueryMetamodelName("NoSuchInvariant"), Is.Null); + Assert.That(NotationInvariants.QueryMetamodelName(null), Is.Null); + } + } + + [Test] + public void VerifyQueryMetaclass() + { + // Without a cache source there is nothing to resolve against, and an unknown key has no name to + // resolve — neither may throw, because both simply disable the rule the invariant backs. + using (Assert.EnterMultipleScope()) + { + Assert.That(NotationInvariants.QueryMetaclass(NotationInvariants.ResultMemberMetaclass, null), Is.Null); + Assert.That(NotationInvariants.QueryMetaclass("NoSuchInvariant", null), Is.Null); + Assert.That(NotationInvariants.QueryMetaclass(null, null), Is.Null); + } + } + + [Test] + public void VerifyQueryUnresolvedInvariants() + { + NotationInvariants.MarkResolved(NotationInvariants.ImpliedDirectionProperty); + + // Marking must be tolerant: a blank key records nothing rather than corrupting the set. + NotationInvariants.MarkResolved(null); + NotationInvariants.MarkResolved(" "); + + var unresolved = NotationInvariants.QueryUnresolvedInvariants(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(unresolved, Is.Not.Null); + Assert.That(unresolved.Any(invariant => invariant.Name == NotationInvariants.ImpliedDirectionProperty), Is.False); + + // An unresolved entry has to be actionable: it names the anchor that went missing and why it + // mattered, because the rule it backs is silently off until it is re-anchored. + Assert.That(unresolved.All(invariant => !string.IsNullOrWhiteSpace(invariant.MetamodelName)), Is.True); + Assert.That(unresolved.All(invariant => !string.IsNullOrWhiteSpace(invariant.Justification)), Is.True); + } + } + } +} diff --git a/SysML2.NET.CodeGenerator.Tests/SysML2.NET.CodeGenerator.Tests.csproj b/SysML2.NET.CodeGenerator.Tests/SysML2.NET.CodeGenerator.Tests.csproj index a4f371c3..3ea2b698 100644 --- a/SysML2.NET.CodeGenerator.Tests/SysML2.NET.CodeGenerator.Tests.csproj +++ b/SysML2.NET.CodeGenerator.Tests/SysML2.NET.CodeGenerator.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs b/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs index 1157dc54..ca9c7856 100644 --- a/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs +++ b/SysML2.NET.CodeGenerator/Extensions/GrammarErrata.cs @@ -25,25 +25,30 @@ namespace SysML2.NET.CodeGenerator.Extensions using System.Linq; /// - /// Supplies the target metaclass for KEBNF rules whose name does not match the metaclass they build, - /// at generation time. + /// Corrects known defects in the KEBNF grammar carried under Resources/, 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. + /// The KEBNF files are OMG source and are never edited, and the generated output is never hand-edited + /// either — so a defect can only be corrected here, on the way from the one to the other. The files are + /// mechanically extracted from the specification document, while the pilot implementation's parser is a + /// separately hand-maintained Xtext grammar; the two drift, and 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). + /// Two kinds of correction, because the defects differ in kind: + /// — a rule whose name does not match the metaclass it builds and which + /// omits the explicit target the grammar normally writes in that case + /// (RequirementKind : RequirementConstraintMembership). Without it the rule name resolves to no + /// metaclass, and the generator falls back to inferring one from the assigned property names, silently + /// selecting an unrelated class that happens to declare the same property. + /// — a production whose token sequence cannot derive notation the + /// metamodel and the specification's own normative examples require. Applied to the grammar TEXT before + /// it is parsed, so the corrected production flows through the normal pipeline and nothing downstream + /// needs to special-case the rule. + /// Scope is deliberately narrow, and the bar for both kinds is the same: the generator would + /// otherwise produce output that is WRONG, not merely different. A production that admits more than one + /// valid spelling is NOT an erratum — choosing between admissible spellings is the writer's business. + /// The reference test is whether the pilot's Xtext grammar accepts what we emit: where it does, any + /// difference is a style choice; where it cannot, the grammar is genuinely deficient. /// 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. @@ -59,6 +64,34 @@ public static class GrammarErrata "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 production-text corrections applied to the grammar before it is parsed. + /// + /// + /// An entry belongs here only when the grammar cannot derive the notation at all. A production that + /// merely admits a spelling we do not emit is NOT an erratum — see the class remarks. + /// + private static readonly GrammarProductionErratum[] ProductionEntries = + [ + new("CaseBodyItem", + "CaseBodyItem : Type =\r\n ActionBodyItem", + "CaseBodyItem : Type =\r\n CalculationBodyItem", + "SysML 8.2.2.22.1 gives CaseBodyItem the alternative 'ActionBodyItem', which reaches no " + + "ReturnParameterMember, so 'return' cannot be written in a case body. Three independent " + + "sources say it must be: (1) the pilot implementation's own grammar uses " + + "'CalculationBodyItem' here (org.omg.sysml.xtext SysML.xtext, rule CaseBodyItem), and " + + "CalculationBodyItem = ActionBodyItem | ReturnParameterMember; (2) the metamodel permits it " + + "— constraint validateReturnParameterMembershipOwningType requires the owningType of a " + + "ReturnParameterMembership to be a Function or Expression, and VerificationCaseUsage " + + "specializes CaseUsage specializes CalculationUsage specializes Expression; (3) the " + + "normative example in SysML 7.24.2 writes 'return verdict : VerdictKind = " + + "evaluateData.verdict;' inside a 'verification def' body. There is no admissible " + + "alternative spelling: rendering the ReturnParameterMembership through the generic " + + "parameter path emits 'out verdict', which re-parses as a plain FeatureMembership with " + + "direction out and so loses the metaclass. CalculationBodyItem is already declared in the " + + "same file, so the replacement resolves without any further correction.") + ]; + /// /// The corrections that have matched at least one rule during this generator run. /// @@ -97,16 +130,57 @@ public static string ApplyTarget(string ruleName, string targetElementName) } /// - /// Returns the corrections that matched no rule during this generator run. + /// Applies every known production correction to the raw text of a KEBNF file. + /// + /// The grammar text as read from disk. + /// + /// The corrected grammar text, or unchanged when nothing applies. + /// + /// + /// Correcting the text rather than the parsed rule keeps the correction in the grammar's own + /// language: the entry reads as the production OMG should have written, and every consumer parses + /// it exactly as it parses the rest of the file. Each Original is matched verbatim, so a + /// correction cannot partially match, and re-applying it to already-corrected text is a no-op. + /// Both KEBNF files are passed through this, so an entry only fires against the file that carries + /// its production. + /// + public static string ApplyProductions(string kebnfSource) + { + if (string.IsNullOrWhiteSpace(kebnfSource)) + { + return kebnfSource; + } + + return ProductionEntries + .Where(erratum => kebnfSource.Contains(erratum.Original, StringComparison.Ordinal)) + .Aggregate(kebnfSource, (corrected, erratum) => + { + AppliedRuleNames.Add(erratum.RuleName); + + return corrected.Replace(erratum.Original, erratum.Replacement); + }); + } + + /// + /// Returns the corrections that matched nothing during this generator run. /// - /// The stale entries, which should be pruned from . + /// The stale entries, which should be pruned. /// - /// 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. + /// Only meaningful once every grammar file has been loaded and every rule read. A stale entry means + /// the grammar no longer carries the defect — either OMG corrected it, or the rule was renamed or + /// removed. /// - public static IReadOnlyList QueryUnappliedErrata() + public static IReadOnlyList<(string RuleName, string Justification)> QueryUnappliedErrata() { - return [..Entries.Where(erratum => !AppliedRuleNames.Contains(erratum.RuleName))]; + return + [ + ..Entries + .Where(erratum => !AppliedRuleNames.Contains(erratum.RuleName)) + .Select(erratum => (erratum.RuleName, erratum.Justification)), + ..ProductionEntries + .Where(erratum => !AppliedRuleNames.Contains(erratum.RuleName)) + .Select(erratum => (erratum.RuleName, erratum.Justification)) + ]; } } } diff --git a/SysML2.NET.CodeGenerator/Extensions/GrammarProductionErratum.cs b/SysML2.NET.CodeGenerator/Extensions/GrammarProductionErratum.cs new file mode 100644 index 00000000..36b4d5a9 --- /dev/null +++ b/SysML2.NET.CodeGenerator/Extensions/GrammarProductionErratum.cs @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// 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 the text of a KEBNF production before it is parsed. + /// + public sealed class GrammarProductionErratum + { + /// + /// Initializes a new instance of the class. + /// + /// The rule the correction belongs to, used when reporting staleness. + /// The exact production text the grammar carries. + /// The text it is corrected to. + /// The evidence that the original is a defect rather than intent. + /// Thrown when any argument is null or whitespace. + public GrammarProductionErratum(string ruleName, string original, string replacement, string justification) + { + if (string.IsNullOrWhiteSpace(ruleName)) + { + throw new ArgumentException("The rule name is required.", nameof(ruleName)); + } + + if (string.IsNullOrWhiteSpace(original)) + { + throw new ArgumentException("The original production text is required.", nameof(original)); + } + + if (string.IsNullOrWhiteSpace(replacement)) + { + throw new ArgumentException("The replacement production text is required.", nameof(replacement)); + } + + if (string.IsNullOrWhiteSpace(justification)) + { + throw new ArgumentException("A justification is required so the correction can be audited.", nameof(justification)); + } + + this.RuleName = ruleName; + this.Original = original; + this.Replacement = replacement; + this.Justification = justification; + } + + /// + /// Gets the rule the correction belongs to. + /// + public string RuleName { get; } + + /// + /// Gets the exact production text the grammar carries. + /// + public string Original { get; } + + /// + /// Gets the text it is corrected to. + /// + public string Replacement { get; } + + /// + /// Gets the evidence that the original is a defect rather than intent. + /// + public string Justification { get; } + } +} diff --git a/SysML2.NET.CodeGenerator/Extensions/NotationInvariant.cs b/SysML2.NET.CodeGenerator/Extensions/NotationInvariant.cs new file mode 100644 index 00000000..f36765ef --- /dev/null +++ b/SysML2.NET.CodeGenerator/Extensions/NotationInvariant.cs @@ -0,0 +1,79 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// 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 notation invariant: a rule the writer must honour that neither the KEBNF nor the metamodel + /// states machine-readably, anchored to the OMG name it depends on. + /// + public sealed class NotationInvariant + { + /// + /// Initializes a new instance of the class. + /// + /// The stable key the generator refers to the invariant by. + /// The OMG metaclass or property name the invariant depends on. + /// Why the invariant holds, and what breaks without it. + /// Thrown when any argument is null or whitespace. + public NotationInvariant(string name, string metamodelName, string justification) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("The invariant name is required.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(metamodelName)) + { + throw new ArgumentException("The metamodel name is required.", nameof(metamodelName)); + } + + if (string.IsNullOrWhiteSpace(justification)) + { + throw new ArgumentException("A justification is required so the invariant can be audited.", nameof(justification)); + } + + this.Name = name; + this.MetamodelName = metamodelName; + this.Justification = justification; + } + + /// + /// Gets the stable key the generator refers to the invariant by. + /// + /// + /// Deliberately independent of : the generator names the CONCEPT, so an + /// OMG rename is a single edit here rather than a hunt through the emission code. + /// + public string Name { get; } + + /// + /// Gets the OMG metaclass or property name the invariant depends on. + /// + public string MetamodelName { get; } + + /// + /// Gets the reason the invariant holds, and what breaks without it. + /// + public string Justification { get; } + } +} diff --git a/SysML2.NET.CodeGenerator/Extensions/NotationInvariants.cs b/SysML2.NET.CodeGenerator/Extensions/NotationInvariants.cs new file mode 100644 index 00000000..26e7cd9e --- /dev/null +++ b/SysML2.NET.CodeGenerator/Extensions/NotationInvariants.cs @@ -0,0 +1,143 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// 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; + + using SysML2.NET.CodeGenerator.HandleBarHelpers; + + using uml4net.CommonStructure; + using uml4net.StructuredClassifiers; + + /// + /// The OMG names the textual-notation generator depends on for rules that neither the KEBNF nor the + /// metamodel states machine-readably. + /// + /// + /// Some notation rules cannot be derived. The grammar describes token order, and the metamodel describes + /// structure, but neither records that a KEYWORD already conveys a property, nor that a member is always + /// singular. The generator therefore has to know a small number of OMG names outright. + /// Holding them here rather than inline achieves the two things that matter on an OMG release: + /// every dependency is auditable in ONE place with its justification, and a name that stops resolving is + /// REPORTED () instead of silently disabling the rule it backs — + /// which is how the emission bugs these invariants fix would quietly return. + /// Prefer derivation whenever it exists; add an entry only when it does not. Two derivations were + /// tried and rejected for the direction rule below: the metamodel's own + /// ParameterMembership::parameterDirection() is polymorphic but returns in on the base, so + /// suppressing whenever the direction matches it would strip every in parameter; and the "must be + /// out" statement on ReturnParameterMembership is class documentation, not one of its OCL constraints. + /// + public static class NotationInvariants + { + /// + /// The metaclass of a member that the enclosing rule always consumes on its own. + /// + public const string ResultMemberMetaclass = "ResultMemberMetaclass"; + + /// + /// The Feature property whose value a result member's own keyword already conveys. + /// + public const string ImpliedDirectionProperty = "ImpliedDirectionProperty"; + + /// + /// The OMG names the generator depends on, keyed by the concept the generator refers to. + /// + private static readonly NotationInvariant[] Entries = + [ + new(ResultMemberMetaclass, "ReturnParameterMembership", + "The grammar never repeats a result member: it always gives one its OWN slot in the enclosing rule (EmptyResultMember, ConstructorResultMember, ReturnParameterMember), never a comma-separated repetition. A repeated '+=' member that shares the enclosing rule's cursor therefore has to exclude it, because the repetition's declared item type is one of its supertypes — ArgumentMember is a ParameterMembership and EmptyResultMember is a ReturnParameterMembership, so the loop consumed the result member, emitted a separator and then rendered nothing: 'f(a, )'."), + new(ImpliedDirectionProperty, "direction", + "The 'return' keyword of ReturnParameterMember already says the parameter is the result, and a result parameter always carries direction = out, so writing the direction as well emits 'return out verdict' where the notation is 'return verdict'. The exclusion cannot go through QuerySubclassesWithMatchingDefault, which suppresses a keyword whose metamodel DEFAULT already matches: Feature::direction is [0..1] and declares no default, so there is nothing to compare against and a null direction genuinely means undirected.") + ]; + + /// + /// The invariants whose OMG name resolved against the metamodel during this generator run. + /// + private static readonly HashSet ResolvedNames = []; + + /// + /// Returns the OMG name an invariant depends on. + /// + /// The invariant's stable key. + /// The OMG name, or when no entry carries that key. + public static string QueryMetamodelName(string invariantName) + { + return Entries.SingleOrDefault(invariant => string.Equals(invariant.Name, invariantName, StringComparison.Ordinal))?.MetamodelName; + } + + /// + /// Resolves the metaclass an invariant depends on, recording that its name still exists. + /// + /// The invariant's stable key. + /// Any from the loaded model, used to reach the cache. + /// The resolved , or when it no longer exists. + /// + /// A null return disables the rule the invariant backs, which is why the miss is recorded rather than + /// swallowed: turns it into a message on the next run. + /// + public static IClass QueryMetaclass(string invariantName, IClass cacheSource) + { + var metamodelName = QueryMetamodelName(invariantName); + + if (metamodelName == null || cacheSource == null) + { + return null; + } + + var metaclass = RuleQueryUtilities.FindClass(cacheSource.Cache, metamodelName); + + if (metaclass != null) + { + ResolvedNames.Add(invariantName); + } + + return metaclass; + } + + /// + /// Records that an invariant's property name still matches a property the grammar assigns. + /// + /// The invariant's stable key. + public static void MarkResolved(string invariantName) + { + if (!string.IsNullOrWhiteSpace(invariantName)) + { + ResolvedNames.Add(invariantName); + } + } + + /// + /// Returns the invariants whose OMG name resolved against nothing during this generator run. + /// + /// The unresolved entries, whose rules are consequently NOT being applied. + /// + /// Only meaningful once generation has completed. An unresolved entry means OMG renamed or removed + /// the name the invariant hangs on, so the emission rule it backs is silently off and the entry needs + /// re-anchoring — not pruning, since the underlying notation rule still holds. + /// + public static IReadOnlyList QueryUnresolvedInvariants() + { + return [..Entries.Where(invariant => !ResolvedNames.Contains(invariant.Name))]; + } + } +} diff --git a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs index c87d4427..29812bdb 100644 --- a/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs +++ b/SysML2.NET.CodeGenerator/Generators/UmlHandleBarsGenerators/UmlCoreTextualNotationBuilderGenerator.cs @@ -123,6 +123,14 @@ public async Task GenerateAsync(XmiReaderResult xmiReaderResult, TextualNotation await this.GenerateBuilderClasses(xmiReaderResult, textualNotationSpecification, outputDirectory); await this.GenerateSharedBuilder(xmiReaderResult, textualNotationSpecification, outputDirectory); // await this.GenerateBuilderFacade(xmiReaderResult, outputDirectory); + + // Every rule has now been generated, so an invariant whose OMG name resolved against nothing is + // no longer being applied. Reported rather than thrown: the emission rule it backs is off, which + // is a defect to re-anchor, but it must not stop generation. + foreach (var unresolved in NotationInvariants.QueryUnresolvedInvariants()) + { + Console.WriteLine($"[NotationInvariants] UNRESOLVED — {unresolved.Name} is anchored to '{unresolved.MetamodelName}', which the metamodel no longer carries, so the rule it backs is NOT applied. Recorded reason: {unresolved.Justification}"); + } } /// diff --git a/SysML2.NET.CodeGenerator/Grammar/GrammarLoader.cs b/SysML2.NET.CodeGenerator/Grammar/GrammarLoader.cs index 9aa3d884..8272d24f 100644 --- a/SysML2.NET.CodeGenerator/Grammar/GrammarLoader.cs +++ b/SysML2.NET.CodeGenerator/Grammar/GrammarLoader.cs @@ -24,6 +24,7 @@ namespace SysML2.NET.CodeGenerator.Grammar using Antlr4.Runtime; + using SysML2.NET.CodeGenerator.Extensions; using SysML2.NET.CodeGenerator.Grammar.Model; /// @@ -44,7 +45,9 @@ public static TextualNotationSpecification LoadTextualNotationSpecification(stri throw new FileNotFoundException("File not found", fileUri); } - var stream = CharStreams.fromPath(fileUri); + // The KEBNF files are OMG source and are never edited, so a defective production is corrected + // here, on the way into the parser — see GrammarErrata.ApplyProductions. + var stream = CharStreams.fromString(GrammarErrata.ApplyProductions(File.ReadAllText(fileUri))); var lexer = new kebnfLexer(stream); var tokens = new CommonTokenStream(lexer); var parser = new kebnfParser(tokens); diff --git a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs index 18e9c9d0..6d93e911 100644 --- a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs +++ b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs @@ -21,6 +21,7 @@ namespace SysML2.NET.CodeGenerator.HandleBarHelpers { using System; + using System.Collections.Generic; using System.Linq; using HandlebarsDotNet; @@ -99,7 +100,7 @@ internal void ProcessRuleElement(EncodedTextWriter writer, IClass umlClass, Rule } else { - groupTypeGuard = $" && {cursorToUse.CursorVariableName}.Current is {targetClass.QueryFullyQualifiedTypeName()}"; + groupTypeGuard = $" && {cursorToUse.CursorVariableName}.Current is {targetClass.QueryFullyQualifiedTypeName()}{ResolveResultMemberExclusion(cursorToUse, targetClass, umlClass)}"; } } } @@ -313,6 +314,51 @@ private static string ResolveTrailingConsumptionReservation(CursorDefinition cur : $" && {cursorDefinition.CursorVariableName}.GetNext(1) is {trailingTypeName}"; } + /// + /// Builds the extra while clause keeping a repeated += member from consuming a + /// ReturnParameterMembership. + /// + /// The cursor the repeated group consumes from. + /// The class the repetition's own type guard tests for. + /// The class hosting the current rule (provides the UML cache). + /// The additional guard clause, or an empty string when the repetition cannot match one. + /// + /// The grammar never repeats a result member: it always gives one its OWN slot in the enclosing rule + /// (EmptyResultMember, ConstructorResultMember, ReturnParameterMember), never a + /// comma-separated repetition. A repetition that shares the enclosing rule's cursor will therefore + /// swallow it whenever the repetition's guard type happens to be one of its supertypes: + /// + /// InvocationExpression = ownedRelationship += InstantiatedTypeMember + /// ArgumentList + /// ownedRelationship += EmptyResultMember + /// PositionalArgumentList = ownedRelationship += ArgumentMember + /// ( ',' ownedRelationship += ArgumentMember )* + /// + /// ArgumentMember : ParameterMembership and EmptyResultMember : ReturnParameterMembership + /// — a ParameterMembership — so the loop emits a separator for it and then renders nothing: + /// f(a, ). + /// Gating on subtype overlap keeps this general without touching repetitions it cannot affect. + /// Of the fifteen comma-repetition shapes across both grammars only two — ArgumentMember and + /// NamedArgumentMember — guard on a supertype of ReturnParameterMembership; the rest test + /// relationship types or sibling membership subtypes, for which the clause would be dead code. + /// + private static string ResolveResultMemberExclusion(CursorDefinition cursorDefinition, IClass itemTargetClass, IClass umlClass) + { + var resultMemberClass = NotationInvariants.QueryMetaclass(NotationInvariants.ResultMemberMetaclass, umlClass); + + if (resultMemberClass == null || itemTargetClass == null) + { + return string.Empty; + } + + var repetitionAdmitsResultMember = string.Equals(resultMemberClass.Name, itemTargetClass.Name, StringComparison.Ordinal) + || resultMemberClass.QueryAllGeneralClassifiers().Any(general => string.Equals(general.Name, itemTargetClass.Name, StringComparison.Ordinal)); + + return repetitionAdmitsResultMember + ? $" && {cursorDefinition.CursorVariableName}.Current is not {resultMemberClass.QueryFullyQualifiedTypeName()}" + : string.Empty; + } + /// /// Processes an /// @@ -456,8 +502,39 @@ internal void ProcessAssignmentElement(EncodedTextWriter writer, IClass umlClass } else if (targetProperty.QueryIsEnum()) { + // A result member's own keyword already conveys the direction, so writing it + // again emits `return out verdict` where the notation is `return verdict`. + // Testing the owning Membership follows the metamodel's own idiom — Feature's + // parameter-redefinition constraint selects parameters with `direction <> null` + // and then rejects those whose owningFeatureMembership is a result membership. + // Both OMG names come from NotationInvariants, which reports either of them + // going missing rather than letting this rule switch itself off. + var impliedDirectionProperty = NotationInvariants.QueryMetamodelName(NotationInvariants.ImpliedDirectionProperty); + var isImpliedDirectionProperty = string.Equals(targetProperty.Name, impliedDirectionProperty, StringComparison.Ordinal); + + if (isImpliedDirectionProperty) + { + // Marked on the NAME matching, independently of the metaclass below, so a + // report names only the anchor that actually went missing. + NotationInvariants.MarkResolved(NotationInvariants.ImpliedDirectionProperty); + } + + var resultMemberClass = NotationInvariants.QueryMetaclass(NotationInvariants.ResultMemberMetaclass, umlClass); + var suppressForResultParameter = isImpliedDirectionProperty && resultMemberClass != null; + + if (suppressForResultParameter) + { + writer.WriteSafeString($"if (poco.owningMembership is not {resultMemberClass.QueryFullyQualifiedTypeName()}){Environment.NewLine}"); + writer.WriteSafeString($"{{{Environment.NewLine}"); + } + writer.WriteSafeString($"stringBuilder.Append(poco.{targetPropertyName}.ToString().ToLower());{Environment.NewLine}"); writer.WriteSafeString("stringBuilder.Append(' ');"); + + if (suppressForResultParameter) + { + writer.WriteSafeString($"{Environment.NewLine}}}"); + } } else if (targetProperty.QueryIsReferenceType()) { diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Expected/09-Verification/9-Verification-simplified.sysml b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/09-Verification/9-Verification-simplified.sysml new file mode 100644 index 00000000..ba922d98 --- /dev/null +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/09-Verification/9-Verification-simplified.sysml @@ -0,0 +1,82 @@ +package '9-Verification-simplified' { + private import VerificationCases::*; + private import Definitions::*; + package Definitions { + requirement def <'2'> MassRequirement { + attribute massActual :> ISQ::mass; + attribute massReqd :> ISQ::mass; + doc + /* The actual mass shall be less than or equal to the required mass limit. */ + require constraint { massActual <= massReqd } + } + part def Vehicle { + attribute mass :> ISQ::mass; + } + part def MassVerificationSystem; + part def Scale; + part def TestOperator; + individual def TestVehicle1 :> Vehicle; + individual def TestVehicle2 :> Vehicle; + individual def TestSystem :> MassVerificationSystem; + verification def MassTest { + objective massVerificationObjective { + verify requirement massRequirement: '2'; + } + } + } + package Usages { + requirement <'2.1'> vehicleMassRequirement: '2' { + subject vehicle: Vehicle; + doc + /* The vehicle mass shall be less than or equal to 2500 kg. */ + :>> massActual = vehicle.mass; + :>> massReqd = 2500[SI::kg]; + } + part vehicle1_c2: Vehicle; + verification vehicleMassTest: MassTest { + subject testVehicle: Vehicle; + objective vehicleMassVerificationObjective { + verify '2.1' :>> massRequirement; + } + action collectData { + in part testVehicle: Vehicle = vehicleMassTest.testVehicle; + out massMeasured :> ISQ::mass; + } + action processData { + in massMeasured :> ISQ::mass = collectData.massMeasured; + out massProcessed :> ISQ::mass; + } + action evaluateData { + in massProcessed :> ISQ::mass = processData.massProcessed; + out verdict: VerdictKind = PassIf('2.1'(vehicle = new testVehicle(mass = massProcessed))); + } + return verdict: VerdictKind = evaluateData.verdict; + } + part massVerificationSystem: MassVerificationSystem { + perform vehicleMassTest { + in part :>> testVehicle = vehicleUnderTest; + } + ref part vehicleUnderTest: Vehicle; + part testOperator: TestOperator; + part scale: Scale { + perform vehicleMassTest.collectData { + in part :>> testVehicle; + ref measurement = testVehicle.mass; + out :>> massMeasured = measurement; + } + } + } + individual testSystem: TestSystem :> massVerificationSystem { + timeslice test1 { + ref individual :>> vehicleUnderTest : TestVehicle1 :> vehicle1_c2 { + :>> mass = 2500[SI::kg]; + } + } + then timeslice test2 { + ref individual :>> vehicleUnderTest : TestVehicle2 :> vehicle1_c2 { + :>> mass = 2500[SI::kg]; + } + } + } + } +} diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs b/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs index c93170c9..52b2a05f 100644 --- a/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs @@ -105,6 +105,7 @@ public void OneTimeTearDown() [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")] + [TestCase("09-Verification", "9-Verification-simplified.sysmlx")] public async Task VerifyValidationTextualNotationXmi(string folderName, string fileName) { var loggerFactory = LoggerFactory.Create(builder => diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs index 3f1ddc1e..dd752e05 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs @@ -609,8 +609,11 @@ public static void BuildBasicFeaturePrefix(SysML2.NET.Core.POCO.Core.Features.IF if (poco.Direction.HasValue) { - stringBuilder.Append(poco.Direction.ToString().ToLower()); - stringBuilder.Append(' '); + if (poco.owningMembership is not SysML2.NET.Core.POCO.Kernel.Functions.IReturnParameterMembership) + { + stringBuilder.Append(poco.Direction.ToString().ToLower()); + stringBuilder.Append(' '); + } stringBuilder.Append(' '); } @@ -1053,7 +1056,7 @@ public static void BuildPositionalArgumentList(SysML2.NET.Core.POCO.Core.Feature } } - while (ownedRelationshipCursor.Current != null && ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Kernel.Behaviors.IParameterMembership) + while (ownedRelationshipCursor.Current != null && ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Kernel.Behaviors.IParameterMembership && ownedRelationshipCursor.Current is not SysML2.NET.Core.POCO.Kernel.Functions.IReturnParameterMembership) { stringBuilder.Append(", "); @@ -1093,7 +1096,7 @@ public static void BuildNamedArgumentList(SysML2.NET.Core.POCO.Core.Features.IFe } } - while (ownedRelationshipCursor.Current != null && ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Core.Types.IFeatureMembership) + while (ownedRelationshipCursor.Current != null && ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Core.Types.IFeatureMembership && ownedRelationshipCursor.Current is not SysML2.NET.Core.POCO.Kernel.Functions.IReturnParameterMembership) { stringBuilder.Append(", "); diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs index b2ab917e..8b1acd1f 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs @@ -387,7 +387,7 @@ public static void BuildCaseBody(SysML2.NET.Core.POCO.Core.Types.IType poco, Tex /// /// Builds the Textual Notation string for the rule CaseBodyItem - /// CaseBodyItem:Type=ActionBodyItem|ownedRelationship+=SubjectMember|ownedRelationship+=ActorMember|ownedRelationship+=ObjectiveMember + /// CaseBodyItem:Type=CalculationBodyItem|ownedRelationship+=SubjectMember|ownedRelationship+=ActorMember|ownedRelationship+=ObjectiveMember /// /// The from which the rule should be build /// The providing the serialization context for the current @@ -412,7 +412,7 @@ public static void BuildCaseBodyItem(SysML2.NET.Core.POCO.Core.Types.IType poco, } else { - BuildActionBodyItem(poco, writerContext, stringBuilder); + BuildCalculationBodyItem(poco, writerContext, stringBuilder); } } diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs index b491b39f..66a1a9d7 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs @@ -66,8 +66,11 @@ public static void BuildRefPrefix(SysML2.NET.Core.POCO.Systems.DefinitionAndUsag if (poco.Direction.HasValue) { - stringBuilder.Append(poco.Direction.ToString().ToLower()); - stringBuilder.Append(' '); + if (poco.owningMembership is not SysML2.NET.Core.POCO.Kernel.Functions.IReturnParameterMembership) + { + stringBuilder.Append(poco.Direction.ToString().ToLower()); + stringBuilder.Append(' '); + } stringBuilder.Append(' '); } diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs b/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs index 7e620c73..68093929 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs @@ -472,7 +472,11 @@ private string ResolveFresh(IElement target, ReferenceSite site, string escapedN ancestor = QueryOwningContainer(ancestor); } - return target.qualifiedName ?? string.Empty; + var shortQualifiedName = QueryShortQualifiedName(target); + + return string.IsNullOrWhiteSpace(shortQualifiedName) + ? target.qualifiedName ?? string.Empty + : shortQualifiedName; } /// diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs index 495ccce6..541366ea 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs @@ -721,7 +721,15 @@ internal static IFeature QueryEffectiveOwnedMemberFeature(IFeatureMembership mem { var direct = membership?.ownedMemberFeature; - if (membership is IParameterMembership && direct != null) + // A NAMED argument must NOT be unwrapped. Both argument forms arrive as a ParameterMembership, + // but they need different features: a positional Argument is a bare wrapper around its + // ArgumentValue, so the expression inside is what the notation writes, whereas a NamedArgument + // (`ownedRelationship += ParameterRedefinition '=' ownedRelationship += ArgumentValue`) IS the + // feature to write — unwrapping it hands BuildNamedArgument the value expression, which owns + // neither the redefinition nor the value, so it emits a bare `=`. The redefinition is the same + // signal IsValidForPositionalArgumentList uses to tell the two apart. + if (membership is IParameterMembership && direct != null + && !direct.OwnedRelationship.OfType().Any()) { var featureValue = direct.OwnedRelationship.OfType().FirstOrDefault(); diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs b/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs index 136838a3..e5c5d1e2 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs @@ -140,16 +140,30 @@ internal static bool IsValidForInvertingPart(this IFeature feature, TextualNotat /// /// Asserts that the is valid for the PositionalArgumentList rule. /// PositionalArgumentList : Feature = ownedRelationship += ArgumentMember (',' ownedRelationship += ArgumentMember)* - /// Matches when the cursor is positioned at an - /// (positional arguments) — the alternative NamedArgumentList uses plain - /// members. + /// The two argument-list alternatives are told apart by whether the member's parameter + /// carries a ParameterRedefinition, NOT by the metaclass of the Membership: + /// Argument : Feature = ownedRelationship += ArgumentValue — positional, value only. + /// NamedArgument : Feature = ownedRelationship += ParameterRedefinition '=' + /// ownedRelationship += ArgumentValue — named, redefinition first. /// /// The /// The active - /// True if the cursor's current element is an + /// True if the cursor is at an argument member whose parameter has no redefinition + /// + /// The KEBNF declares ArgumentMember : ParameterMembership against + /// NamedArgumentMember : FeatureMembership, which reads as though the Membership metaclass + /// discriminated the two. It does not: IS an + /// , and a named argument arrives as a ParameterMembership too, so a + /// test on the Membership alone matches BOTH and routes every named argument down the positional + /// path — where BuildArgument looks for an ArgumentValue, finds the + /// ParameterRedefinition instead, and emits nothing at all. + /// The redefinition is the reliable signal, and it is what the grammar actually keys on: only + /// NamedArgument owns one. + /// internal static bool IsValidForPositionalArgumentList(this IFeature feature, TextualNotationWriterContext writerContext) { - return QueryCurrentOwnedRelationship(feature, writerContext) is IParameterMembership; + return QueryCurrentOwnedRelationship(feature, writerContext) is IParameterMembership parameterMembership + && parameterMembership.ownedMemberParameter?.OwnedRelationship.OfType().Any() != true; } ///