diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs index 86d5fa69..35d1f14a 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/AstBuilder.cs @@ -2081,7 +2081,7 @@ private static IReadOnlyList ExtractExposedNames( return null; } - var (qn, isWildcard, bracketFilterText, _) = ExtractImportTarget(decl.namespaceImport(), decl.membershipImport()); + var (qn, isWildcard, bracketFilterText, isRecursive) = ExtractImportTarget(decl.namespaceImport(), decl.membershipImport()); if (qn is null) { return null; @@ -2092,6 +2092,8 @@ private static IReadOnlyList ExtractExposedNames( ImportedNamespace = qn, ImportedNames = [qn], IsWildcard = isWildcard, + IsRecursive = isRecursive, + IsMembershipImport = decl.membershipImport() is not null, BracketFilterExpressionText = bracketFilterText, }; } diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs index bce7a878..dbe94999 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/ReferenceResolver.cs @@ -440,6 +440,32 @@ private bool TryResolve( resolvedName = candidate; return true; } + + // A recursive *membership* import (`import X::Y::**;`) brings Y itself into scope by + // its own short name — not just Y's nested descendants — since Y is the explicit + // membership-import target, not merely a containing namespace being wildcard-searched. + // e.g. `import VersionMark::Cli::**;` should let an unqualified `Cli` reference resolve + // to `VersionMark::Cli` itself, in addition to reaching Cli's own nested members below. + if (wildcard.IsMembershipImport && + LastSegment(resolvedNamespace) == name && + _symbolTable.Contains(resolvedNamespace)) + { + resolvedName = resolvedNamespace; + return true; + } + + // Recursive wildcard import (`import X::*::**;`) additionally reaches members of any + // namespace nested within X at any depth, not just X's own direct members — e.g. + // `import VersionMark::*::**;` should let an unqualified `CliSubsystem` resolve to + // `VersionMark::Cli::CliSubsystem` even though `Cli` is a nested package, not X itself. + // Prefer the least-nested match for determinism when more than one namespace at + // different depths happens to declare a same-named member. + if (wildcard.IsRecursive && + FindRecursiveWildcardMatch(resolvedNamespace, name) is { Length: > 0 } recursiveMatch) + { + resolvedName = recursiveMatch; + return true; + } } // Step 4: Explicit named imports — for each `import X::Y` where Y == name, @@ -468,6 +494,60 @@ private bool TryResolve( return false; } + /// + /// Searches the symbol table for a member named declared in any + /// namespace nested (at any depth) within , for + /// resolving a recursive wildcard import's (import X::*::**;) unqualified + /// references. Returns the shallowest (least-nested) match when more than one namespace at + /// different depths declares a same-named member, and when no + /// match exists. + /// + private string FindRecursiveWildcardMatch(string resolvedNamespace, string name) + { + var prefix = resolvedNamespace + "::"; + var suffix = "::" + name; + string? best = null; + var bestDepth = int.MaxValue; + foreach (var qualifiedName in _symbolTable.Symbols.Keys) + { + if (!qualifiedName.StartsWith(prefix, StringComparison.Ordinal) || + !qualifiedName.EndsWith(suffix, StringComparison.Ordinal) || + qualifiedName.Length < prefix.Length + suffix.Length) + { + continue; + } + + // Depth is the number of intermediate "::"-separated segments between + // resolvedNamespace and name — count separators in the portion of qualifiedName + // strictly between the matched prefix and suffix, not raw string length (segment + // names vary in length, so a deeper match can have a shorter qualified-name string + // than a shallower one). The length guard above excludes the exact one-level match + // "prefix + name" (already handled by the direct-candidate check before this method + // is called), where prefix and suffix overlap and slicing out a "middle" would + // otherwise be an invalid (negative-length) range. + var middle = qualifiedName[prefix.Length..^suffix.Length]; + var depth = middle.Length == 0 ? 0 : middle.Split("::").Length; + if (depth < bestDepth) + { + best = qualifiedName; + bestDepth = depth; + } + } + + return best ?? string.Empty; + } + + /// + /// Returns the final ::-separated segment of a qualified name (e.g. + /// "VersionMark::Cli""Cli"), or the whole string when it contains no + /// :: separator. + /// + private static string LastSegment(string qualifiedName) + { + var lastSep = qualifiedName.LastIndexOf("::", StringComparison.Ordinal); + return lastSep >= 0 ? qualifiedName[(lastSep + 2)..] : qualifiedName; + } + /// /// Resolves a raw (possibly nested-relative) namespace name referenced by an import /// statement to its fully-qualified form, trying a direct symbol-table lookup first and diff --git a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs index 2c574581..fa4cb857 100644 --- a/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs +++ b/src/DemaConsulting.SysML2Tools.Language/Semantic/Model/SysmlNode.cs @@ -195,6 +195,28 @@ public sealed class SysmlImportNode : SysmlNode /// public bool IsWildcard { get; init; } + /// + /// Gets a value indicating whether this import is recursive — derived from a trailing + /// ::** on a namespace-import wildcard (import X::*::**;), which per the + /// KerML/SysML v2 grammar imports members of X and every namespace nested within it + /// at any depth, not just X's direct members. for a plain + /// import X::*; (direct members only) or any non-wildcard import. + /// + public bool IsRecursive { get; init; } + + /// + /// Gets a value indicating whether this import was parsed from the membership-import + /// grammar form (import X::Y; or its recursive import X::Y::**; variant, where + /// ImportedNamespace is the fully-qualified path to the specific member Y) + /// rather than the namespace-import wildcard form (import X::*; or + /// import X::*::**;, where ImportedNamespace is the containing namespace whose + /// members are being imported). This distinction matters for recursive resolution: a + /// recursive membership import must additionally bring Y itself into scope by its + /// own short name (in addition to Y's nested descendants), whereas a recursive + /// namespace import must not — X itself was never a name being imported. + /// + public bool IsMembershipImport { get; init; } + /// /// Gets the raw source text of this import's bracketed filter expression (from /// expose <path>::**[<expr>]'s filterPackageMember().ownedExpression().GetText()), diff --git a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs index cd87186f..7c7eb6d7 100644 --- a/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs +++ b/test/DemaConsulting.SysML2Tools.Tests/Semantic/WorkspaceLoaderTests.cs @@ -397,6 +397,229 @@ part def Foo specializes Bar {} } } + // Level 12b: Unqualified name resolves via recursive wildcard import + /// + /// A recursive wildcard import (import X::*::**;) must reach members declared in + /// namespaces nested at any depth under X, not just X's own direct members. + /// This mirrors the official OMG conformance construct in + /// test/SysMLModels/OMG/examples/SimpleTests/ImportTest.sysml (private import + /// Pkg211::*::**;), which previously parsed without error but never actually resolved + /// nested-namespace references — the OMG corpus sweep only checks for parse errors, never + /// resolution correctness, so this gap went uncaught. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_UnqualifiedNameViaRecursiveWildcardImport_ResolvesWithoutWarning() + { + // Arrange — Foo and Bar each live two namespace levels below package A, in the + // nested Sub1 and Sub2 sub-packages respectively. Consumer uses a recursive + // wildcard import targeting A and references both by short name. + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); + try + { + await File.WriteAllTextAsync(tempFile, """ + package TestNs { + package A { + package Sub1 { + part def Foo; + } + package Sub2 { + part def Bar; + } + } + } + package Consumer { + import TestNs::A::*::**; + + part def UsesBoth { + part f : Foo; + part b : Bar; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert — "Foo" and "Bar" both resolve via the recursive wildcard import, even + // though they're nested two levels below the imported namespace. + Assert.NotNull(result.Workspace); + Assert.DoesNotContain(result.Diagnostics, + d => d.Severity == DemaConsulting.SysML2Tools.Parser.DiagnosticSeverity.Warning && + (d.Message.Contains("Foo") || d.Message.Contains("Bar"))); + } + finally + { + File.Delete(tempFile); + } + } + + // Level 12d: Ambiguous recursive wildcard match resolves to the shallowest namespace depth, + // not the shortest qualified-name string + /// + /// When two namespaces at different nesting depths under a recursive wildcard import's + /// target both declare a same-named member, resolution must prefer the genuinely + /// shallowest (least-nested) match — measured by the number of intermediate namespace + /// segments, not by comparing raw qualified-name string length. This uses deliberately + /// long package names at the shallow depth and short ones at the deep depth so a + /// length-based tie-break (an easy mistake) would pick the wrong one. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_RecursiveWildcardImport_AmbiguousMatch_PrefersShallowestDepth() + { + // Arrange — Foo is declared twice under A: once one level down in a namespace with a + // deliberately long name (shallow, but long string), and once three levels down through + // short-named namespaces (deep, but short string). The shallow one must win. + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); + try + { + await File.WriteAllTextAsync(tempFile, """ + package TestNs { + package A { + package AVeryLongIntermediateNamespaceName { + part def Foo; + } + package B { + package C { + package D { + part def Foo {} + } + } + } + } + } + package Consumer { + import TestNs::A::*::**; + + part def Uses { + part f : Foo; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + Assert.NotNull(result.Workspace); + var index = result.Workspace!.Index; + + // Assert — "Foo" resolves as a Typing edge to the shallower (one-level-deep) match, + // not the deeper-but-shorter-string one. + Assert.Contains(index.GetOutgoingEdges("Consumer::Uses::f"), + e => e.Kind == DemaConsulting.SysML2Tools.Semantic.Model.SysmlEdgeKind.Typing && + e.TargetQualifiedName == "TestNs::A::AVeryLongIntermediateNamespaceName::Foo"); + } + finally + { + File.Delete(tempFile); + } + } + + // Level 12e: Unqualified name resolves via recursive membership import, including the + // imported member's own name + /// + /// A recursive membership import (import X::Y::**;, as distinct from the + /// recursive namespace-import form covered by the previous test) must bring both the + /// explicitly named member Y itself into scope by its own short name, and every + /// name declared in a namespace nested under Y at any depth. This is a distinct + /// grammar shape from import X::*::**;: there, X is a containing namespace + /// being wildcard-searched and is never itself a name being imported, whereas here Y + /// is the explicit membership-import target and must resolve by its own name too. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_UnqualifiedNameViaRecursiveMembershipImport_ResolvesWithoutWarning() + { + // Arrange — A is a nested package directly under TestNs; Foo lives one level further + // down, inside A's own Sub1 sub-package. Consumer uses a recursive membership import + // targeting A itself and references both A and Foo by short name. + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); + try + { + await File.WriteAllTextAsync(tempFile, """ + package TestNs { + package A { + package Sub1 { + part def Foo; + } + } + } + package Consumer { + import TestNs::A::**; + + part def UsesA { + part a : A; + part f : Foo; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert — both "A" (the membership-import target itself) and "Foo" (A's nested + // descendant) resolve without warning. + Assert.NotNull(result.Workspace); + Assert.DoesNotContain(result.Diagnostics, + d => d.Severity == DemaConsulting.SysML2Tools.Parser.DiagnosticSeverity.Warning && + (d.Message.Contains("'A'") || d.Message.Contains("Foo"))); + } + finally + { + File.Delete(tempFile); + } + } + + // Level 12c: Non-recursive wildcard import must NOT reach nested-namespace members + /// + /// A plain (non-recursive) wildcard import (import X::*;) must only bring X's own + /// direct members into scope — it must NOT reach members declared in namespaces nested + /// under X. This is the control case proving the recursive-descent fallback added for + /// import X::*::**; is correctly gated on IsRecursive and doesn't leak into + /// the plain wildcard-import path. + /// + [Fact] + public async Task WorkspaceLoader_LoadAsync_UnqualifiedNameViaPlainWildcardImport_DoesNotReachNestedNamespace() + { + // Arrange — Foo lives in the nested Sub1 sub-package, but Consumer only uses a + // plain (non-recursive) wildcard import targeting A. + var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".sysml"); + try + { + await File.WriteAllTextAsync(tempFile, """ + package TestNs { + package A { + package Sub1 { + part def Foo; + } + } + } + package Consumer { + import TestNs::A::*; + + part def Uses { + part f : Foo; + } + } + """, TestContext.Current.CancellationToken); + + // Act + var (stdlibTable, _) = StdlibProvider.GetSymbolTable(); + var result = await WorkspaceLoader.LoadAsync([tempFile], stdlibTable); + + // Assert — "Foo" must NOT resolve; a non-recursive wildcard import doesn't reach + // into nested namespaces, so this must still produce an Unresolved reference warning. + Assert.NotNull(result.Workspace); + Assert.Contains(result.Diagnostics, + d => d.Severity == DemaConsulting.SysML2Tools.Parser.DiagnosticSeverity.Warning && + d.Message.Contains("Foo")); + } + finally + { + File.Delete(tempFile); + } + } + // Level 13: Explicit named import resolves short name /// /// A part def that specializes a type using only its short name, where that type is