diff --git a/SysML2.NET.CodeGenerator/Extensions/PropertyExtension.cs b/SysML2.NET.CodeGenerator/Extensions/PropertyExtension.cs index f91aeb062..7910d64f5 100644 --- a/SysML2.NET.CodeGenerator/Extensions/PropertyExtension.cs +++ b/SysML2.NET.CodeGenerator/Extensions/PropertyExtension.cs @@ -140,6 +140,62 @@ public static string QueryIfStatementContentForNonEmpty(this IProperty property, return "THIS WILL PRODUCE COMPILE ERROR"; } + /// + /// Finds the minimal set of subclasses of (including + /// itself) that directly redefine with a <defaultValue> + /// equal to . Used by the textual-notation codegen to suppress + /// emission of a ?= 'literal' keyword when the property's runtime value matches the + /// metamodel default for the concrete subtype. + /// "Minimal" means: if a class C is in the set and any of C's ancestors is also in the set, C + /// is removed (the C# is check on the ancestor already matches C at runtime). + /// Comparison of to the property's defaultValue is + /// done via , which already normalises booleans + /// ("true" / "false"), enum literals, integers, and strings. + /// + /// The base property whose redefinitions are being scanned (e.g. Usage::isReference). + /// The metaclass under which to scan (typically the textual rule's effective target). + /// The default-value string that should trigger inclusion in the exclusion set (e.g. "true"). + /// The minimal list of redefining classes; empty when no subclass redefines with the matching default. + public static IReadOnlyList QuerySubclassesWithMatchingDefault(this IProperty property, IClass rootClass, string literalTriggerValue) + { + ArgumentNullException.ThrowIfNull(property); + ArgumentNullException.ThrowIfNull(rootClass); + ArgumentException.ThrowIfNullOrWhiteSpace(literalTriggerValue); + + var allClasses = rootClass.Cache.Values.OfType(); + var introducers = new List(); + + foreach (var candidateClass in allClasses) + { + if (candidateClass != rootClass && !candidateClass.QueryAllGeneralClassifiers().Contains(rootClass)) + { + continue; + } + + foreach (var ownedAttribute in candidateClass.OwnedAttribute) + { + if (!string.Equals(ownedAttribute.Name, property.Name, StringComparison.Ordinal)) + { + continue; + } + + var defaultValueAsString = ownedAttribute.QueryDefaultValueAsString(); + + if (string.Equals(defaultValueAsString, literalTriggerValue, StringComparison.OrdinalIgnoreCase)) + { + introducers.Add(candidateClass); + break; + } + } + } + + // Reduce to the minimal set: drop any class whose ancestor is already present, because the + // C# `poco is not IAncestor` check at runtime already covers the descendant. + return introducers + .Where(candidate => !introducers.Any(other => other != candidate && candidate.QueryAllGeneralClassifiers().Contains(other))) + .ToList(); + } + /// /// Returns every from the owning class's OwnedRule that /// applies to the given derived . The XMI shipped with this diff --git a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs index d95050a20..a5293391b 100644 --- a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs +++ b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.ElementProcessing.cs @@ -374,7 +374,19 @@ internal void ProcessAssignmentElement(EncodedTextWriter writer, IClass umlClass { if (!isPartOfMultipleAlternative && assignmentElement.Container is not GroupElement { IsOptional: true }) { - writer.WriteSafeString($"if({targetProperty.QueryIfStatementContentForNonEmpty("poco")}){Environment.NewLine}"); + // KEBNF `Prop ?= 'literal'` — emit the literal when the runtime + // value is truthy, but suppress it for concrete subtypes whose + // metamodel default already equals the literal-trigger value. + // For those subtypes the keyword is structurally redundant and + // the canonical idiomatic source omits it (see e.g. SysML + // `attribute X` rather than `ref attribute X` because + // AttributeUsage's `isReference` default is `true`). + var exclusionTypes = targetProperty.QuerySubclassesWithMatchingDefault(umlClass, "true"); + var exclusionClause = exclusionTypes.Count == 0 + ? string.Empty + : $" && poco is not ({string.Join(" or ", exclusionTypes.Select(c => c.QueryFullyQualifiedTypeName()))})"; + + writer.WriteSafeString($"if({targetProperty.QueryIfStatementContentForNonEmpty("poco")}{exclusionClause}){Environment.NewLine}"); writer.WriteSafeString($"{{{Environment.NewLine}"); writer.WriteSafeString($"stringBuilder.Append(\" {terminalElement.Value} \");{Environment.NewLine}"); writer.WriteSafeString('}'); diff --git a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.cs b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.cs index db10565e9..8496f721b 100644 --- a/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.cs +++ b/SysML2.NET.CodeGenerator/HandleBarHelpers/RuleProcessor.cs @@ -393,7 +393,25 @@ private void ProcessSingleAlternative(EncodedTextWriter writer, IClass umlClass, } else { - ifStatementContent.Add(property.QueryIfStatementContentForNonEmpty("poco")); + var condition = property.QueryIfStatementContentForNonEmpty("poco"); + + // For `Prop ?= 'literal'` keyword assignments inside an optional `(...)?` + // group, exclude concrete subtypes whose metamodel default for the + // assigned property already equals the literal-trigger value — the + // keyword is structurally redundant for those subtypes and the + // canonical source omits it (e.g. `attribute X` rather than + // `ref attribute X` because AttributeUsage::isReference defaults to true). + if (property.QueryIsBool()) + { + var exclusionTypes = property.QuerySubclassesWithMatchingDefault(umlClass, "true"); + + if (exclusionTypes.Count > 0) + { + condition += $" && poco is not ({string.Join(" or ", exclusionTypes.Select(c => c.QueryFullyQualifiedTypeName()))})"; + } + } + + ifStatementContent.Add(condition); } } @@ -526,6 +544,16 @@ private void ProcessSingleElementAlternatives(EncodedTextWriter writer, IClass u /// The distinct element types across all alternatives private void ProcessMixedTypeSingleElementAlternatives(EncodedTextWriter writer, IClass umlClass, IReadOnlyCollection alternatives, RuleGenerationContext ruleGenerationContext, List types) { + if (this.TryEmitSubclassRuleDispatchAlternatives(writer, umlClass, alternatives, ruleGenerationContext)) + { + return; + } + + if (this.TryEmitSingleElementOrSameClassRuleAlternatives(writer, umlClass, alternatives, ruleGenerationContext)) + { + return; + } + if (types.SequenceEqual([typeof(AssignmentElement), typeof(NonTerminalElement)])) { foreach (var alternative in alternatives) @@ -916,6 +944,206 @@ private static bool TryEmitQualifiedNameOrChainAlternatives(EncodedTextWriter wr return true; } + /// + /// Attempts to emit code for the subclass-rule dispatch pattern: + /// property = X | SubclassRule, where SubclassRule targets a strict + /// specialization of the current rule's target metaclass (e.g. + /// FeatureChainMember : Membership = memberElement = [QualifiedName] | OwnedFeatureChainMember + /// with OwnedFeatureChainMember : OwningMembership). The runtime subtype is the + /// discriminator: only an instance of the subclass can be the subclass-rule alternative, + /// so the emitted code dispatches on the POCO's runtime type FIRST and only falls back to + /// the assignment alternative for base-class instances. Emitting the alternatives in + /// grammar order instead would put a derived-property null check (e.g. + /// MemberElement != null, never null on an OwningMembership) in front, + /// rendering the subclass alternative unreachable. + /// + /// The used to write output + /// The related + /// The grammar alternatives to process + /// The current + /// true if the pattern matched and code was emitted; false otherwise + private bool TryEmitSubclassRuleDispatchAlternatives(EncodedTextWriter writer, IClass umlClass, IReadOnlyCollection alternatives, RuleGenerationContext ruleGenerationContext) + { + if (alternatives.Count != 2) + { + return false; + } + + var assignmentAlt = alternatives.FirstOrDefault(alt => + alt.Elements.Count == 1 + && alt.Elements[0] is AssignmentElement { Operator: "=" }); + + var subclassRuleAlt = alternatives.FirstOrDefault(alt => + alt.Elements.Count == 1 + && alt.Elements[0] is NonTerminalElement); + + if (assignmentAlt == null || subclassRuleAlt == null) + { + return false; + } + + var assignmentElement = (AssignmentElement)assignmentAlt.Elements[0]; + var nonTerminalElement = (NonTerminalElement)subclassRuleAlt.Elements[0]; + + var targetProperty = umlClass.QueryAllProperties().SingleOrDefault(x => + string.Equals(x.Name, assignmentElement.Property, StringComparison.OrdinalIgnoreCase)); + + if (targetProperty == null || targetProperty.QueryIsEnumerable()) + { + return false; + } + + var referencedRule = ruleGenerationContext.FindRule(nonTerminalElement.Name); + + if (referencedRule == null) + { + return false; + } + + var subclass = RuleQueryUtilities.FindClass(umlClass.Cache, referencedRule.EffectiveTarget); + + if (subclass == null || subclass == umlClass || !subclass.QueryAllGeneralClassifiers().Contains(umlClass)) + { + return false; + } + + var variableName = ruleGenerationContext.CurrentVariableName ?? "poco"; + var subclassTypeName = subclass.QueryFullyQualifiedTypeName(); + var patternVariableName = $"{subclass.Name.LowerCaseFirstLetter()}{ruleGenerationContext.NarrowedTypeCheckCounter}"; + ruleGenerationContext.NarrowedTypeCheckCounter++; + + writer.WriteSafeString($"if ({variableName} is {subclassTypeName} {patternVariableName}){Environment.NewLine}"); + writer.WriteSafeString($"{{{Environment.NewLine}"); + writer.WriteSafeString($"{subclass.Name}TextualNotationBuilder.Build{nonTerminalElement.Name}({patternVariableName}, writerContext, stringBuilder);{Environment.NewLine}"); + writer.WriteSafeString($"}}{Environment.NewLine}"); + + // A NonTerminal-valued assignment emits its own null guard inside ProcessAssignmentElement; + // only a value-literal assignment (e.g. [QualifiedName]) needs the guard supplied here. + if (assignmentElement.Value is ValueLiteralElement) + { + writer.WriteSafeString($"else if ({targetProperty.QueryIfStatementContentForNonEmpty(variableName)}){Environment.NewLine}"); + } + else + { + writer.WriteSafeString($"else{Environment.NewLine}"); + } + + writer.WriteSafeString($"{{{Environment.NewLine}"); + this.ProcessAssignmentElement(writer, umlClass, ruleGenerationContext, assignmentElement, true); + writer.WriteSafeString($"{Environment.NewLine}}}{Environment.NewLine}"); + + return true; + } + + /// + /// Attempts to emit code for the single-element-or-same-class-rule pattern: + /// collection += X | SameClassRule, where SameClassRule targets the current + /// rule's own metaclass and re-consumes the same collection property (e.g. + /// ChainingPart : Feature = 'chains' (ownedRelationship += OwnedFeatureChaining | FeatureChain) + /// with FeatureChain : Feature = ownedRelationship += OwnedFeatureChaining ('.' ownedRelationship += OwnedFeatureChaining)+). + /// Because the same-class rule consumes two or more elements of the += value type, + /// the discriminator is the element count: exactly one matching element selects the single + /// += alternative (with its Golden-Rule Move()), otherwise the same-class + /// rule is delegated to and manages the shared cursor itself. A bare + /// cursor.Current != null discriminator would make the same-class alternative + /// unreachable and, without the Move(), stall the caller's dispatch loop. + /// + /// The used to write output + /// The related + /// The grammar alternatives to process + /// The current + /// true if the pattern matched and code was emitted; false otherwise + private bool TryEmitSingleElementOrSameClassRuleAlternatives(EncodedTextWriter writer, IClass umlClass, IReadOnlyCollection alternatives, RuleGenerationContext ruleGenerationContext) + { + if (alternatives.Count != 2) + { + return false; + } + + var collectionAlt = alternatives.FirstOrDefault(alt => + alt.Elements.Count == 1 + && alt.Elements[0] is AssignmentElement { Operator: "+=", Value: NonTerminalElement }); + + var sameClassRuleAlt = alternatives.FirstOrDefault(alt => + alt.Elements.Count == 1 + && alt.Elements[0] is NonTerminalElement); + + if (collectionAlt == null || sameClassRuleAlt == null) + { + return false; + } + + var assignmentElement = (AssignmentElement)collectionAlt.Elements[0]; + var nonTerminalElement = (NonTerminalElement)sameClassRuleAlt.Elements[0]; + + var referencedRule = ruleGenerationContext.FindRule(nonTerminalElement.Name); + + if (referencedRule == null || !string.Equals(referencedRule.EffectiveTarget, umlClass.Name, StringComparison.Ordinal)) + { + return false; + } + + // The same-class rule must re-consume the same collection property THROUGH THE SAME + // sub-rule (e.g. FeatureChain re-consumes ownedRelationship += OwnedFeatureChaining): + // only then do the two alternatives compete for the same element type and need the + // count discriminator. Disjoint element types (e.g. CalculationBodyItem's + // ReturnParameterMember vs ActionBodyItem) stay with the plain type dispatch. + var elementValueNonTerminal = (NonTerminalElement)assignmentElement.Value; + + var reconsumesSameElements = referencedRule.Alternatives + .SelectMany(alt => alt.Elements) + .OfType() + .Any(innerAssignment => innerAssignment is { Operator: "+=", Value: NonTerminalElement innerNonTerminal } + && string.Equals(innerAssignment.Property, assignmentElement.Property, StringComparison.OrdinalIgnoreCase) + && string.Equals(innerNonTerminal.Name, elementValueNonTerminal.Name, StringComparison.Ordinal)); + + if (!reconsumesSameElements) + { + return false; + } + + var targetProperty = umlClass.QueryAllProperties().SingleOrDefault(x => + string.Equals(x.Name, assignmentElement.Property, StringComparison.OrdinalIgnoreCase)); + + if (targetProperty == null || !targetProperty.QueryIsEnumerable()) + { + return false; + } + + var elementTypeName = ResolveAssignmentTargetTypeName(assignmentElement, umlClass, ruleGenerationContext); + var elementRule = ruleGenerationContext.FindRule(elementValueNonTerminal.Name); + var elementTypeTarget = elementRule?.EffectiveTarget; + var sameClassRuleCall = ResolveBuilderCall(umlClass, nonTerminalElement, referencedRule.EffectiveTarget, ruleGenerationContext); + + if (elementTypeName == null || elementTypeTarget == null || sameClassRuleCall == null) + { + return false; + } + + this.DeclareAllRequiredCursors(writer, umlClass, collectionAlt, ruleGenerationContext); + var cursor = ruleGenerationContext.DefinedCursors.Single(x => x.ApplicableRuleElements.Contains(assignmentElement)); + + var variableName = ruleGenerationContext.CurrentVariableName ?? "poco"; + var propertyAccessor = targetProperty.QueryPropertyNameBasedOnUmlProperties(); + var elementVariableName = $"elementAs{elementTypeTarget}"; + + var singleElementBuilderCall = elementTypeTarget == ruleGenerationContext.NamedElementToGenerate.Name + ? $"Build{elementValueNonTerminal.Name}({elementVariableName}, writerContext, stringBuilder);" + : $"{elementTypeTarget}TextualNotationBuilder.Build{elementValueNonTerminal.Name}({elementVariableName}, writerContext, stringBuilder);"; + + writer.WriteSafeString($"if ({variableName}.{propertyAccessor}.OfType<{elementTypeName}>().Count() == 1 && {cursor.CursorVariableName}.Current is {elementTypeName} {elementVariableName}){Environment.NewLine}"); + writer.WriteSafeString($"{{{Environment.NewLine}"); + writer.WriteSafeString($"{singleElementBuilderCall}{Environment.NewLine}"); + writer.WriteSafeString($"{cursor.CursorVariableName}.Move();{Environment.NewLine}"); + writer.WriteSafeString($"}}{Environment.NewLine}"); + writer.WriteSafeString($"else{Environment.NewLine}"); + writer.WriteSafeString($"{{{Environment.NewLine}"); + writer.WriteSafeString($"{sameClassRuleCall}{Environment.NewLine}"); + writer.WriteSafeString($"}}{Environment.NewLine}"); + + return true; + } + /// /// Emits the terminal-vs-body pattern where the first alternative is a single terminal /// (e.g., ;) and the second alternative is a body with collection assignments or @@ -1039,7 +1267,27 @@ private void EmitTerminalVsBodyWithCollectionNonTerminals(EncodedTextWriter writ var propertyAccessName = targetProperty.QueryPropertyNameBasedOnUmlProperties(); - writer.WriteSafeString($"if(writerContext.CursorCache.GetOrCreateCursor(poco.Id, \"{targetProperty.Name}\", poco.{propertyAccessName}).Current == null){Environment.NewLine}"); + // KEBNF `XBody : Type = ';' | '{' XBodyItem* '}'` — both the choice and the `*` loop + // are bounded by "does the current cursor element match an XBodyItem alternative?". + // For body item rules whose dispatcher can encounter unrecognised elements legitimately + // belonging to a parent rule (notably PortDefinition's trailing + // ConjugatedPortDefinitionMember, which appears in OwnedRelationship but is NOT a + // DefinitionBodyItem alternative), the body rule must defer to an + // `IsValidFor{XBodyItem}` predicate. Other body rules retain the simple + // `cursor.Current != null` semantics; we promote rules into the guarded form by name. + var requiresIsValidForGuard = IsGuardedBodyItemRule(collectionNonTerminals[0].Name); + var guardCallSuffix = requiresIsValidForGuard + ? $".IsValidFor{collectionNonTerminals[0].Name}(writerContext)" + : string.Empty; + + if (requiresIsValidForGuard) + { + writer.WriteSafeString($"if (writerContext.CursorCache.GetOrCreateCursor(poco.Id, \"{targetProperty.Name}\", poco.{propertyAccessName}).Current is not SysML2.NET.Core.POCO.Root.Elements.IRelationship emptyBodyCandidate || !emptyBodyCandidate{guardCallSuffix}){Environment.NewLine}"); + } + else + { + writer.WriteSafeString($"if(writerContext.CursorCache.GetOrCreateCursor(poco.Id, \"{targetProperty.Name}\", poco.{propertyAccessName}).Current == null){Environment.NewLine}"); + } writer.WriteSafeString($"{{{Environment.NewLine}"); writer.WriteSafeString($"stringBuilder.AppendLine(\"{terminalValue}\");{Environment.NewLine}"); @@ -1063,7 +1311,15 @@ private void EmitTerminalVsBodyWithCollectionNonTerminals(EncodedTextWriter writ var perItemCall = ResolveBuilderCall(umlClass, collectionNonTerminal, typeTarget, ruleGenerationContext); - writer.WriteSafeString($"while ({cursorVarName}.Current != null){Environment.NewLine}"); + if (requiresIsValidForGuard) + { + writer.WriteSafeString($"while ({cursorVarName}.Current is SysML2.NET.Core.POCO.Root.Elements.IRelationship loopBodyItem && loopBodyItem{guardCallSuffix}){Environment.NewLine}"); + } + else + { + writer.WriteSafeString($"while ({cursorVarName}.Current != null){Environment.NewLine}"); + } + writer.WriteSafeString($"{{{Environment.NewLine}"); if (perItemCall != null) @@ -1178,5 +1434,28 @@ private void EmitTerminalVsBodyWithSingleNonTerminal(EncodedTextWriter writer, I writer.WriteSafeString($"}}{Environment.NewLine}"); } + + /// + /// Returns true when the KEBNF body-item rule named can have + /// elements ahead of the cursor that legitimately belong to a parent rule (and therefore must + /// NOT be consumed by the body's * loop). For these rules, + /// emits the ; / { … } choice and the * loop as + /// IsValidFor{XBodyItem}-guarded code rather than a raw cursor.Current != null + /// check, faithfully implementing the KEBNF * quantifier semantics ("iterate while + /// the current element matches an alternative") for the affected rules. + /// Allowlist (rather than allow-all) keeps the codegen change scoped: only rules whose + /// dispatcher can encounter foreign elements need the guard. Currently this is the two + /// rules whose body item includes the DefinitionMember alternative — DefinitionBodyItem + /// (consumed by PortDefinition's trailing ConjugatedPortDefinitionMember that + /// the body must skip) and InterfaceBodyItem (same shape). All other body item rules + /// retain the existing cursor.Current != null semantics. + /// + /// The KEBNF rule name of the body item (e.g. DefinitionBodyItem) + /// true if the codegen should emit the guarded form + private static bool IsGuardedBodyItemRule(string bodyItemRuleName) + { + return string.Equals(bodyItemRuleName, "DefinitionBodyItem", StringComparison.Ordinal) + || string.Equals(bodyItemRuleName, "InterfaceBodyItem", StringComparison.Ordinal); + } } } diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Expected/01-Parts Tree/1a-Parts Tree.sysml b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/01-Parts Tree/1a-Parts Tree.sysml new file mode 100644 index 000000000..df22bd851 --- /dev/null +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/01-Parts Tree/1a-Parts Tree.sysml @@ -0,0 +1,105 @@ +package '1a-Parts Tree' { + private import SI::kg; + package Definitions { + part def Vehicle { + attribute mass :> ISQ::mass { + doc + /* + * The 'mass' attribute property is declared here to be a + * specialization (subset) of the general 'mass' quantity + * from the 'ISQ' (International System of Quantities) + * library model. + */ + + } + } + part def AxleAssembly; + part def Axle { + attribute mass :> ISQ::mass; + } + part def FrontAxle :> Axle { + attribute steeringAngle: ScalarValues::Real; + } + part def Wheel; + } + package Usages { + private import Definitions::* { + /* + * A "private" private import makes the imported names private to the + * imported package. + */ + } + ref part vehicle1: Vehicle { + /* + * 'vehicle1' is a package-owned part of type Vehicle. + */ + attribute mass :>> Vehicle::mass = 1750[kg] { + /* + * This redefines the 'mass' attribute property from 'Vehicle' to + * give it a fixed attribute. + */ + } + part frontAxleAssembly: AxleAssembly { + /* + * 'frontAxleAssembly' is a nested part of part 'vehicle1'. + * It is a composite part of the containing part. + * (And similarly for 'rearAxleAssembly'.) + */ + part frontAxle: Axle; + part frontWheel: Wheel[2] ordered { + /* + * 'frontWheel' is a nested part of type 'Wheel' with + * multiplicity "2". This means that this axle assembly + * must have exactly two wheels. However, there is still + * only one 'frontWheel' part. The part is "ordered", + * so that the first wheel can be distinguished from the + * second. + */ + } + } + part rearAxleAssembly: AxleAssembly { + part rearAxle: Axle; + part rearWheel: Wheel[2] ordered; + } + } + ref part vehicle1_c1: Vehicle { + /* + * 'vehicle1_c1' is a modified copy of 'vehicle1'. There is no + * connection between this copy and the original version in the + * model. + */ + attribute mass :>> Vehicle::mass = 2000[kg] { + /* + * The mass attribute has been modified. + */ + } + part frontAxleAssembly: AxleAssembly { + part frontAxle: FrontAxle { + /* + * The part 'frontAxle' has been modified to have type 'FrontAxle'. + */ + } + part frontWheel: Wheel[2] ordered { + /* + * The parts 'frontWheel_1' and 'frontWheel_2' have been added + * as subsets of 'frontWheel'. These are separate parts from + * 'frontWheel', but essentially provide alternate names for + * each of the two wheels, as given by their defining expressions. + */ + } + part frontWheel_1 :> frontWheel = frontWheel#(1); + part frontWheel_2 :> frontWheel = frontWheel#(2); + } + part rearAxleAssembly: AxleAssembly { + /* + * 'rearAxleAssembly' has also been modified to add subsetting parts + * for 'rearWheel'. + */ + part rearAxle: Axle; + part rearWheel: Wheel[2] ordered; + part rearWheel_1 :> rearWheel = rearWheel#(1); + part rearWheel_2 :> rearWheel = rearWheel#(2); + } + } + } +} diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Expected/01-Parts Tree/1c-Parts Tree Redefinition.sysml b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/01-Parts Tree/1c-Parts Tree Redefinition.sysml new file mode 100644 index 000000000..f77508b30 --- /dev/null +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/01-Parts Tree/1c-Parts Tree Redefinition.sysml @@ -0,0 +1,75 @@ +package '1c-Parts Tree Redefinition' { + private import SI::kg; + package Definitions { + part def Vehicle { + attribute mass :> ISQ::mass; + } + part def AxleAssembly; + part def Axle { + attribute mass :> ISQ::mass; + } + part def FrontAxle :> Axle { + attribute steeringAngle: ScalarValues::Real; + } + part def Wheel; + } + package Usages { + private import Definitions::*; + ref part vehicle1: Vehicle { + attribute mass :>> Vehicle::mass default = 1750[kg] { + doc + /* + * The mass attribute is redefined to give it a default value. + */ + + } + part frontAxleAssembly: AxleAssembly { + part frontAxle: Axle; + part frontWheel: Wheel[2] ordered; + } + part rearAxleAssembly: AxleAssembly { + part rearAxle: Axle; + part rearWheel: Wheel[2] ordered; + } + } + ref part vehicle1_c1 :> vehicle1 { + /* + * 'vehicle1_c1' is a specialization of 'vehicle1' (technically + * a subset). It inherits all the parts of 'vehicle1' and + * only needs to specify additional or redefined parts. + */ + attribute mass :>> vehicle1::mass = 2000[kg] { + /* + * The mass is further redefined to override the default value + * with a bound value for 'vehicle_c1'. + */ + } + part frontAxleAssembly_c1 :>> frontAxleAssembly { + part frontAxle_c1: FrontAxle :>> frontAxle { + /* + * 'frontAxle_c1' redefines 'frontAxleAssembly'::'frontAxle' + * to give it a new name and the specialized type + * 'FrontAxle'. + */ + } + /* + * 'frontWheel' is inherited from 'vehicle1'::'frontAxleAssembly', + * allowing it to be used in the following part declarations. + */ + part frontWheel_1 :> frontWheel = frontWheel#(1); + part frontWheel_2 :> frontWheel = frontWheel#(2); + } + part rearAxleAssembly_c1 :>> rearAxleAssembly { + part rearAxle_c1 :>> rearAxle { + /* + * 'rearAxle_c1' redefines 'rearAxleAssembly'::'rearAxle' + * to give it a new name. It inherits the type 'Axle' + * from the redefined part. + */ + } + part rearWheel_1 :> rearWheel = rearWheel#(1); + part rearWheel_2 :> rearWheel = rearWheel#(2); + } + } + } +} diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Expected/01-Parts Tree/1d-Parts Tree with Reference.sysml b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/01-Parts Tree/1d-Parts Tree with Reference.sysml new file mode 100644 index 000000000..bf5ad2c62 --- /dev/null +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/01-Parts Tree/1d-Parts Tree with Reference.sysml @@ -0,0 +1,44 @@ +package '1d-Parts Tree with Reference' { + package Definitions { + part def Vehicle; + part def Trailer; + part def TrailerHitch; + part def HitchBall; + part def TrailerCoupler; + } + package Usages { + private import Definitions::*; + ref part vehicle_trailer_system { + part vehicle1_c1: Vehicle { + ref hitchBall: HitchBall { + /* + * 'vehicle1_c1'::'hitchBall' is a reference property that + * references a hitch ball that is not part of this vehicle. + * If 'vehicle1_c1' is removed or destroyed, this does not + * effect the hitchBall referenced here. + */ + } + } + ref bind vehicle1_c1.hitchBall = trailerHitch.hitchBall { + /* + * This is a binding connector between the 'hitchBall' in 'vehicle1_c1' + * and the 'hitchBall' in 'trailerHitch'. + */ + } + part trailerHitch: TrailerHitch { + part hitchBall: HitchBall; + part trailerCoupler: TrailerCoupler; + } + part trailer1: Trailer { + ref trailerCoupler: TrailerCoupler = trailerHitch.trailerCoupler { + /* + * This is a shorthand for a binding connector between the + * 'trailerCoupler' here and the 'trailerCoupler' in 'trailerHitch'. + * The binding connector is now contained within the 'trailer1' + * part, though, rather than being at the system level. + */ + } + } + } + } +} diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Expected/02-Parts Interconnection/2a-Parts Interconnection.sysml b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/02-Parts Interconnection/2a-Parts Interconnection.sysml new file mode 100644 index 000000000..755f93651 --- /dev/null +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/02-Parts Interconnection/2a-Parts Interconnection.sysml @@ -0,0 +1,153 @@ +package '2a-Parts Interconnection' { + public import Definitions::*; + public import Usages::*; + package Definitions { + port def FuelCmdPort; + port def DrivePwrPort; + port def ClutchPort; + port def ShaftPort_a; + port def ShaftPort_b; + port def ShaftPort_c; + port def ShaftPort_d; + port def DiffPort; + port def AxlePort; + port def AxleToWheelPort; + port def WheelToAxlePort; + port def WheelToRoadPort; + port def VehicleToRoadPort { + /* + * A port definition can have nested ports. + */ + port wheelToRoadPort: WheelToRoadPort[2]; + } + part def VehicleA { + ref port fuelCmdPort: FuelCmdPort; + ref port vehicleToRoadPort: VehicleToRoadPort; + } + part def AxleAssembly; + part def RearAxleAssembly :> AxleAssembly { + ref port shaftPort_d: ShaftPort_d; + } + part def Axle; + part def RearAxle :> Axle; + part def HalfAxle { + ref port axleToDiffPort: AxlePort; + ref port axleToWheelPort: AxleToWheelPort; + } + part def Engine { + ref port fuelCmdPort: FuelCmdPort; + ref port drivePwrPort: DrivePwrPort; + } + part def Transmission { + ref port clutchPort: ClutchPort; + ref port shaftPort_a: ShaftPort_a; + } + part def Driveshaft { + ref port shaftPort_b: ShaftPort_b; + ref port shaftPort_c: ShaftPort_c; + } + part def Differential { + /* + * Ports do not have to be defined on part defs. + * They can be added directly to their usages. + */ + } + part def Wheel; + interface def EngineToTransmissionInterface { + /* + * The ends of an interface definition are always ports. + */ + end drivePwrPort: DrivePwrPort; + end clutchPort: ClutchPort; + } + interface def DriveshaftInterface { + end shaftPort_a: ShaftPort_a; + end shaftPort_d: ShaftPort_d; + ref driveshaft: Driveshaft { + /* + * 'driveshaft' is a reference to the driveshaft that will + * act as the "interface medium" for this interface. + */ + } + connect shaftPort_a to driveshaft.shaftPort_b { + /* + * The two ends of 'DriveShaftInterface' are always connected + * via the referenced 'driveshaft'. + */ + } + connect driveshaft.shaftPort_c to shaftPort_d; + } + } + package Usages { + ref part vehicle1_c1: VehicleA { + ref bind fuelCmdPort = engine.fuelCmdPort; + part engine: Engine; + interface : EngineToTransmissionInterface connect engine.drivePwrPort to transmission.clutchPort { + /* + * A usage of an interface definition connects two ports relative to + * a containing context. + */ + } + part transmission: Transmission; + part driveshaft: Driveshaft { + /* + * This 'driveshaft' is the part of 'vehicle1_c1' that will act as the + * interface medium in the following 'DriveshaftInterface' usage. + */ + } + interface : DriveshaftInterface connect transmission.shaftPort_a to rearAxleAssembly.shaftPort_d { + ref :>> driveshaft = vehicle1_c1.driveshaft { + /* + * The reference property from 'DriveshaftInterface' is redefined + * in order to bind it to the appropriate part of 'vehicle1_c1'. + */ + } + } + part rearAxleAssembly: RearAxleAssembly { + ref bind shaftPort_d = differential.shaftPort_d; + part differential: Differential { + ref port shaftPort_d: ShaftPort_d { + /* + * If the part def has no ports, then they can be defined directly in + * a usage of the part def. + */ + } + ref port leftDiffPort: DiffPort; + ref port rightDiffPort: DiffPort; + } + interface differential.leftDiffPort to rearAxle.leftHalfAxle.axleToDiffPort { + /* + * A connection can be to a port that is arbitrarily deeply nested, on either end. + */ + } + interface differential.rightDiffPort to rearAxle.rightHalfAxle.axleToDiffPort; + part rearAxle: RearAxle { + part leftHalfAxle: HalfAxle; + part rightHalfAxle: HalfAxle; + } + connect rearAxle.leftHalfAxle.axleToWheelPort to leftWheel.wheelToAxlePort; + connect rearAxle.rightHalfAxle.axleToWheelPort to rightWheel.wheelToAxlePort; + part rearWheel: Wheel[2] ordered; + /* + * The two rear wheels of 'rearAxleAssembly' must be given + * their own names in order to be referenced in connections. + * (":>" is a shorthand here for "subsets".) + */ + part leftWheel :> rearWheel = rearWheel#(1) { + ref port wheelToAxlePort: WheelToAxlePort; + ref port wheelToRoadPort: WheelToRoadPort; + } + part rightWheel :> rearWheel = rearWheel#(2) { + ref port wheelToAxlePort: WheelToAxlePort; + ref port wheelToRoadPort: WheelToRoadPort; + } + } + ref bind rearAxleAssembly.leftWheel.wheelToRoadPort = vehicleToRoadPort.leftWheelToRoadPort; + ref bind rearAxleAssembly.rightWheel.wheelToRoadPort = vehicleToRoadPort.rightWheelToRoadPort; + ref port vehicleToRoadPort :>> VehicleA::vehicleToRoadPort { + port leftWheelToRoadPort :> wheelToRoadPort = wheelToRoadPort#(1); + port rightWheelToRoadPort :> wheelToRoadPort = wheelToRoadPort#(2); + } + } + } +} diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Expected/02-Parts Interconnection/2c-Parts Interconnection-Multiple Decompositions.sysml b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/02-Parts Interconnection/2c-Parts Interconnection-Multiple Decompositions.sysml new file mode 100644 index 000000000..7c60b604f --- /dev/null +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Expected/02-Parts Interconnection/2c-Parts Interconnection-Multiple Decompositions.sysml @@ -0,0 +1,76 @@ +package '2c-Parts Interconnection-Multiple Decompositions' { + part def A1; + part def B11 { + ref port pe; + } + part def B12 { + ref port pf; + } + part def B21 { + ref port pg; + } + part def B22 { + ref port ph; + } + part def C1 { + ref port pa; + ref port pb; + } + part def C2 { + ref port pc; + } + part def C3 { + ref port pd; + } + part def C4; + ref part a11: A1 { + doc + /* + * Decomposition 1 - Subsystems b11, b12 + */ + + part b11: B11 { + part c1: C1; + part c2: C2; + connect c1.pa to c2.pc; + ref port :>> pe = c1.pb { + doc + /* + * This combines the definition of a port with a binding + * connector. (It is the same notation used to bind a + * attribute to a attribute property or a reference to a reference + * property.) + */ + + } + } + part b12: B12 { + part c3: C3; + part c4: C4; + ref port :>> pf = c3.pd; + } + connect b11.pe to b12.pf; + } + ref part a12: A1 { + doc + /* + * Decomposition 2 - Assemblies b21, b22 + */ + + part b21: B21 { + /* + * The c-level entities are already composite parts within + * a11, so they cannot also be composite parts within a12. + */ + ref c1: C1 = a11.b11.c1; + ref c3: C3 = a11.b12.c3; + connect c1.pb to c3.pd; + ref port :>> pg = c1.pa; + } + part b22: B22 { + ref c2: C2 = a11.b11.c2; + ref c4: C4 = a11.b12.c4; + ref port :>> ph = c2.pc; + } + } +} diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/SysML2.NET.Serializer.TextualNotation.Tests.csproj b/SysML2.NET.Serializer.TextualNotation.Tests/SysML2.NET.Serializer.TextualNotation.Tests.csproj index d7644f6b8..f5a088659 100644 --- a/SysML2.NET.Serializer.TextualNotation.Tests/SysML2.NET.Serializer.TextualNotation.Tests.csproj +++ b/SysML2.NET.Serializer.TextualNotation.Tests/SysML2.NET.Serializer.TextualNotation.Tests.csproj @@ -61,6 +61,27 @@ Always + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Validation/02-Parts Interconnection/2a-Parts Interconnection.sysmlx b/SysML2.NET.Serializer.TextualNotation.Tests/Validation/02-Parts Interconnection/2a-Parts Interconnection.sysmlx new file mode 100644 index 000000000..5decf171d --- /dev/null +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Validation/02-Parts Interconnection/2a-Parts Interconnection.sysmlx @@ -0,0 +1,997 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Validation/02-Parts Interconnection/2c-Parts Interconnection-Multiple Decompositions.sysmlx b/SysML2.NET.Serializer.TextualNotation.Tests/Validation/02-Parts Interconnection/2c-Parts Interconnection-Multiple Decompositions.sysmlx new file mode 100644 index 000000000..5ae0dd743 --- /dev/null +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Validation/02-Parts Interconnection/2c-Parts Interconnection-Multiple Decompositions.sysmlx @@ -0,0 +1,517 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs b/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs index 3768e5f03..dba1e5439 100644 --- a/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs +++ b/SysML2.NET.Serializer.TextualNotation.Tests/Writers/TextualNotationValidationTestFixture.cs @@ -22,8 +22,6 @@ namespace SysML2.NET.Serializer.TextualNotation.Tests.Writers { using System; using System.IO; - using System.Linq; - using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -41,6 +39,8 @@ public class TextualNotationValidationTestFixture [TestCase("01-Parts Tree", "1a-Parts Tree.sysmlx")] [TestCase("01-Parts Tree", "1c-Parts Tree Redefinition.sysmlx")] [TestCase("01-Parts Tree", "1d-Parts Tree with Reference.sysmlx")] + [TestCase("02-Parts Interconnection", "2a-Parts Interconnection.sysmlx")] + [TestCase("02-Parts Interconnection", "2c-Parts Interconnection-Multiple Decompositions.sysmlx")] public async Task VerifyValidationTextualNotationXmi(string folderName, string fileName) { var loggerFactory = LoggerFactory.Create(builder => @@ -80,6 +80,11 @@ public async Task VerifyValidationTextualNotationXmi(string folderName, string f TestContext.WriteLine("=== Textual Notation Output ==="); TestContext.WriteLine(textualNotation); TestContext.WriteLine("=== End ==="); + + var expectedFilePath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Expected", folderName, fileName.Replace(".sysmlx", ".sysml")); + + var expectedContent = await File.ReadAllTextAsync(expectedFilePath); + Assert.That(expectedContent, Is.EqualTo(textualNotation)); } } } diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/ClassifierTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/ClassifierTextualNotationBuilder.cs index 42ee426a6..1f62924e5 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/ClassifierTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/ClassifierTextualNotationBuilder.cs @@ -85,7 +85,7 @@ public static void BuildClassifierDeclaration(SysML2.NET.Core.POCO.Core.Classifi { var ownedRelationshipCursor = writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelationship", poco.OwnedRelationship); - if (poco.IsSufficient) + if (poco.IsSufficient && poco is not (SysML2.NET.Core.POCO.Systems.Connections.IConnectionDefinition)) { stringBuilder.Append(" all "); stringBuilder.Append(' '); diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs index 36132f046..594623e39 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/FeatureTextualNotationBuilder.cs @@ -714,23 +714,17 @@ public static void BuildChainingPart(SysML2.NET.Core.POCO.Core.Features.IFeature { var ownedRelationshipCursor = writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelationship", poco.OwnedRelationship); stringBuilder.Append("chains "); - if (ownedRelationshipCursor.Current != null) + if (poco.OwnedRelationship.OfType().Count() == 1 && ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Core.Features.IFeatureChaining elementAsFeatureChaining) { - - if (ownedRelationshipCursor.Current != null) - { - - if (ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Core.Features.IFeatureChaining elementAsFeatureChaining) - { - FeatureChainingTextualNotationBuilder.BuildOwnedFeatureChaining(elementAsFeatureChaining, writerContext, stringBuilder); - } - } + FeatureChainingTextualNotationBuilder.BuildOwnedFeatureChaining(elementAsFeatureChaining, writerContext, stringBuilder); + ownedRelationshipCursor.Move(); } else { BuildFeatureChain(poco, writerContext, stringBuilder); } + } /// diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/ImportTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/ImportTextualNotationBuilder.cs index 0274943e0..1a927443b 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/ImportTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/ImportTextualNotationBuilder.cs @@ -67,7 +67,7 @@ public static void BuildImport(SysML2.NET.Core.POCO.Root.Namespaces.IImport poco stringBuilder.Append(' '); stringBuilder.Append("import "); - if (poco.IsImportAll) + if (poco.IsImportAll && poco is not (SysML2.NET.Core.POCO.Systems.Views.IExpose)) { stringBuilder.Append(" all "); stringBuilder.Append(' '); diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/MembershipTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/MembershipTextualNotationBuilder.cs index 2df466889..c5b5e01f4 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/MembershipTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/MembershipTextualNotationBuilder.cs @@ -99,28 +99,16 @@ public static void BuildAliasMember(SysML2.NET.Core.POCO.Root.Namespaces.IMember /// The that accumulates the entire textual notation with indentation public static void BuildFeatureChainMember(SysML2.NET.Core.POCO.Root.Namespaces.IMembership poco, TextualNotationWriterContext writerContext, IndentedStringBuilder stringBuilder) { - - if (poco.MemberElement != null) + if (poco is SysML2.NET.Core.POCO.Root.Namespaces.IOwningMembership owningMembership0) + { + OwningMembershipTextualNotationBuilder.BuildOwnedFeatureChainMember(owningMembership0, writerContext, stringBuilder); + } + else if (poco.MemberElement != null) { SharedTextualNotationBuilder.AppendQualifiedName(stringBuilder, poco.MemberElement, writerContext, poco); stringBuilder.Append(' '); } - else - { - var ownedRelatedElementCursor = writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelatedElement", poco.OwnedRelatedElement); - if (ownedRelatedElementCursor.Current != null) - { - - if (ownedRelatedElementCursor.Current is SysML2.NET.Core.POCO.Core.Features.IFeature elementAsFeature) - { - FeatureTextualNotationBuilder.BuildOwnedFeatureChain(elementAsFeature, writerContext, stringBuilder); - ownedRelatedElementCursor.Move(); - - } - } - - } } /// @@ -169,8 +157,11 @@ public static void BuildElementReferenceMember(SysML2.NET.Core.POCO.Root.Namespa /// The that accumulates the entire textual notation with indentation public static void BuildInstantiatedTypeMember(SysML2.NET.Core.POCO.Root.Namespaces.IMembership poco, TextualNotationWriterContext writerContext, IndentedStringBuilder stringBuilder) { - - if (poco.MemberElement != null) + if (poco is SysML2.NET.Core.POCO.Root.Namespaces.IOwningMembership owningMembership0) + { + OwningMembershipTextualNotationBuilder.BuildOwnedFeatureChainMember(owningMembership0, writerContext, stringBuilder); + } + else { if (poco.MemberElement != null) @@ -180,22 +171,7 @@ public static void BuildInstantiatedTypeMember(SysML2.NET.Core.POCO.Root.Namespa } } - else - { - var ownedRelatedElementCursor = writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelatedElement", poco.OwnedRelatedElement); - if (ownedRelatedElementCursor.Current != null) - { - - if (ownedRelatedElementCursor.Current is SysML2.NET.Core.POCO.Core.Features.IFeature elementAsFeature) - { - FeatureTextualNotationBuilder.BuildOwnedFeatureChain(elementAsFeature, writerContext, stringBuilder); - ownedRelatedElementCursor.Move(); - - } - } - - } } /// diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs index 2bd68f70f..13235b390 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/TypeTextualNotationBuilder.cs @@ -42,7 +42,7 @@ public static partial class TypeTextualNotationBuilder /// The that accumulates the entire textual notation with indentation public static void BuildDefinitionBody(SysML2.NET.Core.POCO.Core.Types.IType poco, TextualNotationWriterContext writerContext, IndentedStringBuilder stringBuilder) { - if (writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelationship", poco.OwnedRelationship).Current == null) + if (writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelationship", poco.OwnedRelationship).Current is not SysML2.NET.Core.POCO.Root.Elements.IRelationship emptyBodyCandidate || !emptyBodyCandidate.IsValidForDefinitionBodyItem(writerContext)) { stringBuilder.AppendLine(";"); } @@ -52,7 +52,7 @@ public static void BuildDefinitionBody(SysML2.NET.Core.POCO.Core.Types.IType poc stringBuilder.AppendLine("{"); stringBuilder.IncreaseIndent(); var ownedRelationshipCursor = writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelationship", poco.OwnedRelationship); - while (ownedRelationshipCursor.Current != null) + while (ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Root.Elements.IRelationship loopBodyItem && loopBodyItem.IsValidForDefinitionBodyItem(writerContext)) { BuildDefinitionBodyItem(poco, writerContext, stringBuilder); } @@ -83,7 +83,7 @@ public static void BuildDefinitionBodyItem(SysML2.NET.Core.POCO.Core.Types.IType /// The that accumulates the entire textual notation with indentation public static void BuildInterfaceBody(SysML2.NET.Core.POCO.Core.Types.IType poco, TextualNotationWriterContext writerContext, IndentedStringBuilder stringBuilder) { - if (writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelationship", poco.OwnedRelationship).Current == null) + if (writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelationship", poco.OwnedRelationship).Current is not SysML2.NET.Core.POCO.Root.Elements.IRelationship emptyBodyCandidate || !emptyBodyCandidate.IsValidForInterfaceBodyItem(writerContext)) { stringBuilder.AppendLine(";"); } @@ -93,7 +93,7 @@ public static void BuildInterfaceBody(SysML2.NET.Core.POCO.Core.Types.IType poco stringBuilder.AppendLine("{"); stringBuilder.IncreaseIndent(); var ownedRelationshipCursor = writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelationship", poco.OwnedRelationship); - while (ownedRelationshipCursor.Current != null) + while (ownedRelationshipCursor.Current is SysML2.NET.Core.POCO.Root.Elements.IRelationship loopBodyItem && loopBodyItem.IsValidForInterfaceBodyItem(writerContext)) { BuildInterfaceBodyItem(poco, writerContext, stringBuilder); } @@ -508,7 +508,7 @@ public static void BuildTypeDeclaration(SysML2.NET.Core.POCO.Core.Types.IType po { var ownedRelationshipCursor = writerContext.CursorCache.GetOrCreateCursor(poco.Id, "ownedRelationship", poco.OwnedRelationship); - if (poco.IsSufficient) + if (poco.IsSufficient && poco is not (SysML2.NET.Core.POCO.Systems.Connections.IConnectionDefinition)) { stringBuilder.Append(" all "); stringBuilder.Append(' '); diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs index 7294471aa..31bc473fe 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/AutoGenTextualNotationBuilder/UsageTextualNotationBuilder.cs @@ -107,7 +107,7 @@ public static void BuildBasicUsagePrefix(SysML2.NET.Core.POCO.Systems.Definition { BuildRefPrefix(poco, writerContext, stringBuilder); - if (poco.isReference) + if (poco.isReference && poco is not (SysML2.NET.Core.POCO.Systems.Attributes.IAttributeUsage or SysML2.NET.Core.POCO.Systems.DefinitionAndUsage.IReferenceUsage or SysML2.NET.Core.POCO.Systems.Occurrences.IEventOccurrenceUsage)) { stringBuilder.Append(" ref "); } diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/IndentedStringBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/IndentedStringBuilder.cs index eecccabfd..674c5c488 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/IndentedStringBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/IndentedStringBuilder.cs @@ -339,6 +339,33 @@ public IndentedStringBuilder AppendLine(string value) return this; } + /// + /// Emits the current indent prefix (if positioned at the start of a new logical line) + /// and then appends verbatim — bypassing the leading-space + /// strip, the consecutive-space collapse, and the tight-token normalisation that + /// applies. The wrapper is left in mid-line state, so + /// subsequent / calls + /// continue to normalise their payloads as usual. + /// Use this for the rare case where the canonical SST form preserves leading + /// whitespace inside a line prefix — notably the alignment space inside a multi-line + /// regular comment (" * " aligns the body asterisks with the asterisk in the + /// opening /*). The normal path strips that + /// leading space at line start, which collapses " * " to "* ". + /// + /// The literal text to append after the indent prefix; null or empty is a no-op. + /// The current instance, to allow chaining. + public IndentedStringBuilder AppendIndentedLiteral(string literal) + { + if (string.IsNullOrEmpty(literal)) + { + return this; + } + + this.EmitIndentIfNeeded(); + this.builder.Append(literal); + return this; + } + /// /// Converts the accumulated content of the underlying to /// a . diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs b/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs index fbd42610e..c304cd6f3 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/NameResolutionCache.cs @@ -85,6 +85,21 @@ private readonly Dictionary> sourceScopeChains private readonly Dictionary<(Guid TargetId, Guid SourceScopeId), string> resolvedReferences = new (); + /// + /// Reverse index from a canonical-owning to the set of + /// "facade" namespaces that DIRECTLY re-export it via a . + /// Populated during the eager pass alongside the + /// per-scope simple-name indices. Single-hop only — no transitive walk. + /// Used by to shorten library references like + /// ISQBase::mass to the OMG SST idiomatic facade form ISQ::mass: when a + /// reference targets an element owned by ISQBase, and a namespace ISQ + /// directly imports ISQBase, AND ISQ is reachable from the source scope + /// chain, the writer emits ISQ::simpleName instead of ISQBase::simpleName. + /// The SST tutorial (Release 2026-03) uses the facade form 17:1 over the implementation + /// form (ISQ:: vs ISQBase::), establishing the canonical idiom. + /// + private readonly Dictionary> directFacadeIndex = new(); + /// /// Initializes a new rooted at /// and eagerly populates the per-namespace simple-name @@ -94,7 +109,7 @@ private readonly Dictionary> sourceScopeChains public NameResolutionCache(INamespace rootNamespace) { this.RootNamespace = rootNamespace ?? throw new ArgumentNullException(nameof(rootNamespace)); - this.simpleNameIndices = BuildSimpleNameIndices(rootNamespace); + this.simpleNameIndices = this.BuildSimpleNameIndices(rootNamespace); } /// @@ -152,6 +167,30 @@ public string Resolve(IElement target, IElement sourcePoco) // Memoised path — look up by (target.Id, sourceLocalScope.Id). var sourceLocalScope = this.GetSourceLocalScope(sourcePoco); + + // Redefinition-context: when the source is an OwnedRedefinition and the target is its + // RedefinedFeature, the LOCAL redefining feature must not shadow the redefined target + // during simple-name lookup. The parser resolves `:>> name` against the type's + // INHERITED members (the redefining feature isn't a member of the type yet — it's the + // very feature being defined), so the writer mirrors that by filtering the local + // redefiner out of every candidate bucket. Bypasses the memo because the redefinition + // context is per-call, not per (target, sourceLocalScope). + // EXCEPTION: when the local redefining feature has a DECLARED name equal to the + // target's name, emitting the bare simple-name form would re-resolve at parse time to + // the local redefiner itself (the post-parse local member shadows the inherited one), + // not to the redefined target. KerML §8.2.3.5 requires the qualified form in that + // case so the round-trip resolves to the SAME element. We detect this collision and + // fall through to the normal cached path, which produces the qualified form. + if (sourcePoco is IRedefinition redefinition && ReferenceEquals(target, redefinition.RedefinedFeature)) + { + var localRedefiner = redefinition.RedefiningFeature; + + if (localRedefiner != null && !RedefinerDeclaredNameCollidesWith(localRedefiner, target)) + { + return this.ResolveFresh(target, sourcePoco, sourceLocalScope, escapedName, localRedefiner); + } + } + var cacheKey = (target.Id, sourceLocalScope?.Id ?? Guid.Empty); if (this.resolvedReferences.TryGetValue(cacheKey, out var cached)) @@ -159,7 +198,7 @@ public string Resolve(IElement target, IElement sourcePoco) return cached; } - var resolved = this.ResolveFresh(target, sourcePoco, sourceLocalScope, escapedName); + var resolved = this.ResolveFresh(target, sourcePoco, sourceLocalScope, escapedName, localRedefiner: null); this.resolvedReferences[cacheKey] = resolved; return resolved; } @@ -184,8 +223,9 @@ public string Resolve(IElement target, IElement sourcePoco) /// The reference site's source POCO. /// The previously-computed local scope (may be ). /// The target's escaped raw name. + /// The local that acts like redefiner /// The resolved emission string. - private string ResolveFresh(IElement target, IElement sourcePoco, INamespace sourceLocalScope, string escapedName) + private string ResolveFresh(IElement target, IElement sourcePoco, INamespace sourceLocalScope, string escapedName, IFeature localRedefiner) { var chain = this.GetSourceScopeChain(sourcePoco, sourceLocalScope); @@ -199,7 +239,7 @@ private string ResolveFresh(IElement target, IElement sourcePoco, INamespace sou { escapedShortName = rawShortName.QueryIsValidBasicName() ? rawShortName : rawShortName.ToUnrestrictedName(); - if (this.TryResolveSimpleNameAcrossChain(chain, target, rawShortName, escapedShortName, out var matchedShort)) + if (this.TryResolveSimpleNameAcrossChain(chain, target, rawShortName, escapedShortName, localRedefiner, out var matchedShort)) { return matchedShort; } @@ -208,11 +248,24 @@ private string ResolveFresh(IElement target, IElement sourcePoco, INamespace sou var rawName = target.name; if (!string.IsNullOrWhiteSpace(rawName) - && this.TryResolveSimpleNameAcrossChain(chain, target, rawName, escapedName, out var matchedLong)) + && this.TryResolveSimpleNameAcrossChain(chain, target, rawName, escapedName, localRedefiner, out var matchedLong)) { return matchedLong; } + // Facade re-export pass — when the target's owningNamespace is DIRECTLY re-exported + // by another namespace via NamespaceImport AND that facade is reachable from the + // source scope chain, prefer the OMG SST canonical form `facade::simpleName` over + // the implementation-owning form `owner::simpleName`. The SST tutorial (Release + // 2026-03) uses the facade form 17:1 over the implementation form (e.g. + // `ISQ::mass` 17 times vs `ISQBase::mass` once), establishing this as the canonical + // textual idiom. KerML §8.2.3.5.4 leaves the choice between the two formally + // undetermined, so both forms parse to the same element. + if (this.TryResolveViaDirectFacade(chain, target, escapedShortName, escapedName, out var matchedFacade)) + { + return matchedFacade; + } + // Depth ≥ 1 — walk owner-chain ancestors outward and look for the first one that // itself resolves uniquely in the source-scope chain. Once found, emit it as the // anchor followed by the owner-chain segments down to the target. @@ -242,7 +295,7 @@ private string ResolveFresh(IElement target, IElement sourcePoco, INamespace sou var ancestorRawName = !string.IsNullOrWhiteSpace(rawAncestorShort) ? rawAncestorShort : rawAncestorLong; if (!string.IsNullOrWhiteSpace(ancestorRawName) - && this.TryResolveSimpleNameAcrossChain(chain, ancestor, ancestorRawName, ancestorSegment, out var matchedAnchor)) + && this.TryResolveSimpleNameAcrossChain(chain, ancestor, ancestorRawName, ancestorSegment, localRedefiner, out var matchedAnchor)) { var builder = new StringBuilder(matchedAnchor); @@ -289,6 +342,194 @@ private static IElement QueryOwningContainer(IElement element) } } + /// + /// Determines whether the 's DECLARED name (the + /// modeller-typed identifier, NOT the effective name derived from the redefinition + /// chain) equals the 's effective name. When this returns + /// , the writer must emit the redefined target as a qualified + /// name — emitting the bare simple-name form would re-resolve at parse time to the + /// local redefiner (because the local member, once parsed, shadows the inherited one) + /// instead of to the redefined target. Per KerML §8.2.3.5 the qualified form + /// guarantees that the textual round-trip resolves back to the SAME element. + /// An anonymous redefining feature (e.g. ref :>> driveshaft = …) has + /// both DeclaredName and DeclaredShortName empty — its effective name is + /// derived from the redefinition. Such a redefiner cannot collide and the writer can + /// safely emit the shortened form. + /// + /// The redefining feature; must be non-null. + /// The redefined target. + /// when the declared simple-name of the redefiner equals the target's effective name. + private static bool RedefinerDeclaredNameCollidesWith(IFeature localRedefiner, IElement target) + { + var redefinerDeclaredName = localRedefiner.DeclaredName; + var redefinerDeclaredShortName = localRedefiner.DeclaredShortName; + + if (string.IsNullOrWhiteSpace(redefinerDeclaredName) && string.IsNullOrWhiteSpace(redefinerDeclaredShortName)) + { + return false; + } + + var targetName = target.name; + var targetShortName = target.shortName; + + return (!string.IsNullOrWhiteSpace(redefinerDeclaredName) && string.Equals(redefinerDeclaredName, targetName, StringComparison.Ordinal)) + || (!string.IsNullOrWhiteSpace(redefinerDeclaredName) && string.Equals(redefinerDeclaredName, targetShortName, StringComparison.Ordinal)) + || (!string.IsNullOrWhiteSpace(redefinerDeclaredShortName) && string.Equals(redefinerDeclaredShortName, targetName, StringComparison.Ordinal)) + || (!string.IsNullOrWhiteSpace(redefinerDeclaredShortName) && string.Equals(redefinerDeclaredShortName, targetShortName, StringComparison.Ordinal)); + } + + /// + /// Attempts to emit a "facade re-export" form for — i.e. + /// facade::simpleName where facade is a namespace that DIRECTLY imports + /// the target's owning namespace via and is reachable + /// from the source scope . This matches the OMG SST canonical + /// idiom of ISQ::mass over ISQBase::mass (KerML §8.2.3.5.4 leaves the + /// choice formally undetermined; the SST tutorial uses the facade form 17:1). + /// Single-hop only — the SST does not use deep-chain facade names like + /// SI::mass (SI imports ISQ which imports ISQBase, two hops away). + /// Tie-break order when multiple facades are reachable: (1) facade whose simple + /// name resolves uniquely to ITSELF in the scope chain (i.e. a clean anchor); (2) + /// innermost scope-chain proximity (the facade reachable at the innermost scope wins); + /// (3) shorter facade name; (4) stable alphabetical. + /// + /// The source scope chain (innermost first). + /// The element being referenced. + /// Pre-escaped target shortName (may be ). + /// Pre-escaped target name. + /// On a hit, the emitted facade::simpleName string. + /// when a reachable facade was found and the emission was assembled. + private bool TryResolveViaDirectFacade(IReadOnlyList chain, IElement target, string escapedShortName, string escapedName, out string matched) + { + matched = null; + + var canonicalOwner = QueryOwningContainer(target) as INamespace; + + if (canonicalOwner == null || !this.directFacadeIndex.TryGetValue(canonicalOwner, out var facades) || facades.Count == 0) + { + return false; + } + + // Prefer the target's shortest emission form, mirroring the depth-0 walk above. + var targetSimpleName = !string.IsNullOrWhiteSpace(escapedShortName) ? escapedShortName : escapedName; + + if (string.IsNullOrWhiteSpace(targetSimpleName)) + { + return false; + } + + INamespace bestFacade = null; + var bestScopeDepth = int.MaxValue; + + // First pass — prefer facades reachable via a scope in the source chain (their + // simple name resolves directly in some chain scope's index). This is the + // strictest reachability and matches the lexical-resolution model. + for (var scopeDepth = 0; scopeDepth < chain.Count; scopeDepth++) + { + var scope = chain[scopeDepth]; + var scopeIndex = this.GetSimpleNameIndex(scope); + + foreach (var facade in facades) + { + var facadeName = !string.IsNullOrWhiteSpace(facade.shortName) ? facade.shortName : facade.name; + + if (string.IsNullOrWhiteSpace(facadeName) || !scopeIndex.TryGetValue(facadeName, out var facadeBucket) || !facadeBucket.Contains(facade)) + { + continue; + } + + // Innermost-scope win takes priority; within the same scope depth, prefer + // the shorter facade name, then stable alphabetical. + if (bestFacade == null + || scopeDepth < bestScopeDepth + || (scopeDepth == bestScopeDepth && CompareFacades(facade, bestFacade) < 0)) + { + bestFacade = facade; + bestScopeDepth = scopeDepth; + } + } + + if (bestFacade != null) + { + break; + } + } + + // Second pass — KerML §8.2.3.5.4 says name resolution walks all the way out to + // the global namespace, which contains all loaded library root namespaces. A + // facade indexed by the cache (even one not lexically owned by a source-chain + // scope) is therefore reachable for the parser via the global resolution step, + // and `facade::simpleName` round-trips to the same target element. + // Restrict to facades whose name is MEANINGFULLY shorter than the canonical + // owner's — i.e. at most 70% of the owner's length. This matches the OMG SST + // convention: ISBase (7 chars) → ISQ (3 chars, 43% of ISBase) is a meaningful + // shortening; but ScalarValues (12 chars) → Collections (11 chars, 92%) is NOT + // — Collections is structurally a parent wrapper, not a user-facing facade for + // ScalarValues. Without semantic understanding the canonical owner is preferred + // in the latter case. + if (bestFacade == null) + { + var canonicalNameForCompare = QueryPreferredEscapedSegment(canonicalOwner); + var canonicalLength = canonicalNameForCompare?.Length ?? int.MaxValue; + var meaningfulShorterMax = (int)(canonicalLength * 0.7); + + foreach (var facade in facades) + { + if (!this.simpleNameIndices.ContainsKey(facade)) + { + continue; + } + + var facadeNameForCompare = QueryPreferredEscapedSegment(facade); + + if (string.IsNullOrWhiteSpace(facadeNameForCompare) || facadeNameForCompare.Length > meaningfulShorterMax) + { + continue; + } + + if (bestFacade == null || CompareFacades(facade, bestFacade) < 0) + { + bestFacade = facade; + } + } + } + + if (bestFacade == null) + { + return false; + } + + var bestFacadeSegment = QueryPreferredEscapedSegment(bestFacade); + + if (string.IsNullOrWhiteSpace(bestFacadeSegment)) + { + return false; + } + + matched = bestFacadeSegment + "::" + targetSimpleName; + return true; + } + + /// + /// Stable ordering for facade candidates at the SAME scope depth: shorter name first, + /// then ordinal alphabetical. Ensures the writer's output is deterministic across runs + /// when multiple facades re-export the same owning namespace from the same scope. + /// + /// First candidate. + /// Second candidate. + /// Negative if sorts first, positive if right, zero if tied. + private static int CompareFacades(INamespace left, INamespace right) + { + var leftName = !string.IsNullOrWhiteSpace(left.shortName) ? left.shortName : left.name; + var rightName = !string.IsNullOrWhiteSpace(right.shortName) ? right.shortName : right.name; + + leftName ??= string.Empty; + rightName ??= string.Empty; + + var lengthCompare = leftName.Length.CompareTo(rightName.Length); + + return lengthCompare != 0 ? lengthCompare : string.CompareOrdinal(leftName, rightName); + } + /// /// Returns 's shortest escaped name segment — preferring /// over , with KEBNF @@ -322,9 +563,10 @@ private static string QueryPreferredEscapedSegment(IElement element) /// The referenced element. /// The simple-name lexical form to probe (may be / whitespace). /// The escaped form to emit on a hit. + /// The local that acts as redefiner /// On a unique-binding hit, the simple-name string to emit. /// when the simple name resolves uniquely to the target somewhere in the chain. - private bool TryResolveSimpleNameAcrossChain(IReadOnlyList chain, IElement target, string rawName, string escapedName, out string matched) + private bool TryResolveSimpleNameAcrossChain(IReadOnlyList chain, IElement target, string rawName, string escapedName, IFeature localRedefiner, out string matched) { matched = null; @@ -335,7 +577,7 @@ private bool TryResolveSimpleNameAcrossChain(IReadOnlyList chain, IE foreach (var scope in chain) { - var resolution = this.ResolveSimpleNameInScope(scope, target, rawName); + var resolution = this.ResolveSimpleNameInScope(scope, target, rawName, localRedefiner); switch (resolution) { @@ -651,8 +893,16 @@ private enum SimpleNameResolution /// The scope whose index is inspected. /// The element to look up. /// The simple-name lexical form to probe; must be non-blank. + /// + /// Optional feature to filter OUT of the scope's name bucket before leaf reduction — + /// used by the redefinition-resolution path so the local redefining feature does not + /// shadow the redefined target. Pass for the normal resolution + /// path. KerML §8.2.3.5: a redefining feature is not yet a resolvable member of its + /// owning Type at the redefinition site, so it must not participate in name resolution + /// when emitting :>> name. + /// /// The resolution state. - private SimpleNameResolution ResolveSimpleNameInScope(INamespace scope, IElement target, string rawName) + private SimpleNameResolution ResolveSimpleNameInScope(INamespace scope, IElement target, string rawName, IFeature localRedefiner) { var index = this.GetSimpleNameIndex(scope); @@ -661,9 +911,24 @@ private SimpleNameResolution ResolveSimpleNameInScope(INamespace scope, IElement return SimpleNameResolution.NotBound; } - if (elements.Count == 1) + // Filter out the local redefining feature so it doesn't shadow the redefined target + // it points to. When the bucket contains only the local redefiner, treat the name as + // unbound in this scope and continue the chain walk outward. + var hasLocalRedefiner = localRedefiner != null && elements.Contains(localRedefiner); + var effectiveCount = hasLocalRedefiner ? elements.Count - 1 : elements.Count; + + if (effectiveCount == 0) + { + return SimpleNameResolution.NotBound; + } + + if (effectiveCount == 1) { - return elements.Contains(target) + var only = hasLocalRedefiner + ? elements.First(e => !ReferenceEquals(e, localRedefiner)) + : elements.First(); + + return ReferenceEquals(only, target) ? SimpleNameResolution.Matched : SimpleNameResolution.Shadowed; } @@ -671,10 +936,12 @@ private SimpleNameResolution ResolveSimpleNameInScope(INamespace scope, IElement // Reduce to the leaf set: drop any element that is transitively redefined by // another element in `elements`. The shadow set is the union of each candidate's // `AllRedefinedFeatures()` closure (excluding the candidate itself, which the - // operation includes as the seed of the closure). + // operation includes as the seed of the closure). The local redefiner — when + // present — is excluded from this computation entirely so it neither participates + // in shadow accumulation nor in the final leaf count. var shadowed = new HashSet(); - foreach (var candidate in elements.OfType()) + foreach (var candidate in elements.OfType().Where(candidate => !ReferenceEquals(candidate, localRedefiner))) { foreach (var redefined in candidate.AllRedefinedFeatures().Where(redefined => !ReferenceEquals(redefined, candidate))) { @@ -685,7 +952,7 @@ private SimpleNameResolution ResolveSimpleNameInScope(INamespace scope, IElement IElement onlyLeaf = null; var leafCount = 0; - foreach (var element in elements.Where(element => element is not IFeature feature || !shadowed.Contains(feature))) + foreach (var element in elements.Where(element => !ReferenceEquals(element, localRedefiner) && (element is not IFeature feature || !shadowed.Contains(feature)))) { leafCount++; @@ -729,7 +996,19 @@ private static bool IsChainAccessor(IElement sourcePoco) var siblings = chainOwner.OwnedRelationship.OfType().ToList(); var index = siblings.IndexOf(chaining); - return index > 0; + + if (index > 0) + { + return true; + } + + // The FIRST chaining segment is also a chain accessor when the owned chain Feature + // itself is the target member of a FeatureChainExpression (grammar rule + // OwnedFeatureChainMember): the parser resolves even the first segment against the + // argument expression's result, not the lexical scope, so the bare simple name is + // the correct emission. A chain owned by a Specialization / ReferenceSubsetting + // (e.g. a connect end) keeps lexical resolution for its first segment. + return chainOwner.OwningRelationship is IMembership { OwningRelatedElement: IFeatureChainExpression } and not IParameterMembership; } /// @@ -741,7 +1020,7 @@ private static bool IsChainAccessor(IElement sourcePoco) /// /// The root namespace. /// The full structural cache. - private static Dictionary>> BuildSimpleNameIndices(INamespace rootNamespace) + private Dictionary>> BuildSimpleNameIndices(INamespace rootNamespace) { var result = new Dictionary>>(); var pending = new Queue(); @@ -760,7 +1039,7 @@ private static Dictionary>(StringComparer.Ordinal); - BuildOwnedAndImportedEntries(scope, index, pending); + this.BuildOwnedAndImportedEntries(scope, index, pending); if (scope is IType type) { @@ -781,7 +1060,7 @@ private static DictionaryThe namespace whose entries are populated. /// The destination index. /// Queue of namespaces yet to be indexed. - private static void BuildOwnedAndImportedEntries(INamespace scope, Dictionary> index, Queue pending) + private void BuildOwnedAndImportedEntries(INamespace scope, Dictionary> index, Queue pending) { try { @@ -803,11 +1082,36 @@ private static void BuildOwnedAndImportedEntries(INamespace scope, Dictionary + /// Records as a direct (single-hop) re-exporter of + /// . Called once per + /// encountered in the eager build pass. + /// + /// The namespace being directly imported. + /// The namespace whose ownedImport contains the + /// targeting . + private void RecordDirectFacade(INamespace canonicalOwner, INamespace facade) + { + if (!this.directFacadeIndex.TryGetValue(canonicalOwner, out var facades)) + { + facades = []; + this.directFacadeIndex[canonicalOwner] = facades; + } + + facades.Add(facade); + } + /// /// Indexes the entries inherited from the transitive supertypes of /// . Bypasses the RemoveRedefinedFeatures filter so diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs b/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs index 5f38e9b20..38049374a 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/SharedTextualNotationBuilder.cs @@ -468,24 +468,29 @@ internal static void BuildDefinitionOrInterfaceBodyItemHandCoded( ownedRelationshipCursor.Move(); break; - case IOwningMembership owningMembership: + case IOwningMembership owningMembership when owningMembership.IsValidForDefinitionMember(writerContext): OwningMembershipTextualNotationBuilder.BuildDefinitionMember(owningMembership, writerContext, stringBuilder); ownedRelationshipCursor.Move(); break; - case IMembership membership: - MembershipTextualNotationBuilder.BuildAliasMember(membership, writerContext, stringBuilder); - ownedRelationshipCursor.Move(); - break; - case IImport import: ImportTextualNotationBuilder.BuildImport(import, writerContext, stringBuilder); ownedRelationshipCursor.Move(); break; - default: + 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; } } } @@ -612,11 +617,12 @@ internal static void AppendRegularComment(IndentedStringBuilder stringBuilder, s foreach (var rawLine in lines.Where(l => !string.IsNullOrWhiteSpace(l))) { var line = rawLine.TrimEnd('\r'); - stringBuilder.Append(" * "); + stringBuilder.AppendIndentedLiteral(" * "); stringBuilder.AppendLine(line); } - stringBuilder.AppendLine(" */"); + stringBuilder.AppendIndentedLiteral(" */"); + stringBuilder.AppendLine(); if (surroundWithBlankLines) { diff --git a/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs b/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs index 56e5f1cc4..6f80ca136 100644 --- a/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs +++ b/SysML2.NET.Serializer.TextualNotation/Writers/TextualNotationValidationExtensions.cs @@ -31,6 +31,9 @@ namespace SysML2.NET.Serializer.TextualNotation.Writers using SysML2.NET.Core.POCO.Kernel.Expressions; using SysML2.NET.Core.POCO.Kernel.Functions; using SysML2.NET.Core.POCO.Kernel.Interactions; + using SysML2.NET.Core.POCO.Kernel.Packages; + using SysML2.NET.Core.POCO.Root.Annotations; + using SysML2.NET.Core.POCO.Root.Dependencies; using SysML2.NET.Core.POCO.Root.Elements; using SysML2.NET.Core.POCO.Root.Namespaces; using SysML2.NET.Core.POCO.Systems.Actions; @@ -866,26 +869,135 @@ internal static bool IsValidForNonOccurrenceUsageMember(this IFeatureMembership } /// - /// Asserts that the has valid element types for StructureUsageMember - /// inside the collection + /// Asserts that the is valid for the StructureUsageMember rule. + /// StructureUsageMember : FeatureMembership = MemberPrefix ownedRelatedElement += StructureUsageElement + /// StructureUsageElement : Usage = + /// OccurrenceUsage | IndividualUsage | PortionUsage | EventOccurrenceUsage + /// | ItemUsage | PartUsage | ViewUsage | RenderingUsage | PortUsage + /// | ConnectionUsage | InterfaceUsage | AllocationUsage | Message + /// | FlowUsage | SuccessionFlowUsage + /// Encoded as the disjunction of the StructureUsageElement union expressed via + /// the corresponding metamodel interfaces. Because the metamodel inheritance chain has + /// IFlowUsage : IActionUsage (a FlowUsage is structurally an action), but the + /// KEBNF places FlowUsage under StructureUsageElement and ActionUsage + /// under BehaviorUsageElement, the simple supertype check + /// e is IOccurrenceUsage is paired with two exclusion clauses: + /// + /// !(e is IActionUsage and not IFlowUsage) — every + /// that is NOT an belongs to + /// BehaviorUsageElement (ActionUsage, CalculationUsage, StateUsage, CaseUsage, + /// AnalysisCaseUsage, VerificationCaseUsage, UseCaseUsage). + /// !(e is IConstraintUsage) + /// and its descendants (RequirementUsage, ConcernUsage) belong to + /// BehaviorUsageElement. + /// + /// Interfaces (not concrete POCO classes) are used so the guard is robust to + /// alternate implementations (extensions, test doubles) + /// — the POCO classes in this model do not inherit from each other (only via interface + /// chains), so an OfType{ConcreteClass} check would silently miss any instance + /// supplied through a different concrete class but the same interface. /// /// The /// The active (unused for this guard) - /// True if contains any of the required element types + /// True if any matches the StructureUsageElement union internal static bool IsValidForStructureUsageMember(this IFeatureMembership featureMembership, TextualNotationWriterContext writerContext) { - return featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any() - || featureMembership.OwnedRelatedElement.OfType().Any(); + return featureMembership.OwnedRelatedElement.Any(element => + element is IOccurrenceUsage + && !(element is IActionUsage && element is not IFlowUsage) + && element is not IConstraintUsage); + } + + /// + /// Asserts that the is valid for the DefinitionMember rule. + /// DefinitionMember : OwningMembership = MemberPrefix ownedRelatedElement += DefinitionElement + /// DefinitionElement : Element = Package | LibraryPackage | AnnotatingElement | Dependency + /// | AttributeDefinition | EnumerationDefinition | OccurrenceDefinition | IndividualDefinition + /// | ItemDefinition | PartDefinition | ConnectionDefinition | FlowDefinition | InterfaceDefinition + /// | PortDefinition | ActionDefinition | CalculationDefinition | StateDefinition | ConstraintDefinition + /// | RequirementDefinition | ConcernDefinition | CaseDefinition | AnalysisCaseDefinition + /// | VerificationCaseDefinition | UseCaseDefinition | ViewDefinition | ViewpointDefinition + /// | RenderingDefinition | MetadataDefinition | ExtendedDefinition + /// The four covering supertypes of the union are , , + /// , . + /// IS-A but is NOT in the DefinitionElement union — it is only ever the + /// inner element of a ConjugatedPortDefinitionMember consumed by the parent PortDefinition + /// rule and is therefore excluded explicitly. + /// + /// The + /// The active (unused for this guard) + /// True if at least one is a DefinitionElement + internal static bool IsValidForDefinitionMember(this IOwningMembership owningMembership, TextualNotationWriterContext writerContext) + { + foreach (var ownedRelatedElement in owningMembership.OwnedRelatedElement) + { + switch (ownedRelatedElement) + { + case IConjugatedPortDefinition: + continue; + case IDefinition or IPackage or IAnnotatingElement or IDependency: + return true; + } + } + + return false; + } + + /// + /// Asserts that the currently positioned by the cursor matches any + /// alternative of the DefinitionBodyItem rule. + /// DefinitionBodyItem : Type = + /// ownedRelationship += DefinitionMember + /// | ownedRelationship += VariantUsageMember + /// | ownedRelationship += NonOccurrenceUsageMember + /// | ( ownedRelationship += SourceSuccessionMember )? ownedRelationship += OccurrenceUsageMember + /// | ownedRelationship += AliasMember + /// | ownedRelationship += Import + /// Used by BuildDefinitionBody to bound the KEBNF * quantifier and the + /// ';' | '{' DefinitionBodyItem* '}' choice. Returns false for relationships that the body + /// must not consume — notably the synthetic ConjugatedPortDefinitionMember (which carries an + /// and is consumed by the parent PortDefinition rule). + /// + /// The at the cursor + /// The active + /// True if the relationship matches a DefinitionBodyItem alternative + internal static bool IsValidForDefinitionBodyItem(this IRelationship relationship, TextualNotationWriterContext writerContext) + { + return relationship switch + { + IImport => true, + IVariantMembership => true, + IFeatureMembership featureMembership => + featureMembership.IsValidForSourceSuccessionMember(writerContext) + || featureMembership.IsValidForOccurrenceUsageMember(writerContext) + || featureMembership.IsValidForNonOccurrenceUsageMember(writerContext), + IOwningMembership owningMembership => owningMembership.IsValidForDefinitionMember(writerContext), + IMembership => true, + _ => false, + }; + } + + /// + /// Asserts that the currently positioned by the cursor matches any + /// alternative of the InterfaceBodyItem rule. + /// InterfaceBodyItem : Type = + /// ownedRelationship += DefinitionMember + /// | ownedRelationship += VariantUsageMember + /// | ownedRelationship += InterfaceNonOccurrenceUsageMember + /// | ( ownedRelationship += SourceSuccessionMember )? ownedRelationship += InterfaceOccurrenceUsageMember + /// | ownedRelationship += AliasMember + /// | ownedRelationship += Import + /// The shape is identical to DefinitionBodyItem except for the + /// InterfaceOccurrenceUsageMember / InterfaceNonOccurrenceUsageMember specialisations + /// — for the boolean-only guard, they share the same underlying + /// IsValidForOccurrenceUsageMember / IsValidForNonOccurrenceUsageMember predicates. + /// + /// The at the cursor + /// The active + /// True if the relationship matches an InterfaceBodyItem alternative + internal static bool IsValidForInterfaceBodyItem(this IRelationship relationship, TextualNotationWriterContext writerContext) + { + return relationship.IsValidForDefinitionBodyItem(writerContext); } } }