Skip to content

Commit 216a1d1

Browse files
Implement LuceneDev6001, 6002, 6003 Analyzers & CodeFixes with Unit Tests (#14)
* Add analyzers, codefixes, sample and test files for 6001, 6002, 6003 * feat(analyzers): smart Span fixes, char optimizations,NRT updatesand add Documentation as per suggestion * Address LuceneDev600x review feedback and fix tests - LuceneDev6003 code fix: use getInnermostNodeForTie + descendant fallback so FindNode resolves to the LiteralExpressionSyntax. - LuceneDev6003 analyzer: detect char overload correctly for spans. ReadOnlySpan<T>.IndexOf(T) / LastIndexOf(T) are MemoryExtensions extensions, so the previous search on the containing type missed them. StartsWith/EndsWith intentionally remain excluded (no char overload exists for spans). - LuceneDev6001 analyzer: pass the invalid StringComparison value name to Diagnostic.Create so the {1} placeholder in the message format is substituted (previously rendered as literal "{1}"). - LuceneDev6001 code fix: use continue instead of return inside the diagnostic loop so multiple diagnostics in one file all get fixes offered. - Sample project: uncomment violating examples so the diagnostics fire live in the IDE (matches the existing LuceneDevXxxxSample convention). Add .editorconfig to downgrade error-severity rules to warning in the sample project so it still compiles. - LuceneDev6003 sample: rewrite to use the analyzer's target methods (IndexOf/LastIndexOf/StartsWith/EndsWith) rather than string.Equals, including a ReadOnlySpan<char> example. - Clean up excessive blank lines in 6002 code fix and a stray trailing space in a variable name. - Update 6001 tests to include the comparison-value argument. - Fix off-by-one spans in 6003 code fix tests and add a span test. * Renumber LuceneDev6xxx rules to contiguous IDs Replace the underscore-suffixed IDs (6001_1, 6001_2, 6002_1, 6002_2) with contiguous numeric IDs matching the rest of the Lucene.NET analyzer taxonomy: 6001_1 -> 6001 Missing StringComparison on String overload 6001_2 -> 6002 Invalid StringComparison on String overload 6002_1 -> 6003 Redundant Ordinal on span overload 6002_2 -> 6004 Invalid StringComparison on span overload 6003 -> 6005 Single-character string literal Each analyzer class still bundles the two related diagnostics that share symbol resolution; files are named with the ID range (e.g., LuceneDev6001_6002_StringComparisonAnalyzer.cs) and the LuceneDev6005 analyzer stands alone. Update Descriptors, Resources.resx, AnalyzerReleases.Unshipped.md, DiagnosticCategoryAndIdRanges.txt, samples, editorconfig overrides, and tests to reference the new IDs. Tighten the 6005 summary doc to note that spans only have char overloads for IndexOf/LastIndexOf. --------- Co-authored-by: Paul Irwin <paulirwin@gmail.com>
1 parent 998cc93 commit 216a1d1

23 files changed

Lines changed: 3652 additions & 23 deletions

DiagnosticCategoryAndIdRanges.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Globalization:
1818
Mobility:
1919
Performance:
2020
Security:
21-
Usage: LuceneDev6000-LuceneDev6003
21+
Usage: LuceneDev6000-LuceneDev6005
2222
Naming:
2323
Interoperability:
2424
Maintainability:

Directory.Packages.props

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,9 @@
2323
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
2424
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
2525
</PropertyGroup>
26-
2726
<PropertyGroup Label="Shared NuGet Package Reference Versions">
2827
<RoslynAnalyzerPackageVersion>5.3.0</RoslynAnalyzerPackageVersion>
2928
</PropertyGroup>
30-
3129
<ItemGroup Label="NuGet Package Reference Versions">
3230
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.9.50" />
3331
<PackageVersion Include="NUnit" Version="4.5.1" />
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file for additional information.
4+
* The ASF licenses this file under the Apache License, Version 2.0.
5+
*/
6+
7+
using System.Collections.Immutable;
8+
using System.Composition;
9+
using System.Linq;
10+
using System.Threading;
11+
using System.Threading.Tasks;
12+
using Lucene.Net.CodeAnalysis.Dev.Utility;
13+
using Microsoft.CodeAnalysis;
14+
using Microsoft.CodeAnalysis.CodeActions;
15+
using Microsoft.CodeAnalysis.CodeFixes;
16+
using Microsoft.CodeAnalysis.CSharp.Syntax;
17+
using Microsoft.CodeAnalysis.CSharp;
18+
19+
namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev6xxx
20+
{
21+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LuceneDev6001_6002_StringComparisonCodeFixProvider)), Shared]
22+
public sealed class LuceneDev6001_6002_StringComparisonCodeFixProvider : CodeFixProvider
23+
{
24+
private const string Ordinal = "Ordinal";
25+
private const string OrdinalIgnoreCase = "OrdinalIgnoreCase";
26+
private const string TitleOrdinal = "Use StringComparison.Ordinal";
27+
private const string TitleOrdinalIgnoreCase = "Use StringComparison.OrdinalIgnoreCase";
28+
29+
public override ImmutableArray<string> FixableDiagnosticIds =>
30+
ImmutableArray.Create(
31+
Descriptors.LuceneDev6001_MissingStringComparison.Id,
32+
Descriptors.LuceneDev6002_InvalidStringComparison.Id);
33+
34+
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
35+
36+
/// <summary>
37+
/// Registers available code fixes for all diagnostics in the context.
38+
/// </summary>
39+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
40+
{
41+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
42+
if (root == null) return;
43+
44+
// Iterate over ALL diagnostics in the context to ensure all issues are offered a fix.
45+
foreach (var diagnostic in context.Diagnostics)
46+
{
47+
var invocation = root.FindToken(diagnostic.Location.SourceSpan.Start)
48+
.Parent?
49+
.AncestorsAndSelf()
50+
.OfType<InvocationExpressionSyntax>()
51+
.FirstOrDefault();
52+
if (invocation == null) continue;
53+
54+
var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
55+
if (semanticModel == null) continue;
56+
57+
// Skip char literals and single-character string literals when safe (LuceneDev6005 handles conversion).
58+
var firstArgExpr = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression;
59+
if (firstArgExpr is LiteralExpressionSyntax lit)
60+
{
61+
if (lit.IsKind(SyntaxKind.CharacterLiteralExpression))
62+
continue;
63+
64+
if (lit.IsKind(SyntaxKind.StringLiteralExpression) && lit.Token.ValueText.Length == 1)
65+
{
66+
bool hasStringComparisonArg = invocation.ArgumentList.Arguments.Any(arg =>
67+
(semanticModel.GetTypeInfo(arg.Expression).Type is INamedTypeSymbol t &&
68+
t.ToDisplayString() == "System.StringComparison")
69+
|| (semanticModel.GetSymbolInfo(arg.Expression).Symbol is IFieldSymbol f &&
70+
f.ContainingType?.ToDisplayString() == "System.StringComparison"));
71+
72+
if (!hasStringComparisonArg)
73+
continue;
74+
}
75+
}
76+
77+
// --- Fix Registration Logic ---
78+
79+
if (diagnostic.Id == Descriptors.LuceneDev6001_MissingStringComparison.Id)
80+
{
81+
// Case 1: Argument is missing. Only offer Ordinal as the safe, conservative default.
82+
RegisterFix(context, invocation, Ordinal, TitleOrdinal, diagnostic);
83+
}
84+
else if (diagnostic.Id == Descriptors.LuceneDev6002_InvalidStringComparison.Id)
85+
{
86+
// Case 2: Invalid argument is present. Determine the best replacement.
87+
if (TryDetermineReplacement(invocation, semanticModel, out string? targetComparison))
88+
{
89+
var title = (targetComparison!) == Ordinal ? TitleOrdinal : TitleOrdinalIgnoreCase;
90+
RegisterFix(context, invocation, targetComparison!, title, diagnostic);
91+
}
92+
// If TryDetermineReplacement returns false, the argument is an invalid non-constant
93+
// expression (e.g., a variable). We skip the fix to avoid arbitrary changes.
94+
}
95+
}
96+
}
97+
98+
private static void RegisterFix(
99+
CodeFixContext context,
100+
InvocationExpressionSyntax invocation,
101+
string comparisonMember,
102+
string title,
103+
Diagnostic diagnostic)
104+
{
105+
context.RegisterCodeFix(CodeAction.Create(
106+
title: title,
107+
createChangedDocument: c => FixInvocationAsync(context.Document, invocation, comparisonMember, c),
108+
equivalenceKey: title),
109+
diagnostic);
110+
}
111+
112+
/// <summary>
113+
/// Determines the appropriate ordinal replacement (Ordinal or OrdinalIgnoreCase)
114+
/// for an existing culture-sensitive StringComparison argument.
115+
/// Only operates on constant argument values.
116+
/// </summary>
117+
/// <returns>True if a valid replacement was determined, false otherwise (e.g., if argument is non-constant).</returns>
118+
private static bool TryDetermineReplacement(InvocationExpressionSyntax invocation, SemanticModel semanticModel, out string? targetComparison)
119+
{
120+
targetComparison = null;
121+
var stringComparisonType = semanticModel.Compilation.GetTypeByMetadataName("System.StringComparison");
122+
var existingArg = invocation.ArgumentList.Arguments.FirstOrDefault(arg =>
123+
SymbolEqualityComparer.Default.Equals(
124+
semanticModel.GetTypeInfo(arg.Expression).Type, stringComparisonType));
125+
126+
if (existingArg != null)
127+
{
128+
var constVal = semanticModel.GetConstantValue(existingArg.Expression);
129+
if (constVal.HasValue && constVal.Value is int intVal)
130+
{
131+
// Map original comparison to corresponding ordinal variant for constant values
132+
switch ((System.StringComparison)intVal)
133+
{
134+
case System.StringComparison.CurrentCulture:
135+
case System.StringComparison.InvariantCulture:
136+
targetComparison = Ordinal;
137+
return true;
138+
case System.StringComparison.CurrentCultureIgnoreCase:
139+
case System.StringComparison.InvariantCultureIgnoreCase:
140+
targetComparison = OrdinalIgnoreCase;
141+
return true;
142+
case System.StringComparison.Ordinal:
143+
case System.StringComparison.OrdinalIgnoreCase:
144+
return false; // Already correct
145+
}
146+
}
147+
// Argument exists, but is not a constant value (e.g., a variable). We skip the fix.
148+
return false;
149+
}
150+
151+
// Should not be called for missing arguments by the caller.
152+
return false;
153+
}
154+
155+
/// <summary>
156+
/// Creates the new document by either replacing an existing StringComparison argument
157+
/// or adding a new one, based on the fix action.
158+
/// </summary>
159+
private static async Task<Document> FixInvocationAsync(Document document, InvocationExpressionSyntax invocation, string comparisonMember, CancellationToken cancellationToken)
160+
{
161+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
162+
if (root == null) return document;
163+
164+
var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
165+
var stringComparisonType = semanticModel?.Compilation.GetTypeByMetadataName("System.StringComparison");
166+
167+
// 1. Create the new StringComparison argument expression
168+
var stringComparisonExpr = SyntaxFactory.MemberAccessExpression(
169+
SyntaxKind.SimpleMemberAccessExpression,
170+
SyntaxFactory.IdentifierName("StringComparison"),
171+
SyntaxFactory.IdentifierName(comparisonMember));
172+
173+
var newArg = SyntaxFactory.Argument(stringComparisonExpr);
174+
175+
// 2. Find existing argument for replacement/addition check
176+
var existingArg = invocation.ArgumentList.Arguments.FirstOrDefault(arg =>
177+
semanticModel != null &&
178+
SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(arg.Expression).Type, stringComparisonType));
179+
180+
// 3. Perform the syntax replacement/addition
181+
InvocationExpressionSyntax newInvocation;
182+
if (existingArg != null)
183+
{
184+
// Argument exists (Replacement case: InvalidComparison)
185+
// Preserve leading/trailing trivia (spaces/comma) from the expression being replaced
186+
var newExprWithTrivia = stringComparisonExpr
187+
.WithLeadingTrivia(existingArg.Expression.GetLeadingTrivia())
188+
.WithTrailingTrivia(existingArg.Expression.GetTrailingTrivia());
189+
190+
var newArgWithTrivia = existingArg.WithExpression(newExprWithTrivia);
191+
192+
newInvocation = invocation.ReplaceNode(existingArg, newArgWithTrivia);
193+
}
194+
else
195+
{
196+
// Argument is missing (Addition case: MissingComparison)
197+
// Use AddArguments, relying on Roslyn to correctly handle comma/spacing trivia.
198+
newInvocation = invocation.WithArgumentList(
199+
invocation.ArgumentList.AddArguments(newArg)
200+
);
201+
}
202+
203+
// 4. Update the document root (Ensure using statement is present and replace invocation)
204+
var newRoot = EnsureSystemUsing(root).ReplaceNode(invocation, newInvocation);
205+
return document.WithSyntaxRoot(newRoot);
206+
}
207+
208+
/// <summary>
209+
/// Ensures a 'using System;' directive is present in the document.
210+
/// </summary>
211+
private static SyntaxNode EnsureSystemUsing(SyntaxNode root)
212+
{
213+
if (root is CompilationUnitSyntax compilationUnit)
214+
{
215+
var hasSystemUsing = compilationUnit.Usings.Any(u =>
216+
u.Name is IdentifierNameSyntax id && id.Identifier.ValueText == "System");
217+
218+
if (!hasSystemUsing)
219+
{
220+
var systemUsing = SyntaxFactory.UsingDirective(SyntaxFactory.IdentifierName("System"))
221+
.WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed);
222+
return compilationUnit.AddUsings(systemUsing);
223+
}
224+
}
225+
226+
return root;
227+
}
228+
}
229+
}

0 commit comments

Comments
 (0)