Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .claude/rules/diagnostics/fc0002-casing-mismatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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`).
1 change: 1 addition & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions src/ALCops.Common/Reflection/EnumProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,8 @@ public static class SyntaxKind
new(() => ParseEnum<NavCodeAnalysis.SyntaxKind>(nameof(NavCodeAnalysis.SyntaxKind.Field)));
private static readonly Lazy<NavCodeAnalysis.SyntaxKind> _fieldGroup =
new(() => ParseEnum<NavCodeAnalysis.SyntaxKind>(nameof(NavCodeAnalysis.SyntaxKind.FieldGroup)));
private static readonly Lazy<NavCodeAnalysis.SyntaxKind> _genericDataType =
new(() => ParseEnum<NavCodeAnalysis.SyntaxKind>(nameof(NavCodeAnalysis.SyntaxKind.GenericDataType)));
private static readonly Lazy<NavCodeAnalysis.SyntaxKind> _globalVarSection =
new(() => ParseEnum<NavCodeAnalysis.SyntaxKind>(nameof(NavCodeAnalysis.SyntaxKind.GlobalVarSection)));
private static readonly Lazy<NavCodeAnalysis.SyntaxKind> _identifierName =
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using ALCops.FormattingCop.CodeFixes;
using RoslynTestKit;

namespace ALCops.FormattingCop.Test
Expand Down Expand Up @@ -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(
Expand All @@ -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);

Expand Down Expand Up @@ -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(
Expand All @@ -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<CasingMismatchCodeFix>(
new CodeFixTestFixtureConfig
{
AdditionalAnalyzers = [_analyzer]
});

fixture.TestCodeFix(currentCode, expectedCode, DiagnosticDescriptors.CasingMismatch);
}
}
}
Original file line number Diff line number Diff line change
@@ -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") { } }
Original file line number Diff line number Diff line change
@@ -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 { }
Original file line number Diff line number Diff line change
@@ -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)
{
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
codeunit 50100 MyCodeunit
{
var
MyList: List of [[|TEXT|]];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
codeunit 50100 MyCodeunit
{
var
MyList: List of [Text];
}
Original file line number Diff line number Diff line change
@@ -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) { }
}
}
Original file line number Diff line number Diff line change
@@ -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) { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
codeunit 50100 MyCodeunit
{
var
MyTable: Record [|"MY CUSTOMER"|];
}

table 50100 "My Customer"
{
fields
{
field(1; "Primary Key"; Integer) { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
codeunit 50100 MyCodeunit
{
var
MyTable: Record "My Customer";
}

table 50100 "My Customer"
{
fields
{
field(1; "Primary Key"; Integer) { }
}
}
Original file line number Diff line number Diff line change
@@ -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") { } }
Original file line number Diff line number Diff line change
@@ -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 { }
Original file line number Diff line number Diff line change
@@ -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)
{
}
}
}
Loading