diff --git a/.claude/rules/diagnostics/fc0002-casing-mismatch.md b/.claude/rules/diagnostics/fc0002-casing-mismatch.md index 3184611d..5d25e500 100644 --- a/.claude/rules/diagnostics/fc0002-casing-mismatch.md +++ b/.claude/rules/diagnostics/fc0002-casing-mismatch.md @@ -17,12 +17,17 @@ Reports when the casing of a keyword or identifier reference differs from its ca | `XmlPort → Xmlport` remap in `_symbolKindDictionary` | The `::` left side and static class bind to the SDK's `XmlportClassTypeSymbol`, literally named `"Xmlport"` (`XmlportClassTypeSymbol.cs`). | | `.Run`/`.Import`/`.Export` receiver (`Xmlport.Run`) is NOT analyzed | The `KeywordTexts` filter in `ResolveIdentifiers` skips identifiers named after keywords to avoid false positives on user symbols. Known false negative; kept intentionally. | | Identifiers grouped by (text, scope) before `GetSymbolInfo` | Performance: one semantic call per distinct spelling per method scope. | +| Generic type arguments (`List of [...]`, `Dictionary of [...]`) walked by pushing the `GenericNamedDataTypeSyntax` node onto the existing stack | `ChildNodes()` yields only the type-argument `DataTypeSyntax` nodes (TypeName/`of`/brackets are tokens, never revisited), so the outer type name is not double-reported and nested generics recurse for free. Issue #255. | +| Object references after subtyped data types (`Record MyTable`, `Interface "IMyInterface"`) resolved via `GetSymbolInfo` on the inner `IdentifierNameSyntax` | The SDK's `GetSemanticInfoSymbolInNonMemberContext` derives the `SymbolKind` from the enclosing `SubtypedDataTypeSyntax.TypeName`, so one call returns the referenced object's canonical `Name` — no member model needed for declaration nodes. | +| Object references batched in a dedicated `objectReferences` list keyed by the `(TypeName, name)` tuple, NOT the `identifiers` list | The `identifiers` list groups by (text, method scope); `Record Customer` and a variable named `Customer` would share a group and cross-contaminate the canonical text (false positive + wrong fix). Kind must be in the key because `Record Foo` and `Codeunit Foo` resolve to different symbols. The type name is captured at the collection site in `WalkNode` (not re-derived via parent traversal), and the default case-sensitive tuple comparer matches `ResolveIdentifiers` — differently-cased duplicates just cost one extra `GetSymbolInfo`. | +| `ObjectIdSyntax` (`Record 18`) subtypes are not collected | IDs have no casing. | +| Namespace-qualified subtypes (`Record Ns.Path.MyTable`) resolved in a separate `ResolveQualifiedObjectReferences` pass | `GetSymbolInfo` on the `QualifiedNameSyntax` routes through the SDK's `GetSymbolFromObjectReference`, which has an explicit `QualifiedName` case (`LookupObjectTypeSymbol`). Namespace-part casing is compared right-aligned against `GetContainingNamespaceQualifiedNameWithReflection()` split on `.` (the reflection helper works on all TFMs; a null result skips namespace parts but still checks the object name). Not fed into `ResolveQualifiedNames` — its `Left.Kind == IdentifierName` branch assumes a field-in-object shape and early-returns. | ## Architecture - Two analyzers share the descriptor `DiagnosticDescriptors.CasingMismatch`: `CasingMismatchKeyword` (keyword tokens) and `CasingMismatchIdentifier` (identifiers, data types, properties, option/object access). - `CasingMismatchKeyword`: `RegisterSymbolAction` per object kind; walks descendant tokens, compares keyword tokens against `SyntaxFactory.Token(kind).ValueText`. Skips tokens whose parent is a `*DataType` node or `IdentifierName`. -- `CasingMismatchIdentifier`: single iterative tree walk per object symbol. Dictionary-resolvable nodes are handled inline (fast); identifiers, qualified names, and triggers are batched for semantic-model resolution, grouped by (text, scope) so `GetSymbolInfo` runs once per group. +- `CasingMismatchIdentifier`: single iterative tree walk per object symbol. Dictionary-resolvable nodes are handled inline (fast); identifiers, qualified names, triggers, and subtyped object references (simple and namespace-qualified) are batched for semantic-model resolution (`ResolveIdentifiers`/`ResolveQualifiedNames`/`ResolveTriggers`/`ResolveObjectReferences`/`ResolveQualifiedObjectReferences`), grouped so `GetSymbolInfo` runs once per group. `GenericDataType` is in the stack-push allow-list alongside `EnumDataType`/`LabelDataType` so type arguments are walked. Key dictionaries (all `OrdinalIgnoreCase` keyed, value = canonical text): @@ -51,4 +56,4 @@ Key dictionaries (all `OrdinalIgnoreCase` keyed, value = canonical text): ## CodeFix: CasingMismatchKeyword -`CodeFixes/CasingMismatchKeyword.cs` fixes keyword tokens only; identifier diagnostics carry `CanonicalText` in properties but have no CodeFix yet. +`CodeFixes/CasingMismatchKeyword.cs` (class `CasingMismatchCodeFix`) is registered for the whole FC0002 ID and fixes every diagnostic carrying `CanonicalText` in properties — keyword and identifier diagnostics alike. It replaces the diagnostic span with `CanonicalText.QuoteIdentifierIfNeededWithReflection()`, which re-quotes names that need quotes (`"MY TABLE"` → `"My Table"`) and drops unnecessary quotes (`"IMYINTERFACE"` → `IMyInterface`). diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 584e9ac0..933280c8 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -289,6 +289,7 @@ Rules for .al fixtures: 3. Keep fixtures minimal: only include code relevant to the rule being tested. 4. Place `[|...|]` markers precisely around the syntax node the analyzer targets. 5. Every `HasDiagnostic` fixture must have at least one marker. Every `NoDiagnostic` fixture must also have markers (on the same kind of syntax node, but in a valid scenario). +6. When adding or creating tests, consider both a fixture **without** namespaces and one **with** a `namespace` declaration (if applicable to the analyzed syntax) — analyzers must support both. Use a generic multi-part namespace such as `MyPublisher.MyExtension.MyAppDomain`, and where relevant include fully-qualified object references (`MyPublisher.MyExtension.MyAppDomain.MyTable`). See `Rules/CasingMismatchDeclaration/HasDiagnostic/NamespacedObjectReference.al` in FormattingCop.Test for an example. Typical fixture structure: diff --git a/src/ALCops.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index 315a4486..0cc2ceea 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -1017,6 +1017,8 @@ public static class SyntaxKind new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.Field))); private static readonly Lazy _fieldGroup = new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.FieldGroup))); + private static readonly Lazy _genericDataType = + new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.GenericDataType))); private static readonly Lazy _globalVarSection = new(() => ParseEnum(nameof(NavCodeAnalysis.SyntaxKind.GlobalVarSection))); private static readonly Lazy _identifierName = @@ -1307,6 +1309,7 @@ public static class SyntaxKind public static NavCodeAnalysis.SyntaxKind ForStatement => _forStatement.Value; public static NavCodeAnalysis.SyntaxKind Field => _field.Value; public static NavCodeAnalysis.SyntaxKind FieldGroup => _fieldGroup.Value; + public static NavCodeAnalysis.SyntaxKind GenericDataType => _genericDataType.Value; public static NavCodeAnalysis.SyntaxKind GlobalVarSection => _globalVarSection.Value; public static NavCodeAnalysis.SyntaxKind IdentifierName => _identifierName.Value; public static NavCodeAnalysis.SyntaxKind IdentifierEqualsLiteral => _identifierEqualsLiteral.Value; diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs index 4d4fc618..278a36e2 100644 --- a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/CasingMismatchDeclaration.cs @@ -1,3 +1,4 @@ +using ALCops.FormattingCop.CodeFixes; using RoslynTestKit; namespace ALCops.FormattingCop.Test @@ -40,6 +41,9 @@ public void Setup() [TestCase("GlobalVarAndLocalVar")] [TestCase("XmlPortDataType")] [TestCase("XmlPortObjectAccess")] + [TestCase("GenericDataType")] + [TestCase("SubtypedObjectReference")] + [TestCase("NamespacedObjectReference")] public async Task HasDiagnostic(string testCase) { SkipTestIfVersionIsTooLow( @@ -60,6 +64,12 @@ public async Task HasDiagnostic(string testCase) "14.0" ); + SkipTestIfVersionIsTooLow( + ["GenericDataType", "SubtypedObjectReference"], + testCase, + "14.0", + "No support for Interface as a generic type argument before version 14.0"); + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasDiagnostic), $"{testCase}.al")) .ConfigureAwait(false); @@ -92,6 +102,9 @@ public async Task HasDiagnostic(string testCase) [TestCase("GlobalVarAndLocalVar")] [TestCase("XmlPortDataType")] [TestCase("XmlPortObjectAccess")] + [TestCase("GenericDataType")] + [TestCase("SubtypedObjectReference")] + [TestCase("NamespacedObjectReference")] public async Task NoDiagnostic(string testCase) { SkipTestIfVersionIsTooLow( @@ -112,10 +125,37 @@ public async Task NoDiagnostic(string testCase) "14.0" ); + SkipTestIfVersionIsTooLow( + ["GenericDataType", "SubtypedObjectReference"], + testCase, + "14.0", + "No support for Interface as a generic type argument before version 14.0"); + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) .ConfigureAwait(false); _fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.CasingMismatch); } + + [Test] + [TestCase("GenericTypeArgument")] + [TestCase("QuotedObjectReference")] + [TestCase("QualifiedObjectReference")] + public async Task HasFix(string testCase) + { + var currentCode = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasFix), testCase, "current.al")) + .ConfigureAwait(false); + + var expectedCode = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasFix), testCase, "expected.al")) + .ConfigureAwait(false); + + var fixture = RoslynFixtureFactory.Create( + new CodeFixTestFixtureConfig + { + AdditionalAnalyzers = [_analyzer] + }); + + fixture.TestCodeFix(currentCode, expectedCode, DiagnosticDescriptors.CasingMismatch); + } } } \ No newline at end of file diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/GenericDataType.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/GenericDataType.al new file mode 100644 index 00000000..e5f1a5d3 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/GenericDataType.al @@ -0,0 +1,13 @@ +codeunit 50100 MyCodeunit +{ + var + MyList: List of [[|TEXT|]]; + MyDict: Dictionary of [[|INTEGER|], [|TEXT|]]; + MyNestedList: List of [Dictionary of [Integer, [|TEXT|]]]; + MyInterfaceList: List of [[|INTERFACE|] "My Interface"]; + MyCodeList: List of [[|CODE|][20]]; + MyEnumList: List of [[|ENUM|] "My Enum"]; +} + +interface "My Interface" { } +enum 50100 "My Enum" { value(0; "My Value") { } } diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/NamespacedObjectReference.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/NamespacedObjectReference.al new file mode 100644 index 00000000..9138a326 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/NamespacedObjectReference.al @@ -0,0 +1,24 @@ +namespace MyPublisher.MyExtension.MyAppDomain; + +codeunit 50000 MyCodeunit +{ + var + FullyQualified: Record [|MYPUBLISHER|].[|MYEXTENSION|].[|MYAPPDOMAIN|].[|MYTABLE|]; + Unqualified: Record [|MYTABLE|]; + + procedure Foo(p: Record [|MYPUBLISHER|].[|MYEXTENSION|].[|MYAPPDOMAIN|].[|MYTABLE|]) + var + LocalQualified: Codeunit [|MYPUBLISHER|].[|MYEXTENSION|].[|MYAPPDOMAIN|].[|MYHELPER|]; + begin + end; +} + +table 50000 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +codeunit 50001 MyHelper { } diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/SubtypedObjectReference.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/SubtypedObjectReference.al new file mode 100644 index 00000000..e1139354 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasDiagnostic/SubtypedObjectReference.al @@ -0,0 +1,42 @@ +codeunit 50100 MyCodeunit +{ + var + MyTable: Record [|"MY CUSTOMER"|]; + MyInterface: Interface [|"IMYINTERFACE"|]; + MyCodeunit2: Codeunit [|"MYHELPER"|]; + MyPage: Page [|"MY CUSTOMER CARD"|]; + MyXmlPort: XmlPort [|"MY EXPORT"|]; + MyInterfaceList: List of [Interface [|"IMYINTERFACE"|]]; + + procedure MyProcedure(ParamTable: Record [|"MY CUSTOMER"|]) ReturnTable: Record [|"MY CUSTOMER"|] + var + LocalTable: Record [|"MY CUSTOMER"|]; + begin + end; +} + +table 50100 "My Customer" +{ + fields + { + field(1; "Primary Key"; Integer) { } + } +} + +interface IMyInterface { } +codeunit 50101 MyHelper { } + +page 50100 "My Customer Card" +{ + SourceTable = "My Customer"; +} + +xmlport 50100 "My Export" +{ + schema + { + textelement(Root) + { + } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/current.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/current.al new file mode 100644 index 00000000..e1b1d3da --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/current.al @@ -0,0 +1,5 @@ +codeunit 50100 MyCodeunit +{ + var + MyList: List of [[|TEXT|]]; +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/expected.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/expected.al new file mode 100644 index 00000000..943d0e9c --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/GenericTypeArgument/expected.al @@ -0,0 +1,5 @@ +codeunit 50100 MyCodeunit +{ + var + MyList: List of [Text]; +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/current.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/current.al new file mode 100644 index 00000000..e392ee15 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/current.al @@ -0,0 +1,15 @@ +namespace MyPublisher.MyExtension.MyAppDomain; + +codeunit 50000 MyCodeunit +{ + var + FullyQualified: Record MyPublisher.MyExtension.MyAppDomain.[|MYTABLE|]; +} + +table 50000 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/expected.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/expected.al new file mode 100644 index 00000000..eba6b808 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QualifiedObjectReference/expected.al @@ -0,0 +1,15 @@ +namespace MyPublisher.MyExtension.MyAppDomain; + +codeunit 50000 MyCodeunit +{ + var + FullyQualified: Record MyPublisher.MyExtension.MyAppDomain.MyTable; +} + +table 50000 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/current.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/current.al new file mode 100644 index 00000000..5d9962bf --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/current.al @@ -0,0 +1,13 @@ +codeunit 50100 MyCodeunit +{ + var + MyTable: Record [|"MY CUSTOMER"|]; +} + +table 50100 "My Customer" +{ + fields + { + field(1; "Primary Key"; Integer) { } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/expected.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/expected.al new file mode 100644 index 00000000..b2795826 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/HasFix/QuotedObjectReference/expected.al @@ -0,0 +1,13 @@ +codeunit 50100 MyCodeunit +{ + var + MyTable: Record "My Customer"; +} + +table 50100 "My Customer" +{ + fields + { + field(1; "Primary Key"; Integer) { } + } +} diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/GenericDataType.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/GenericDataType.al new file mode 100644 index 00000000..c6baf991 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/GenericDataType.al @@ -0,0 +1,13 @@ +codeunit 50100 MyCodeunit +{ + var + MyList: List of [[|Text|]]; + MyDict: Dictionary of [[|Integer|], [|Text|]]; + MyNestedList: List of [Dictionary of [Integer, [|Text|]]]; + MyInterfaceList: List of [[|Interface|] "My Interface"]; + MyCodeList: List of [[|Code|][20]]; + MyEnumList: List of [[|Enum|] "My Enum"]; +} + +interface "My Interface" { } +enum 50100 "My Enum" { value(0; "My Value") { } } diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/NamespacedObjectReference.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/NamespacedObjectReference.al new file mode 100644 index 00000000..846cdb8a --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/NamespacedObjectReference.al @@ -0,0 +1,24 @@ +namespace MyPublisher.MyExtension.MyAppDomain; + +codeunit 50000 MyCodeunit +{ + var + FullyQualified: Record [|MyPublisher|].[|MyExtension|].[|MyAppDomain|].[|MyTable|]; + Unqualified: Record [|MyTable|]; + + procedure Foo(p: Record [|MyPublisher|].[|MyExtension|].[|MyAppDomain|].[|MyTable|]) + var + LocalQualified: Codeunit [|MyPublisher|].[|MyExtension|].[|MyAppDomain|].[|MyHelper|]; + begin + end; +} + +table 50000 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +codeunit 50001 MyHelper { } diff --git a/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/SubtypedObjectReference.al b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/SubtypedObjectReference.al new file mode 100644 index 00000000..07dc70b9 --- /dev/null +++ b/src/ALCops.FormattingCop.Test/Rules/CasingMismatchDeclaration/NoDiagnostic/SubtypedObjectReference.al @@ -0,0 +1,43 @@ +codeunit 50100 MyCodeunit +{ + var + MyTable: Record [|"My Customer"|]; + MyInterface: Interface [|IMyInterface|]; + MyCodeunit2: Codeunit [|MyHelper|]; + MyPage: Page [|"My Customer Card"|]; + MyXmlPort: XmlPort [|"My Export"|]; + MyInterfaceList: List of [Interface [|IMyInterface|]]; + MyTableById: Record [|50100|]; + + procedure MyProcedure(ParamTable: Record [|"My Customer"|]) ReturnTable: Record [|"My Customer"|] + var + LocalTable: Record [|"My Customer"|]; + begin + end; +} + +table 50100 "My Customer" +{ + fields + { + field(1; "Primary Key"; Integer) { } + } +} + +interface IMyInterface { } +codeunit 50101 MyHelper { } + +page 50100 "My Customer Card" +{ + SourceTable = "My Customer"; +} + +xmlport 50100 "My Export" +{ + schema + { + textelement(Root) + { + } + } +} diff --git a/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs b/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs index 3b3f666e..52ada31b 100644 --- a/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs +++ b/src/ALCops.FormattingCop/Analyzers/CasingMismatchIdentifier.cs @@ -52,12 +52,16 @@ private void AnalyzeDeclarations(SymbolAnalysisContext ctx) var identifiers = new List<(IdentifierNameSyntax Node, SyntaxNode? Scope)>(); var qualifiedNames = new List<(QualifiedNameSyntax Node, SyntaxNode? Scope)>(); var triggers = new List(); + var objectReferences = new List<(string? TypeName, IdentifierNameSyntax Node)>(); + var qualifiedObjectReferences = new List<(string? TypeName, QualifiedNameSyntax Node)>(); - WalkNode(ctx, root, identifiers, qualifiedNames, triggers); + WalkNode(ctx, root, identifiers, qualifiedNames, triggers, objectReferences, qualifiedObjectReferences); ResolveIdentifiers(ctx, semanticModel, identifiers); ResolveQualifiedNames(ctx, semanticModel, qualifiedNames); ResolveTriggers(ctx, semanticModel, triggers); + ResolveObjectReferences(ctx, semanticModel, objectReferences); + ResolveQualifiedObjectReferences(ctx, semanticModel, qualifiedObjectReferences); } #region Tree Walk @@ -73,6 +77,8 @@ private static void WalkNode( List<(IdentifierNameSyntax Node, SyntaxNode? Scope)> identifiers, List<(QualifiedNameSyntax Node, SyntaxNode? Scope)> qualifiedNames, List triggers, + List<(string? TypeName, IdentifierNameSyntax Node)> objectReferences, + List<(string? TypeName, QualifiedNameSyntax Node)> qualifiedObjectReferences, bool skipChildIdentifiers = false) { var stack = new Stack<(SyntaxNode node, bool skipIds, SyntaxNode? scope)>(); @@ -98,7 +104,13 @@ private static void WalkNode( if (child is SubtypedDataTypeSyntax subtyped) { if (subtyped.Subtype.Kind == EnumProvider.SyntaxKind.ObjectReference) + { CompareAgainstDictionary(ctx, subtyped.TypeName, _navTypeKindDictionary); + if (subtyped.Subtype.Identifier is IdentifierNameSyntax subtypeName) + objectReferences.Add((subtyped.TypeName.ValueText, subtypeName)); + else if (subtyped.Subtype.Identifier is QualifiedNameSyntax qualifiedSubtypeName) + qualifiedObjectReferences.Add((subtyped.TypeName.ValueText, qualifiedSubtypeName)); + } continue; } @@ -106,7 +118,8 @@ private static void WalkNode( { CompareAgainstDictionary(ctx, dataType.TypeName, _navTypeKindDictionary); if (kind == EnumProvider.SyntaxKind.EnumDataType || - kind == EnumProvider.SyntaxKind.LabelDataType) + kind == EnumProvider.SyntaxKind.LabelDataType || + kind == EnumProvider.SyntaxKind.GenericDataType) stack.Push((child, false, currentScope)); continue; } @@ -541,6 +554,98 @@ private static void ResolveTriggers( } } + private static void ResolveObjectReferences( + SymbolAnalysisContext ctx, + SemanticModel semanticModel, + List<(string? TypeName, IdentifierNameSyntax Node)> objectReferences) + { + var groups = objectReferences + .ToLookup(item => (item.TypeName, item.Node.Identifier.ValueText)); + + foreach (var group in groups) + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + IdentifierNameSyntax? representative = null; + foreach (var item in group) + { + if (representative is null || item.Node.Position > representative.Position) + representative = item.Node; + } + + if (representative is null) + continue; + + if (semanticModel.GetSymbolInfo(representative, ctx.CancellationToken).Symbol is not ISymbol symbol) + continue; + + foreach (var item in group) + CompareIdentifier(ctx, item.Node.Identifier, symbol.Name); + } + } + + private static void ResolveQualifiedObjectReferences( + SymbolAnalysisContext ctx, + SemanticModel semanticModel, + List<(string? TypeName, QualifiedNameSyntax Node)> qualifiedObjectReferences) + { + var groups = qualifiedObjectReferences + .ToLookup(item => (item.TypeName, item.Node.ToString())); + + foreach (var group in groups) + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + QualifiedNameSyntax? representative = null; + foreach (var item in group) + { + if (representative is null || item.Node.Position > representative.Position) + representative = item.Node; + } + + if (representative is null) + continue; + + if (semanticModel.GetSymbolInfo(representative, ctx.CancellationToken).Symbol is not ISymbol symbol) + continue; + + var namespaceParts = symbol.GetContainingNamespaceQualifiedNameWithReflection()?.Split('.'); + + foreach (var item in group) + { + CompareIdentifier(ctx, item.Node.Right.Identifier, symbol.Name); + CompareNamespaceParts(ctx, item.Node.Left, namespaceParts); + } + } + } + + // Right-aligned: the qualifier's parts are matched against the tail of the declared + // namespace, so a shorter qualifier never walks past its own leftmost part. + private static void CompareNamespaceParts(SymbolAnalysisContext ctx, SyntaxNode left, string[]? namespaceParts) + { + if (namespaceParts is null) + return; + + var index = namespaceParts.Length - 1; + var current = left; + + while (index >= 0) + { + if (current is QualifiedNameSyntax qualified) + { + CompareIdentifier(ctx, qualified.Right.Identifier, namespaceParts[index]); + current = qualified.Left; + index--; + continue; + } + + if (current is IdentifierNameSyntax identifier) + CompareIdentifier(ctx, identifier.Identifier, namespaceParts[index]); + + break; + } + } + #endregion #region Comparison and Reporting