diff --git a/Directory.Packages.props b/Directory.Packages.props
index 99ec499..dd03cfa 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -23,11 +23,9 @@
true
true
-
4.14.0
-
@@ -46,5 +44,4 @@
-
diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev6xxx/LuceneDev6001_StringComparisonCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev6xxx/LuceneDev6001_StringComparisonCodeFixProvider.cs
new file mode 100644
index 0000000..b780d2b
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev6xxx/LuceneDev6001_StringComparisonCodeFixProvider.cs
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file for additional information.
+ * The ASF licenses this file under the Apache License, Version 2.0.
+ */
+
+using System.Collections.Immutable;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev6xxx
+{
+ [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LuceneDev6001_StringComparisonCodeFixProvider)), Shared]
+ public sealed class LuceneDev6001_StringComparisonCodeFixProvider : CodeFixProvider
+ {
+ private const string Ordinal = "Ordinal";
+ private const string OrdinalIgnoreCase = "OrdinalIgnoreCase";
+ private const string TitleOrdinal = "Use StringComparison.Ordinal";
+ private const string TitleOrdinalIgnoreCase = "Use StringComparison.OrdinalIgnoreCase";
+
+ public override ImmutableArray FixableDiagnosticIds =>
+ ImmutableArray.Create(
+ Descriptors.LuceneDev6001_MissingStringComparison.Id,
+ Descriptors.LuceneDev6001_InvalidStringComparison.Id);
+
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+ ///
+ /// Registers available code fixes for all diagnostics in the context.
+ ///
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root == null) return;
+
+ // Iterate over ALL diagnostics in the context to ensure all issues are offered a fix.
+ foreach (var diagnostic in context.Diagnostics)
+ {
+ var invocation = root.FindToken(diagnostic.Location.SourceSpan.Start)
+ .Parent?
+ .AncestorsAndSelf()
+ .OfType()
+ .FirstOrDefault();
+ if (invocation == null) continue;
+
+ var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+ if (semanticModel == null) continue;
+
+ //Double check to Skip char literals and single-character string literals when safe ---
+ var firstArgExpr = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression;
+ if (firstArgExpr is LiteralExpressionSyntax lit)
+ {
+ if (lit.IsKind(SyntaxKind.CharacterLiteralExpression))
+ return; // already char overload; no diagnostic
+
+ if (lit.IsKind(SyntaxKind.StringLiteralExpression) && lit.Token.ValueText.Length == 1)
+ {
+ // Check if a StringComparison argument is present
+ bool hasStringComparisonArgForLiteral = invocation.ArgumentList.Arguments.Any(arg =>
+ semanticModel.GetTypeInfo(arg.Expression).Type is INamedTypeSymbol t &&
+ t.ToDisplayString() == "System.StringComparison"
+ || (semanticModel.GetSymbolInfo(arg.Expression).Symbol is IFieldSymbol f &&
+ f.ContainingType?.ToDisplayString() == "System.StringComparison"));
+
+ if (!hasStringComparisonArgForLiteral)
+ {
+ // safe to convert to char (6003), so skip 6001 reporting
+ return;
+ }
+ // else: has StringComparison -> do not skip; let codefix handle it
+ }
+ }
+
+ // --- Fix Registration Logic ---
+
+ if (diagnostic.Id == Descriptors.LuceneDev6001_MissingStringComparison.Id)
+ {
+ // Case 1: Argument is missing. Only offer Ordinal as the safe, conservative default.
+ RegisterFix(context, invocation, Ordinal, TitleOrdinal, diagnostic);
+ }
+ else if (diagnostic.Id == Descriptors.LuceneDev6001_InvalidStringComparison.Id)
+ {
+ // Case 2: Invalid argument is present. Determine the best replacement.
+ if (TryDetermineReplacement(invocation, semanticModel, out string? targetComparison))
+ {
+ var title = (targetComparison!) == Ordinal ? TitleOrdinal : TitleOrdinalIgnoreCase;
+ RegisterFix(context, invocation, targetComparison!, title, diagnostic);
+ }
+ // If TryDetermineReplacement returns false, the argument is an invalid non-constant
+ // expression (e.g., a variable). We skip the fix to avoid arbitrary changes.
+ }
+ }
+ }
+
+ private static void RegisterFix(
+ CodeFixContext context,
+ InvocationExpressionSyntax invocation,
+ string comparisonMember,
+ string title,
+ Diagnostic diagnostic)
+ {
+ context.RegisterCodeFix(CodeAction.Create(
+ title: title,
+ createChangedDocument: c => FixInvocationAsync(context.Document, invocation, comparisonMember, c),
+ equivalenceKey: title),
+ diagnostic);
+ }
+
+ ///
+ /// Determines the appropriate ordinal replacement (Ordinal or OrdinalIgnoreCase)
+ /// for an existing culture-sensitive StringComparison argument.
+ /// Only operates on constant argument values.
+ ///
+ /// True if a valid replacement was determined, false otherwise (e.g., if argument is non-constant).
+ private static bool TryDetermineReplacement(InvocationExpressionSyntax invocation, SemanticModel semanticModel, out string? targetComparison)
+ {
+ targetComparison = null;
+ var stringComparisonType = semanticModel.Compilation.GetTypeByMetadataName("System.StringComparison");
+ var existingArg = invocation.ArgumentList.Arguments.FirstOrDefault(arg =>
+ SymbolEqualityComparer.Default.Equals(
+ semanticModel.GetTypeInfo(arg.Expression).Type, stringComparisonType));
+
+ if (existingArg != null)
+ {
+ var constVal = semanticModel.GetConstantValue(existingArg.Expression);
+ if (constVal.HasValue && constVal.Value is int intVal)
+ {
+ // Map original comparison to corresponding ordinal variant for constant values
+ switch ((System.StringComparison)intVal)
+ {
+ case System.StringComparison.CurrentCulture:
+ case System.StringComparison.InvariantCulture:
+ targetComparison = Ordinal;
+ return true;
+ case System.StringComparison.CurrentCultureIgnoreCase:
+ case System.StringComparison.InvariantCultureIgnoreCase:
+ targetComparison = OrdinalIgnoreCase;
+ return true;
+ case System.StringComparison.Ordinal:
+ case System.StringComparison.OrdinalIgnoreCase:
+ return false; // Already correct
+ }
+ }
+ // Argument exists, but is not a constant value (e.g., a variable). We skip the fix.
+ return false;
+ }
+
+ // Should not be called for missing arguments by the caller.
+ return false;
+ }
+
+ ///
+ /// Creates the new document by either replacing an existing StringComparison argument
+ /// or adding a new one, based on the fix action.
+ ///
+ private static async Task FixInvocationAsync(Document document, InvocationExpressionSyntax invocation, string comparisonMember, CancellationToken cancellationToken)
+ {
+ var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
+ if (root == null) return document;
+
+ var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
+ var stringComparisonType = semanticModel?.Compilation.GetTypeByMetadataName("System.StringComparison");
+
+ // 1. Create the new StringComparison argument expression
+ var stringComparisonExpr = SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxFactory.IdentifierName("StringComparison"),
+ SyntaxFactory.IdentifierName(comparisonMember));
+
+ var newArg = SyntaxFactory.Argument(stringComparisonExpr);
+
+ // 2. Find existing argument for replacement/addition check
+ var existingArg = invocation.ArgumentList.Arguments.FirstOrDefault(arg =>
+ semanticModel != null &&
+ SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(arg.Expression).Type, stringComparisonType));
+
+ // 3. Perform the syntax replacement/addition
+ InvocationExpressionSyntax newInvocation;
+ if (existingArg != null)
+ {
+ // Argument exists (Replacement case: InvalidComparison)
+ // Preserve leading/trailing trivia (spaces/comma) from the expression being replaced
+ var newExprWithTrivia = stringComparisonExpr
+ .WithLeadingTrivia(existingArg.Expression.GetLeadingTrivia())
+ .WithTrailingTrivia(existingArg.Expression.GetTrailingTrivia());
+
+ var newArgWithTrivia = existingArg.WithExpression(newExprWithTrivia);
+
+ newInvocation = invocation.ReplaceNode(existingArg, newArgWithTrivia);
+ }
+ else
+ {
+ // Argument is missing (Addition case: MissingComparison)
+ // Use AddArguments, relying on Roslyn to correctly handle comma/spacing trivia.
+ newInvocation = invocation.WithArgumentList(
+ invocation.ArgumentList.AddArguments(newArg)
+ );
+ }
+
+ // 4. Update the document root (Ensure using statement is present and replace invocation)
+ var newRoot = EnsureSystemUsing(root).ReplaceNode(invocation, newInvocation);
+ return document.WithSyntaxRoot(newRoot);
+ }
+
+ ///
+ /// Ensures a 'using System;' directive is present in the document.
+ ///
+ private static SyntaxNode EnsureSystemUsing(SyntaxNode root)
+ {
+ if (root is CompilationUnitSyntax compilationUnit)
+ {
+ var hasSystemUsing = compilationUnit.Usings.Any(u =>
+ u.Name is IdentifierNameSyntax id && id.Identifier.ValueText == "System");
+
+ if (!hasSystemUsing)
+ {
+ var systemUsing = SyntaxFactory.UsingDirective(SyntaxFactory.IdentifierName("System"))
+ .WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed);
+ return compilationUnit.AddUsings(systemUsing);
+ }
+ }
+
+ return root;
+ }
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev6xxx/LuceneDev6002_SpanComparisonCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev6xxx/LuceneDev6002_SpanComparisonCodeFixProvider.cs
new file mode 100644
index 0000000..81ab0da
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev6xxx/LuceneDev6002_SpanComparisonCodeFixProvider.cs
@@ -0,0 +1,344 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.Collections.Immutable;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev6xxx
+{
+ [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LuceneDev6002_SpanComparisonCodeFixProvider)), Shared]
+ public sealed class LuceneDev6002_SpanComparisonCodeFixProvider : CodeFixProvider
+ {
+ private const string TitleRemoveOrdinal = "Remove redundant StringComparison.Ordinal";
+ private const string TitleOptimizeToDefaultOrdinal = "Optimize to default Ordinal comparison (remove argument)";
+ private const string TitleReplaceWithOrdinalIgnoreCase = "Use StringComparison.OrdinalIgnoreCase";
+
+ // Integer values for StringComparison Enum members (used for semantic analysis)
+ private const int CurrentCulture = 0;
+ private const int CurrentCultureIgnoreCase = 1;
+ private const int InvariantCulture = 2;
+ private const int InvariantCultureIgnoreCase = 3;
+ private const int Ordinal = 4;
+ private const int OrdinalIgnoreCase = 5;
+
+ public override ImmutableArray FixableDiagnosticIds =>
+ ImmutableArray.Create(
+ Descriptors.LuceneDev6002_RedundantOrdinal.Id,
+ Descriptors.LuceneDev6002_InvalidComparison.Id);
+
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root == null)
+ return;
+
+ var diagnostic = context.Diagnostics.First();
+ var diagnosticSpan = diagnostic.Location.SourceSpan;
+ var invocation = root.FindNode(diagnosticSpan).FirstAncestorOrSelf();
+ if (invocation == null)
+ return;
+
+ //Double check to Skip char literals and single-character string literals when safe ---
+ var firstArgExpr = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression;
+
+ if (firstArgExpr is LiteralExpressionSyntax lit)
+
+ {
+
+ if (lit.IsKind(SyntaxKind.CharacterLiteralExpression))
+
+ return; // already char overload; skip 6002 fix
+
+
+
+ if (lit.IsKind(SyntaxKind.StringLiteralExpression) && lit.Token.ValueText.Length == 1)
+
+ {
+
+ // Check if a StringComparison argument is present
+
+ var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+
+ if (semanticModel == null)
+
+ return;
+
+
+
+ bool hasStringComparisonArgForLiteral = invocation.ArgumentList.Arguments.Any(arg =>
+
+ semanticModel.GetTypeInfo(arg.Expression).Type is INamedTypeSymbol t &&
+
+ t.ToDisplayString() == "System.StringComparison"
+
+ || (semanticModel.GetSymbolInfo(arg.Expression).Symbol is IFieldSymbol f &&
+
+ f.ContainingType?.ToDisplayString() == "System.StringComparison"));
+
+
+
+ if (!hasStringComparisonArgForLiteral)
+
+ {
+
+ // safe to convert to char (6003), skip 6002 fix
+
+ return;
+
+ }
+
+ // else: has StringComparison -> let the codefix continue
+
+ }
+
+ }
+ switch (diagnostic.Id)
+ {
+ case var id when id == Descriptors.LuceneDev6002_RedundantOrdinal.Id:
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: TitleRemoveOrdinal,
+ createChangedDocument: c => RemoveStringComparisonArgumentAsync(context.Document, invocation, c),
+ equivalenceKey: "RemoveRedundantOrdinal"),
+ diagnostic);
+ break;
+
+ case var id when id == Descriptors.LuceneDev6002_InvalidComparison.Id:
+ var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+ if (semanticModel == null)
+ return;
+
+ var comparisonArg = invocation.ArgumentList.Arguments.FirstOrDefault(arg =>
+ semanticModel.GetTypeInfo(arg.Expression).Type?.ToDisplayString() == "System.StringComparison");
+
+ if (comparisonArg == null)
+ return;
+
+ var originalComparisonValue = semanticModel.GetConstantValue(comparisonArg.Expression);
+
+ if (originalComparisonValue.HasValue && originalComparisonValue.Value is int intValue)
+ {
+ // Check if the original comparison was case-insensitive
+ bool wasCaseInsensitive = intValue == CurrentCultureIgnoreCase ||
+ intValue == InvariantCultureIgnoreCase;
+
+ if (wasCaseInsensitive)
+ {
+ // Fix 1: Case-Insensitive Invalid -> OrdinalIgnoreCase (Single, targeted fix)
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: TitleReplaceWithOrdinalIgnoreCase,
+ createChangedDocument: c => ReplaceWithStringComparisonAsync(context.Document, invocation, "OrdinalIgnoreCase", c),
+ equivalenceKey: "ReplaceWithOrdinalIgnoreCase"),
+ diagnostic);
+
+ // Optionally, still offer the case-sensitive fix for completeness
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: "Use StringComparison.Ordinal", // Offer Ordinal as second choice
+ createChangedDocument: c => ReplaceWithStringComparisonAsync(context.Document, invocation, "Ordinal", c),
+ equivalenceKey: "ReplaceWithOrdinal"),
+ diagnostic);
+ }
+ else
+ {
+ // Fix 1: Case-Sensitive Invalid (CurrentCulture/InvariantCulture) -> Optimal Default (Remove argument)
+ // This skips the redundant intermediate step (Ordinal)
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: TitleOptimizeToDefaultOrdinal,
+ createChangedDocument: c => RemoveStringComparisonArgumentAsync(context.Document, invocation, c),
+ equivalenceKey: "OptimizeToDefaultOrdinal"),
+ diagnostic);
+
+ // Optionally, still offer the case-insensitive fix for completeness
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: TitleReplaceWithOrdinalIgnoreCase,
+ createChangedDocument: c => ReplaceWithStringComparisonAsync(context.Document, invocation, "OrdinalIgnoreCase", c),
+ equivalenceKey: "ReplaceWithOrdinalIgnoreCase"),
+ diagnostic);
+ }
+ }
+ break;
+ }
+ }
+
+ private static async Task RemoveStringComparisonArgumentAsync(
+ Document document,
+ InvocationExpressionSyntax invocation,
+ CancellationToken cancellationToken)
+ {
+ var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
+ if (root == null)
+ return document;
+
+ var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
+ if (semanticModel == null)
+ return document;
+
+ var compilation = semanticModel.Compilation;
+ var stringComparisonType = compilation.GetTypeByMetadataName("System.StringComparison");
+ if (stringComparisonType == null)
+ return document;
+
+ // Find the StringComparison argument
+ ArgumentSyntax? argumentToRemove = null;
+ foreach (var arg in invocation.ArgumentList.Arguments)
+ {
+ var argType = semanticModel.GetTypeInfo(arg.Expression, cancellationToken).Type;
+ if (argType != null && SymbolEqualityComparer.Default.Equals(argType, stringComparisonType))
+ {
+ argumentToRemove = arg;
+ break;
+ }
+
+ // fallback: check if it's a member access of StringComparison.*
+ if (argumentToRemove == null && arg.Expression is MemberAccessExpressionSyntax member &&
+ member.Expression is IdentifierNameSyntax idName &&
+ idName.Identifier.ValueText == "StringComparison")
+ {
+ argumentToRemove = arg;
+ break;
+ }
+
+ }
+
+ if (argumentToRemove == null)
+ return document;
+
+ // Remove the argument
+ var newArguments = invocation.ArgumentList.Arguments.Remove(argumentToRemove);
+ var newArgumentList = invocation.ArgumentList.WithArguments(newArguments);
+
+ // CRITICAL FIX: Removed NormalizeWhitespace() which causes test instability
+ var newInvocation = invocation.WithArgumentList(newArgumentList)
+ .WithTriviaFrom(invocation); // Preserving trivia on the outer node is usually fine
+
+ var newRoot = root.ReplaceNode(invocation, newInvocation);
+ return document.WithSyntaxRoot(newRoot);
+ }
+
+ private static async Task ReplaceWithStringComparisonAsync(
+ Document document,
+ InvocationExpressionSyntax invocation,
+ string comparisonMember,
+ CancellationToken cancellationToken)
+ {
+ var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
+ if (root == null)
+ return document;
+
+ var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
+ if (semanticModel == null)
+ return document;
+
+ var compilation = semanticModel.Compilation;
+ var stringComparisonType = compilation.GetTypeByMetadataName("System.StringComparison");
+ if (stringComparisonType == null)
+ return document;
+
+ // Find the StringComparison argument
+ ArgumentSyntax? argumentToReplace = null;
+ foreach (var arg in invocation.ArgumentList.Arguments)
+ {
+ var argType = semanticModel.GetTypeInfo(arg.Expression, cancellationToken).Type;
+ if (argType != null && SymbolEqualityComparer.Default.Equals(argType, stringComparisonType))
+ {
+ argumentToReplace = arg;
+ break;
+ }
+
+ // fallback: check if it's a member access of StringComparison.*
+ if (argumentToReplace == null && arg.Expression is MemberAccessExpressionSyntax member &&
+ member.Expression is IdentifierNameSyntax idName &&
+ idName.Identifier.ValueText == "StringComparison")
+ {
+ argumentToReplace = arg;
+ break;
+ }
+
+ }
+
+ if (argumentToReplace == null)
+ return document;
+
+ // Check if argument already uses System.StringComparison
+ bool isFullyQualified = argumentToReplace.Expression.ToString().StartsWith("System.StringComparison");
+
+ // Create new StringComparison expression
+ var baseExpression = isFullyQualified
+ ? (ExpressionSyntax)SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxFactory.IdentifierName("System"),
+ SyntaxFactory.IdentifierName("StringComparison"))
+ : SyntaxFactory.IdentifierName("StringComparison");
+
+ var newExpression = SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ baseExpression,
+ SyntaxFactory.IdentifierName(comparisonMember));
+
+
+ var newArgument = argumentToReplace.WithExpression(newExpression);
+
+ // CRITICAL FIX: Removed WithTriviaFrom(invocation) and NormalizeWhitespace() which cause test instability
+ var newInvocation = invocation.ReplaceNode(argumentToReplace, newArgument);
+
+ var newRoot = root;
+ if (!isFullyQualified)
+ {
+ newRoot = EnsureSystemUsing(newRoot);
+ }
+ newRoot = newRoot.ReplaceNode(invocation, newInvocation);
+ return document.WithSyntaxRoot(newRoot);
+ }
+
+ // EnsureSystemUsing remains unchanged as it looks correct for adding a using directive
+ private static SyntaxNode EnsureSystemUsing(SyntaxNode root)
+ {
+ if (root is CompilationUnitSyntax compilationUnit)
+ {
+ var hasSystemUsing = compilationUnit.Usings.Any(u =>
+ u.Name is IdentifierNameSyntax id && id.Identifier.ValueText == "System");
+
+ // only add if missing
+ if (!hasSystemUsing)
+ {
+ var systemUsing = SyntaxFactory.UsingDirective(SyntaxFactory.IdentifierName("System"))
+ .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed);
+
+ return compilationUnit.AddUsings(systemUsing);
+ }
+ }
+
+ return root;
+ }
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev6xxx/LuceneDev6003_SingleCharStringCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev6xxx/LuceneDev6003_SingleCharStringCodeFixProvider.cs
new file mode 100644
index 0000000..b605425
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev6xxx/LuceneDev6003_SingleCharStringCodeFixProvider.cs
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Collections.Immutable;
+using System.Composition;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx
+{
+ [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LuceneDev6003_SingleCharStringCodeFixProvider))]
+ [Shared]
+ public sealed class LuceneDev6003_SingleCharStringCodeFixProvider : CodeFixProvider
+ {
+ public override ImmutableArray FixableDiagnosticIds
+ => ImmutableArray.Create(Descriptors.LuceneDev6003_SingleCharStringAnalyzer.Id);
+
+ public override FixAllProvider GetFixAllProvider()
+ => WellKnownFixAllProviders.BatchFixer;
+
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var diagnostic = context.Diagnostics[0];
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root == null)
+ return;
+
+ var diagnosticSpan = diagnostic.Location.SourceSpan;
+ var node = root.FindNode(diagnosticSpan);
+
+ if (node is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression))
+ {
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ "Use char literal",
+ c => ReplaceWithCharLiteralAsync(context.Document, literal, c),
+ nameof(LuceneDev6003_SingleCharStringCodeFixProvider)),
+ diagnostic);
+ }
+ }
+
+ private static async Task ReplaceWithCharLiteralAsync(
+ Document document,
+ LiteralExpressionSyntax stringLiteral,
+ CancellationToken cancellationToken)
+ {
+ var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
+ if (root == null)
+ return document;
+
+ // Get the original escaped token text (e.g., "\"", "\n", "H")
+ var token = stringLiteral.Token;
+
+ // Get unescaped value
+ var valueText = token.ValueText;
+ if (string.IsNullOrEmpty(valueText) || valueText.Length != 1)
+ return document;
+
+ char ch = valueText[0];
+
+ // Escape it properly as a char literal
+ string escapedCharText = EscapeCharLiteral(ch);
+ var charLiteral = SyntaxFactory.LiteralExpression(
+ SyntaxKind.CharacterLiteralExpression,
+ SyntaxFactory.Literal(escapedCharText, ch));
+
+ var newRoot = root.ReplaceNode(stringLiteral, charLiteral);
+ return document.WithSyntaxRoot(newRoot);
+ }
+
+ private static string EscapeCharLiteral(char ch)
+ {
+ switch (ch)
+ {
+ case '\'':
+ return @"'\''"; // escape single quote
+ case '\\':
+ return @"'\\'"; // escape backslash
+ case '\n':
+ return @"'\n'";
+ case '\r':
+ return @"'\r'";
+ case '\t':
+ return @"'\t'";
+ case '\0':
+ return @"'\0'";
+ case '\b':
+ return @"'\b'";
+ case '\f':
+ return @"'\f'";
+ case '\v':
+ return @"'\v'";
+ default:
+ // Printable character or Unicode escape
+ if (char.IsControl(ch) || char.IsSurrogate(ch))
+ {
+ // Unicode escape sequence
+ return $"'\\u{((int)ch).ToString("X4", CultureInfo.InvariantCulture)}'";
+ }
+ return $"'{ch}'";
+ }
+ }
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev.Sample/Lucene.Net.CodeAnalysis.Dev.Sample.csproj b/src/Lucene.Net.CodeAnalysis.Dev.Sample/Lucene.Net.CodeAnalysis.Dev.Sample.csproj
index baa0e34..d29b0fc 100644
--- a/src/Lucene.Net.CodeAnalysis.Dev.Sample/Lucene.Net.CodeAnalysis.Dev.Sample.csproj
+++ b/src/Lucene.Net.CodeAnalysis.Dev.Sample/Lucene.Net.CodeAnalysis.Dev.Sample.csproj
@@ -1,4 +1,4 @@
-
+
-
+
-
+
-
+
diff --git a/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev6xxx/LuceneDev6001_StringComparisonSample.cs b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev6xxx/LuceneDev6001_StringComparisonSample.cs
new file mode 100644
index 0000000..638909a
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev6xxx/LuceneDev6001_StringComparisonSample.cs
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+namespace Lucene.Net.CodeAnalysis.Dev.Sample;
+
+public class LuceneDev6001_StringComparisonSample
+{
+ // public void BadExample_MissingStringComparison()
+ // {
+ // string text = "Hello World";
+
+ // //Missing StringComparison parameter
+ // int index = text.IndexOf("Hello");
+ // bool starts = text.StartsWith("Hello");
+ // bool ends = text.EndsWith("World");
+ // }
+
+ public void GoodExample_Ordinal()
+ {
+ string text = "Hello World";
+
+ //Correct usage with StringComparison.Ordinal
+ int index = text.IndexOf("Hello", System.StringComparison.Ordinal);
+ bool starts = text.StartsWith("Hello", System.StringComparison.Ordinal);
+ bool ends = text.EndsWith("World", System.StringComparison.Ordinal);
+ }
+
+ public void GoodExample_OrdinalIgnoreCase()
+ {
+ string text = "Hello World";
+
+ // Correct usage with StringComparison.OrdinalIgnoreCase
+ int index = text.IndexOf("hello", System.StringComparison.OrdinalIgnoreCase);
+ bool starts = text.StartsWith("HELLO", System.StringComparison.OrdinalIgnoreCase);
+ bool ends = text.EndsWith("world", System.StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev6xxx/LuceneDev6002_SpanComparisonSample.cs b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev6xxx/LuceneDev6002_SpanComparisonSample.cs
new file mode 100644
index 0000000..5f16f62
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev6xxx/LuceneDev6002_SpanComparisonSample.cs
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+using System;
+
+namespace Lucene.Net.CodeAnalysis.Dev.Sample.LuceneDev6xxx
+{
+ ///
+ /// Sample code demonstrating LuceneDev6002 analyzer rules for Span types.
+ /// Rule: Span types should not use StringComparison.Ordinal (redundant)
+ /// and must only use Ordinal or OrdinalIgnoreCase.
+ ///
+ public class LuceneDev6002_SpanComparisonSample
+ {
+ // public void BadExamples_RedundantOrdinal()
+ // {
+ // ReadOnlySpan span = "Hello World".AsSpan();
+
+ // // Redundant StringComparison.Ordinal
+ // int index1 = span.IndexOf("Hello".AsSpan(), StringComparison.Ordinal);
+ // int index2 = span.LastIndexOf("World".AsSpan(), StringComparison.Ordinal);
+ // bool starts = span.StartsWith("Hello".AsSpan(), StringComparison.Ordinal);
+ // bool ends = span.EndsWith("World".AsSpan(), StringComparison.Ordinal);
+ // }
+
+ // public void BadExamples_InvalidComparison()
+ // {
+ // ReadOnlySpan span = "Hello World".AsSpan();
+
+ // // Culture-sensitive comparisons are not allowed on Span types
+ // int index1 = span.IndexOf("Hello", StringComparison.CurrentCulture);
+ // int index2 = span.LastIndexOf("World", StringComparison.CurrentCultureIgnoreCase);
+ // bool starts = span.StartsWith("Hello", StringComparison.InvariantCulture);
+ // bool ends = span.EndsWith("World", StringComparison.InvariantCultureIgnoreCase);
+ // }
+
+ public void GoodExamples_NoStringComparison()
+ {
+ ReadOnlySpan span = "Hello World".AsSpan();
+
+ // Correct: defaults to Ordinal
+ int index1 = span.IndexOf("Hello".AsSpan());
+ int index2 = span.LastIndexOf("World".AsSpan());
+ bool starts = span.StartsWith("Hello".AsSpan());
+ bool ends = span.EndsWith("World".AsSpan());
+
+ // Single char operations
+ int charIndex = span.IndexOf('H');
+ bool startsWithChar = span[0] == 'H';
+ }
+
+ public void GoodExamples_WithOrdinalIgnoreCase()
+ {
+ ReadOnlySpan span = "Hello World".AsSpan();
+
+ // Correct: case-insensitive search
+ int index = span.IndexOf("hello", StringComparison.OrdinalIgnoreCase);
+ int lastIndex = span.LastIndexOf("WORLD", StringComparison.OrdinalIgnoreCase);
+ bool starts = span.StartsWith("HELLO", StringComparison.OrdinalIgnoreCase);
+ bool ends = span.EndsWith("world", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public void RealWorldExamples()
+ {
+ string path = @"C:\Users\Documents\file.txt";
+ ReadOnlySpan pathSpan = path.AsSpan();
+
+ // Correct: OrdinalIgnoreCase allowed
+ bool isTxtFile = pathSpan.EndsWith(".txt", StringComparison.OrdinalIgnoreCase);
+
+ // Correct: No StringComparison needed
+ ReadOnlySpan url = "https://example.com".AsSpan();
+ bool isHttps = url.StartsWith("https://");
+
+ ReadOnlySpan token = "Bearer:abc123".AsSpan();
+ int separatorIndex = token.IndexOf(':');
+ }
+
+ public void StringTypeComparison()
+ {
+ // Analyzer applies only to Span types
+ string text = "Hello World";
+
+ // String types require StringComparison
+ int index = text.IndexOf("Hello", StringComparison.Ordinal);
+
+ // Span types should not specify Ordinal
+ ReadOnlySpan span = text.AsSpan();
+ int index2 = span.IndexOf("Hello");
+ }
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev6xxx/LuceneDev6003_SingleCharStringSample.cs b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev6xxx/LuceneDev6003_SingleCharStringSample.cs
new file mode 100644
index 0000000..460737a
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev6xxx/LuceneDev6003_SingleCharStringSample.cs
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+using System;
+
+namespace Lucene.Net.CodeAnalysis.Dev.Sample.LuceneDev6xxx
+{
+ ///
+ /// Sample code for LuceneDev6003: Suggest using char overloads instead of single-character string literals.
+ ///
+ public class LuceneDev6003_SingleCharStringSample
+ {
+ public void Example()
+ {
+ string input = "Hello";
+
+ // BAD: Using string.Equals with single-character string literal
+ // if (string.Equals(input[0].ToString(), "H"))
+ // {
+ // Console.WriteLine("Starts with H");
+ // }
+
+ // BAD: Using Equals instance method
+ // if (input[0].ToString().Equals("H"))
+ // {
+ // Console.WriteLine("Starts with H");
+ // }
+
+ // GOOD: Using char comparison instead of string
+ if (input[0] == 'H')
+ {
+ Console.WriteLine("Starts with H");
+ }
+
+ //GOOD: Using Char.Equals
+ if (char.Equals(input[0], 'H'))
+ {
+ Console.WriteLine("Starts with H");
+ }
+ }
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md b/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md
index 30950e2..538ae10 100644
--- a/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md
+++ b/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md
@@ -5,6 +5,8 @@ Rule ID | Category | Severity | Notes
LuceneDev1007 | Design | Warning | Generic Dictionary indexer should not be used to retrieve values because it may throw KeyNotFoundException (value type value)
LuceneDev1008 | Design | Warning | Generic Dictionary indexer should not be used to retrieve values because it may throw KeyNotFoundException (reference type value)
LuceneDev6000 | Usage | Info | IDictionary indexer may be used to retrieve values, but must be checked for null before using the value
-LuceneDev6001 | Usage | Error | String overloads of StartsWith/EndsWith/IndexOf/LastIndexOf must be called with StringComparison.Ordinal or StringComparison.OrdinalIgnoreCase
-LuceneDev6002 | Usage | Warning | Span overloads of StartsWith/EndsWith/IndexOf/LastIndexOf should not pass non-Ordinal StringComparison
+LuceneDev6001_1 | Usage | Error | Missing StringComparison argument in String overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; must use Ordinal/OrdinalIgnoreCase
+LuceneDev6001_2 | Usage | Error | Invalid StringComparison value in String overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; only Ordinal/OrdinalIgnoreCase allowed
+LuceneDev6002_1 | Usage | Warning | Redundant StringComparison.Ordinal argument in Span overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; should be removed
+LuceneDev6002_2 | Usage | Error | Invalid StringComparison value in Span overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; only Ordinal or OrdinalIgnoreCase allowed
LuceneDev6003 | Usage | Info | Single-character string arguments should use the char overload of StartsWith/EndsWith/IndexOf/LastIndexOf instead of a string
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_StringComparisonAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_StringComparisonAnalyzer.cs
new file mode 100644
index 0000000..59e65a8
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6001_StringComparisonAnalyzer.cs
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.Collections.Immutable;
+using System.Linq;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx
+{
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class LuceneDev6001_StringComparisonAnalyzer : DiagnosticAnalyzer
+ {
+ private static readonly ImmutableHashSet TargetMethodNames =
+ ImmutableHashSet.Create("StartsWith", "EndsWith", "IndexOf", "LastIndexOf");
+
+ public override ImmutableArray SupportedDiagnostics
+ => ImmutableArray.Create(
+ Descriptors.LuceneDev6001_MissingStringComparison,
+ Descriptors.LuceneDev6001_InvalidStringComparison);
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);
+ context.EnableConcurrentExecution();
+
+ context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
+ }
+
+ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext ctx)
+ {
+ var invocation = (InvocationExpressionSyntax)ctx.Node;
+
+ if (!(invocation.Expression is MemberAccessExpressionSyntax memberAccess))
+ return;
+
+ var methodName = memberAccess.Name.Identifier.ValueText;
+ if (!TargetMethodNames.Contains(methodName))
+ return;
+
+ var semantic = ctx.SemanticModel;
+ var compilation = semantic.Compilation;
+ var stringComparisonType = compilation.GetTypeByMetadataName("System.StringComparison");
+
+ if (stringComparisonType == null)
+ return;
+
+ // Skip char literals and single-character string literals when safe ---
+ // early in AnalyzeInvocation, after verifying target method & span/string scope
+ var firstArgExpr = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression;
+ if (firstArgExpr is LiteralExpressionSyntax lit)
+ {
+ if (lit.IsKind(SyntaxKind.CharacterLiteralExpression))
+ return; // already char overload; no diagnostic
+
+ if (lit.IsKind(SyntaxKind.StringLiteralExpression) && lit.Token.ValueText.Length == 1)
+ {
+ // Check if a StringComparison argument is present
+ bool hasStringComparisonArgForLiteral = invocation.ArgumentList.Arguments.Any(arg =>
+ semantic.GetTypeInfo(arg.Expression).Type is INamedTypeSymbol t &&
+ t.ToDisplayString() == "System.StringComparison"
+ || (semantic.GetSymbolInfo(arg.Expression).Symbol is IFieldSymbol f &&
+ f.ContainingType?.ToDisplayString() == "System.StringComparison"));
+
+ if (!hasStringComparisonArgForLiteral )
+ {
+ // safe to convert to char (6003), so skip 6001 reporting
+ return;
+ }
+ // else: has StringComparison -> do not skip; let 6001/6002 validate or codefix handle it
+ }
+ }
+
+
+ // Get symbol info
+ var symbolInfo = semantic.GetSymbolInfo(memberAccess);
+ var methodSymbol = symbolInfo.Symbol as IMethodSymbol;
+ var candidateSymbols = symbolInfo.CandidateSymbols.OfType().ToImmutableArray();
+
+ // Determine if containing type qualifies: System.String or J2N.StringBuilderExtensions variants
+ static bool ContainingTypeIsStringOrJ2N(INamedTypeSymbol? containingType)
+ {
+ if (containingType == null) return false;
+ if (containingType.SpecialType == SpecialType.System_String)
+ return true;
+
+ // Accept both "J2N.Text.StringBuilderExtensions" and "J2N.StringBuilderExtensions"
+ var fullname = containingType.ToDisplayString();
+ return fullname == "J2N.Text.StringBuilderExtensions" || fullname == "J2N.StringBuilderExtensions";
+ }
+
+ // Check if method has StringComparison parameter
+ static bool HasStringComparisonParameter(IMethodSymbol? m, INamedTypeSymbol scType)
+ {
+ if (m == null) return false;
+ return m.Parameters.Any(p => SymbolEqualityComparer.Default.Equals(p.Type, scType));
+ }
+
+ // Check if invocation has StringComparison argument and validate it
+ var (hasStringComparisonArg, isValidValue, invalidArgLocation) =
+ CheckStringComparisonArgument(invocation, semantic, stringComparisonType);
+
+ // If resolved symbol available
+ if (methodSymbol != null)
+ {
+ // Only apply rule to System.String or J2N.StringBuilderExtensions containing type
+ if (!ContainingTypeIsStringOrJ2N(methodSymbol.ContainingType))
+ return;
+
+ // If the method has StringComparison parameter in signature
+ bool methodHasComparisonParam = HasStringComparisonParameter(methodSymbol, stringComparisonType);
+
+ if (hasStringComparisonArg)
+ {
+ // Argument is present - check if it's valid
+ if (!isValidValue)
+ {
+ var diag = Diagnostic.Create(
+ Descriptors.LuceneDev6001_InvalidStringComparison,
+ invalidArgLocation ?? memberAccess.Name.GetLocation(),
+ methodName);
+ ctx.ReportDiagnostic(diag);
+ }
+ return;
+ }
+
+ // No StringComparison argument provided
+ if (!methodHasComparisonParam)
+ {
+ // Method doesn't have StringComparison parameter - report error
+ var diag = Diagnostic.Create(
+ Descriptors.LuceneDev6001_MissingStringComparison,
+ memberAccess.Name.GetLocation(),
+ methodName);
+ ctx.ReportDiagnostic(diag);
+ }
+
+ return;
+ }
+
+ // Handle ambiguous candidates
+ if (candidateSymbols.Length > 0)
+ {
+ // Check if any candidate is from String or J2N types
+ var relevantCandidates = candidateSymbols
+ .Where(c => ContainingTypeIsStringOrJ2N(c.ContainingType))
+ .ToImmutableArray();
+
+ if (relevantCandidates.Length == 0)
+ return;
+
+ // If StringComparison argument is provided
+ if (hasStringComparisonArg)
+ {
+ if (!isValidValue)
+ {
+ var diag = Diagnostic.Create(
+ Descriptors.LuceneDev6001_InvalidStringComparison,
+ invalidArgLocation ?? memberAccess.Name.GetLocation(),
+ methodName);
+ ctx.ReportDiagnostic(diag);
+ }
+ return;
+ }
+
+ // No StringComparison argument - check if any candidate has it
+ bool anyCandidateHasComparison = relevantCandidates
+ .Any(c => HasStringComparisonParameter(c, stringComparisonType));
+
+ if (!anyCandidateHasComparison)
+ {
+ // None of the candidates have StringComparison parameter
+ var diag = Diagnostic.Create(
+ Descriptors.LuceneDev6001_MissingStringComparison,
+ memberAccess.Name.GetLocation(),
+ methodName);
+ ctx.ReportDiagnostic(diag);
+ }
+ }
+ }
+
+ private static (bool hasArgument, bool isValid, Location? location) CheckStringComparisonArgument(
+ InvocationExpressionSyntax invocation,
+ SemanticModel semantic,
+ INamedTypeSymbol stringComparisonType)
+ {
+ foreach (var arg in invocation.ArgumentList.Arguments)
+ {
+ var argType = semantic.GetTypeInfo(arg.Expression).Type;
+
+ // Check if argument type is StringComparison
+ if (argType != null && SymbolEqualityComparer.Default.Equals(argType, stringComparisonType))
+ {
+ bool isValid = IsValidStringComparisonValue(semantic, arg.Expression, stringComparisonType);
+ return (true, isValid, arg.Expression.GetLocation());
+ }
+
+ // Also check for enum member access (e.g., StringComparison.Ordinal)
+ var argSymbol = semantic.GetSymbolInfo(arg.Expression).Symbol as IFieldSymbol;
+ if (argSymbol != null && SymbolEqualityComparer.Default.Equals(argSymbol.ContainingType, stringComparisonType))
+ {
+ bool isValid = IsValidStringComparisonValue(semantic, arg.Expression, stringComparisonType);
+ return (true, isValid, arg.Expression.GetLocation());
+ }
+ }
+
+ return (false, true, null);
+ }
+
+ private static bool IsValidStringComparisonValue(
+ SemanticModel semantic,
+ ExpressionSyntax expression,
+ INamedTypeSymbol stringComparisonType)
+ {
+ // Get the constant value if available
+ var constantValue = semantic.GetConstantValue(expression);
+ if (constantValue.HasValue && constantValue.Value is int intValue)
+ {
+ // StringComparison.Ordinal = 4, OrdinalIgnoreCase = 5
+ return intValue == 4 || intValue == 5;
+ }
+
+ // Try to get field symbol
+ var symbolInfo = semantic.GetSymbolInfo(expression);
+ var fieldSymbol = symbolInfo.Symbol as IFieldSymbol;
+
+ if (fieldSymbol != null && SymbolEqualityComparer.Default.Equals(fieldSymbol.ContainingType, stringComparisonType))
+ {
+ var memberName = fieldSymbol.Name;
+ return memberName == "Ordinal" || memberName == "OrdinalIgnoreCase";
+ }
+
+ // If we can't determine, be conservative and allow it
+ return true;
+ }
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6002_SpanComparisonAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6002_SpanComparisonAnalyzer.cs
new file mode 100644
index 0000000..fc9d413
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6002_SpanComparisonAnalyzer.cs
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.Collections.Immutable;
+using System.Linq;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx
+{
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class LuceneDev6002_SpanComparisonAnalyzer : DiagnosticAnalyzer
+ {
+ private static readonly ImmutableHashSet TargetMethodNames =
+ ImmutableHashSet.Create("StartsWith", "EndsWith", "IndexOf", "LastIndexOf");
+
+ public override ImmutableArray SupportedDiagnostics
+ => ImmutableArray.Create(
+ Descriptors.LuceneDev6002_RedundantOrdinal,
+ Descriptors.LuceneDev6002_InvalidComparison);
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);
+ context.EnableConcurrentExecution();
+
+ context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
+ }
+
+ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext ctx)
+ {
+ var invocation = (InvocationExpressionSyntax)ctx.Node;
+
+ if (!(invocation.Expression is MemberAccessExpressionSyntax memberAccess))
+ return;
+
+ var methodName = memberAccess.Name.Identifier.ValueText;
+ if (!TargetMethodNames.Contains(methodName))
+ return;
+
+ var semantic = ctx.SemanticModel;
+ var compilation = semantic.Compilation;
+ var stringComparisonType = compilation.GetTypeByMetadataName("System.StringComparison");
+
+ if (stringComparisonType == null)
+ return;
+
+ // Get symbol info
+ var symbolInfo = semantic.GetSymbolInfo(memberAccess);
+ var methodSymbol = symbolInfo.Symbol as IMethodSymbol;
+ var candidateSymbols = symbolInfo.CandidateSymbols.OfType().ToImmutableArray();
+
+ // Determine if this is a span-like type
+ var receiverType = semantic.GetTypeInfo(memberAccess.Expression).Type;
+
+ // Check if calling on System.String - if so, skip (handled by LuceneDev6001)
+ if (receiverType != null && receiverType.SpecialType == SpecialType.System_String)
+ return;
+
+ // Check if receiver is span-like
+ bool isSpanLike = IsSpanLikeReceiver(receiverType);
+
+ // If not span-like based on receiver, check method symbol
+ if (!isSpanLike && methodSymbol != null)
+ {
+ isSpanLike = IsSpanLikeReceiver(methodSymbol.ContainingType);
+ }
+
+ // Check candidates if still not determined
+ if (!isSpanLike && candidateSymbols.Length > 0)
+ {
+ isSpanLike = candidateSymbols.Any(c => IsSpanLikeReceiver(c.ContainingType));
+ }
+
+ if (!isSpanLike)
+ return;
+
+ // Check if this is a char overload - ignore those
+ if (methodSymbol != null && IsCharOverload(methodSymbol))
+ return;
+
+ if (candidateSymbols.Length > 0 && candidateSymbols.All(c => IsCharOverload(c)))
+ return;
+
+ // Skip char literals and single-character string literals when safe ---
+ var firstArgExpr = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression;
+ if (firstArgExpr is LiteralExpressionSyntax lit)
+ {
+ if (lit.IsKind(SyntaxKind.CharacterLiteralExpression))
+ return; // already char overload; no diagnostic
+
+ if (lit.IsKind(SyntaxKind.StringLiteralExpression) && lit.Token.ValueText.Length == 1)
+ {
+ // Check if a StringComparison argument is present
+ bool hasStringComparisonArgForLiteral = invocation.ArgumentList.Arguments.Any(arg =>
+ semantic.GetTypeInfo(arg.Expression).Type is INamedTypeSymbol t &&
+ t.ToDisplayString() == "System.StringComparison"
+ || (semantic.GetSymbolInfo(arg.Expression).Symbol is IFieldSymbol f &&
+ f.ContainingType?.ToDisplayString() == "System.StringComparison"));
+
+ if (!hasStringComparisonArgForLiteral )
+ {
+ // safe to convert to char (6003), so skip 6001 reporting
+ return;
+ }
+ // else: has StringComparison -> do not skip; let 6001/6002 validate or codefix handle it
+ }
+ }
+
+
+ // Check for StringComparison argument
+ var (hasComparison, comparisonValue, argLocation) =
+ CheckStringComparisonArgument(invocation, semantic, stringComparisonType);
+
+ if (!hasComparison)
+ {
+ // No StringComparison argument - this is OK for span types (default is Ordinal)
+ return;
+ }
+
+ // Has StringComparison argument - validate it
+ if (comparisonValue == "Ordinal")
+ {
+ // Redundant - suggest removal (Warning)
+ var diag = Diagnostic.Create(
+ Descriptors.LuceneDev6002_RedundantOrdinal,
+ argLocation ?? memberAccess.Name.GetLocation(),
+ methodName);
+ ctx.ReportDiagnostic(diag);
+ }
+ else if (comparisonValue == "OrdinalIgnoreCase")
+ {
+ // Valid - no warning
+ return;
+ }
+ else
+ {
+ // Invalid comparison (CurrentCulture, InvariantCulture, etc.) - Error
+ var diag = Diagnostic.Create(
+ Descriptors.LuceneDev6002_InvalidComparison,
+ argLocation ?? memberAccess.Name.GetLocation(),
+ methodName,
+ comparisonValue ?? "non-ordinal comparison");
+ ctx.ReportDiagnostic(diag);
+ }
+ }
+
+ private static bool IsSpanLikeReceiver(ITypeSymbol? type)
+ {
+ if (type == null) return false;
+
+ // Check for Span or ReadOnlySpan
+ if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
+ {
+ var constructedFrom = namedType.ConstructedFrom.ToDisplayString();
+ if (constructedFrom == "System.Span" || constructedFrom == "System.ReadOnlySpan")
+ {
+ // Verify it's char
+ var typeArg = namedType.TypeArguments.FirstOrDefault();
+ if (typeArg != null && typeArg.SpecialType == SpecialType.System_Char)
+ return true;
+ }
+ }
+
+ // Check for custom span-like types
+ var fullname = type.ToDisplayString();
+ return fullname == "J2N.Text.OpenStringBuilder" ||
+ fullname == "Lucene.Net.Text.ValueStringBuilder";
+ }
+
+ private static bool IsCharOverload(IMethodSymbol? method)
+ {
+ if (method == null) return false;
+ // Check if the first parameter (value parameter) is char
+ return method.Parameters.Length > 0 &&
+ method.Parameters[0].Type.SpecialType == SpecialType.System_Char;
+ }
+
+ private static (bool hasArgument, string? value, Location? location) CheckStringComparisonArgument(
+ InvocationExpressionSyntax invocation,
+ SemanticModel semantic,
+ INamedTypeSymbol stringComparisonType)
+ {
+ foreach (var arg in invocation.ArgumentList.Arguments)
+ {
+ var argType = semantic.GetTypeInfo(arg.Expression).Type;
+
+ if (argType != null && SymbolEqualityComparer.Default.Equals(argType, stringComparisonType))
+ {
+ // Try to get the enum member name
+ var symbol = semantic.GetSymbolInfo(arg.Expression).Symbol as IFieldSymbol;
+ if (symbol != null && SymbolEqualityComparer.Default.Equals(symbol.ContainingType, stringComparisonType))
+ {
+ return (true, symbol.Name, arg.Expression.GetLocation());
+ }
+
+ // Check constant value
+ var constantValue = semantic.GetConstantValue(arg.Expression);
+ if (constantValue.HasValue && constantValue.Value is int intValue)
+ {
+ string? name = intValue switch
+ {
+ 4 => "Ordinal",
+ 5 => "OrdinalIgnoreCase",
+ 0 => "CurrentCulture",
+ 1 => "CurrentCultureIgnoreCase",
+ 2 => "InvariantCulture",
+ 3 => "InvariantCultureIgnoreCase",
+ _ => null
+ };
+ return (true, name, arg.Expression.GetLocation());
+ }
+
+ // Has StringComparison but can't determine value
+ return (true, null, arg.Expression.GetLocation());
+ }
+ }
+
+ return (false, null, null);
+ }
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6003_SingleCharStringAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6003_SingleCharStringAnalyzer.cs
new file mode 100644
index 0000000..33e0f28
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev6xxx/LuceneDev6003_SingleCharStringAnalyzer.cs
@@ -0,0 +1,261 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System.Collections.Immutable;
+using System.Linq;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx
+{
+ ///
+ /// Analyzer to detect single-character string literals (including escaped characters)
+ /// that should use char overload instead for better performance.
+ /// Applies to String, Span, and custom span-like types.
+ ///
+ /// Examples of violations:
+ /// - text.IndexOf("H") -> should use text.IndexOf('H')
+ /// - text.IndexOf("\n") -> should use text.IndexOf('\n') // Escaped newline
+ /// - text.IndexOf("\"") -> should use text.IndexOf('\"') // Escaped quote
+ /// - span.StartsWith("a") -> should use span.StartsWith('a')
+ ///
+ /// Severity: Info (suggestion only, not enforced)
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class LuceneDev6003_SingleCharStringAnalyzer : DiagnosticAnalyzer
+ {
+ private static readonly ImmutableHashSet TargetMethodNames =
+ ImmutableHashSet.Create("StartsWith", "EndsWith", "IndexOf", "LastIndexOf");
+
+ public override ImmutableArray SupportedDiagnostics
+ => ImmutableArray.Create(Descriptors.LuceneDev6003_SingleCharStringAnalyzer);
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);
+ context.EnableConcurrentExecution();
+
+ context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
+ }
+
+ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext ctx)
+ {
+ var invocation = (InvocationExpressionSyntax)ctx.Node;
+
+ if (!(invocation.Expression is MemberAccessExpressionSyntax memberAccess))
+ return;
+
+ var methodName = memberAccess.Name.Identifier.ValueText;
+ if (!TargetMethodNames.Contains(methodName))
+ return;
+
+ var semantic = ctx.SemanticModel;
+
+ // Check if invocation has arguments
+ if (invocation.ArgumentList.Arguments.Count == 0)
+ return;
+
+ // Get the first argument (the value to search for)
+ var firstArg = invocation.ArgumentList.Arguments[0];
+
+ // Must be a string literal
+ if (!(firstArg.Expression is LiteralExpressionSyntax literal))
+ return;
+
+ if (!literal.IsKind(SyntaxKind.StringLiteralExpression))
+ return;
+
+ // Get the actual character value (handles escape sequences automatically)
+ var token = literal.Token;
+ var valueText = token.ValueText; // This is the unescaped string value
+
+ // Check if it's exactly one character after unescaping
+ if (valueText.Length != 1)
+ return;
+
+ // Get the method symbol to verify it's called on a valid type
+ var symbolInfo = semantic.GetSymbolInfo(memberAccess);
+ var methodSymbol = symbolInfo.Symbol as IMethodSymbol;
+ var candidateSymbols = symbolInfo.CandidateSymbols.OfType().ToImmutableArray();
+
+ // Determine the receiver type
+ var receiverType = semantic.GetTypeInfo(memberAccess.Expression).Type;
+
+ // Check if this is a valid target type (String, Span, or custom span-like)
+ bool isSpanLike = IsSpanLikeReceiver(receiverType);
+ bool isValidTarget = IsValidTargetType(receiverType)
+ || (methodSymbol != null && IsValidTargetType(methodSymbol.ContainingType))
+ || candidateSymbols.Any(c => IsValidTargetType(c.ContainingType));
+
+ if (!isValidTarget)
+ return;
+
+ // 🌟 CRITICAL FIX: Handle Span/ReadOnlySpan differences
+ // For Span and ReadOnlySpan:
+ // 1. StartsWith/EndsWith only take ReadOnlySpan, NOT a single char, so we must skip the diagnostic.
+ // 2. IndexOf/LastIndexOf only have single-argument overloads for the 'char' (or 'value span') overload.
+ if (isSpanLike)
+ {
+ if (methodName == "StartsWith" || methodName == "EndsWith")
+ {
+ // Span/ReadOnlySpan do not have 'char' overloads for StartsWith/EndsWith.
+ // The string literal "a" is correctly resolved to the ReadOnlySpan overload.
+ return;
+ }
+
+ // For IndexOf/LastIndexOf on spans, if the invocation has more than 1 argument,
+ // it's likely a custom extension method or an invalid call, and it won't resolve
+ // to the simple `IndexOf(char value)` or `IndexOf(ReadOnlySpan value)` methods.
+ // We only target the simplest case for replacement.
+ if (invocation.ArgumentList.Arguments.Count != 1)
+ return;
+ }
+ else
+ {
+ // For System.String and custom types, we allow multiple arguments (e.g., IndexOf("a", 5))
+ // because the char overloads like IndexOf('a', 5) exist.
+ // We rely on the `HasCharOverload` check below to validate that the char overload exists.
+ }
+ // -----------------------------------------------------
+
+ // Check if a char overload exists
+ bool hasCharOverload = HasCharOverload(methodSymbol, candidateSymbols, receiverType, methodName);
+
+ if (!hasCharOverload)
+ return;
+
+ // Report diagnostic with Info severity
+ // token.Text shows the ORIGINAL text as written in code (with escaping)
+ // For example: "\"" shows as "\""
+ // "\n" shows as "\n"
+ var diag = Diagnostic.Create(
+ Descriptors.LuceneDev6003_SingleCharStringAnalyzer,
+ literal.GetLocation(),
+ methodName,
+ literal.Token.Text); // Show the original escaped text in the message
+
+ ctx.ReportDiagnostic(diag);
+ }
+
+ ///
+ /// Determines if the given type is a valid target for this analyzer.
+ /// Valid types: System.String, Span<char>, ReadOnlySpan<char>,
+ /// J2N.Text.OpenStringBuilder, Lucene.Net.Text.ValueStringBuilder
+ ///
+ private static bool IsValidTargetType(ITypeSymbol? type)
+ {
+ if (type == null) return false;
+
+ // System.String
+ if (type.SpecialType == SpecialType.System_String)
+ return true;
+
+ // Span or ReadOnlySpan
+ if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
+ {
+ var constructedFrom = namedType.ConstructedFrom.ToDisplayString();
+ if (constructedFrom == "System.Span" || constructedFrom == "System.ReadOnlySpan")
+ {
+ // Verify it's specifically Span or ReadOnlySpan
+ var typeArg = namedType.TypeArguments.FirstOrDefault();
+ if (typeArg != null && typeArg.SpecialType == SpecialType.System_Char)
+ return true;
+ }
+ }
+
+ // Custom span-like types from Lucene.NET and J2N
+ var fullname = type.ToDisplayString();
+ return fullname == "J2N.Text.OpenStringBuilder" ||
+ fullname == "Lucene.Net.Text.ValueStringBuilder";
+ }
+
+ ///
+ /// Determines if the receiver type is Span<char> or ReadOnlySpan<char>.
+ ///
+ private static bool IsSpanLikeReceiver(ITypeSymbol? type)
+ {
+ if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
+ {
+ var constructedFrom = namedType.ConstructedFrom.ToDisplayString();
+ if ((constructedFrom == "System.Span" || constructedFrom == "System.ReadOnlySpan") &&
+ namedType.TypeArguments.FirstOrDefault()?.SpecialType == SpecialType.System_Char)
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Checks if a char overload exists for the given method.
+ /// A char overload is a method with the same name where the first parameter is System.Char.
+ ///
+ private static bool HasCharOverload(
+ IMethodSymbol? methodSymbol,
+ ImmutableArray candidateSymbols,
+ ITypeSymbol? receiverType,
+ string methodName)
+ {
+ ImmutableArray methodsToCheck = ImmutableArray.Empty;
+
+ // Strategy 1: Get all methods with the same name from the resolved method's containing type
+ if (methodSymbol != null && methodSymbol.ContainingType != null)
+ {
+ methodsToCheck = methodSymbol.ContainingType
+ .GetMembers(methodName)
+ .OfType()
+ .ToImmutableArray();
+ }
+ // Strategy 2: Use candidate symbols if method couldn't be resolved
+ else if (candidateSymbols.Length > 0)
+ {
+ methodsToCheck = candidateSymbols;
+
+ // Also try to get more methods from the first candidate's containing type
+ var containingType = candidateSymbols.FirstOrDefault()?.ContainingType;
+ if (containingType != null)
+ {
+ var additionalMethods = containingType.GetMembers(methodName).OfType();
+ methodsToCheck = methodsToCheck.Concat(additionalMethods).ToImmutableArray();
+ }
+ }
+ // Strategy 3: Use receiver type if nothing else worked
+ else if (receiverType != null)
+ {
+ methodsToCheck = receiverType
+ .GetMembers(methodName)
+ .OfType()
+ .ToImmutableArray();
+ }
+
+ // Look for a char overload
+ // The char overload should have System.Char as the first parameter (the value parameter)
+ foreach (var method in methodsToCheck)
+ {
+ if (method.Parameters.Length > 0 &&
+ method.Parameters[0].Type.SpecialType == SpecialType.System_Char)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx b/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx
index 2786230..c50dba7 100644
--- a/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx
+++ b/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx
@@ -218,4 +218,60 @@ under the License.
'{0}' may fail due to floating point precision issues on .NET Framework and .NET Core prior to version 3.0. Floating point values should be formatted with J2N.Numerics.Single.ToString() or J2N.Numerics.Double.ToString() before being embedded into strings.
The format-able message the diagnostic displays.
+
+
+
+ Missing StringComparison argument
+
+
+ Calls to string comparison methods like StartsWith, EndsWith, IndexOf, and LastIndexOf must explicitly specify a StringComparison to enforce culture-invariant and consistent behavior.
+
+
+ Call to '{0}' must specify a StringComparison argument. Use StringComparison.Ordinal or StringComparison.OrdinalIgnoreCase.
+
+
+
+
+ Invalid StringComparison argument
+
+
+ Only StringComparison.Ordinal or StringComparison.OrdinalIgnoreCase are allowed to ensure predictable and high-performance string comparisons.
+
+
+ Call to '{0}' uses invalid StringComparison value '{1}'. Only Ordinal or OrdinalIgnoreCase are allowed.
+
+
+
+
+ Redundant StringComparison.Ordinal argument
+
+
+ Span-based overloads already perform ordinal comparison by default. Removing redundant arguments simplifies the code and improves clarity.
+
+
+ Call to '{0}' on span overload already uses ordinal comparison. Remove the redundant StringComparison.Ordinal argument.
+
+
+
+
+ Invalid StringComparison argument for span overload
+
+
+ Span-based methods only support StringComparison.Ordinal and StringComparison.OrdinalIgnoreCase. Other values are not valid and should be removed or corrected.
+
+
+ Call to '{0}' uses invalid StringComparison value '{1}'. Span overloads only support Ordinal or OrdinalIgnoreCase.
+
+
+
+
+ Single-character string argument should use char overload
+
+
+ Using char overloads instead of single-character string literals avoids unnecessary string allocations and improves performance.
+
+
+ Call to '{0}' uses a single-character string literal. Use the char overload instead (e.g., 'x' instead of "x").
+
+
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev6xxx.cs b/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev6xxx.cs
new file mode 100644
index 0000000..781a79a
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev6xxx.cs
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
+ * OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations
+ * under the License.
+ */
+
+using Microsoft.CodeAnalysis;
+using static Microsoft.CodeAnalysis.DiagnosticSeverity;
+using static Lucene.Net.CodeAnalysis.Dev.Utility.Category;
+
+namespace Lucene.Net.CodeAnalysis.Dev.Utility
+{
+ public static partial class Descriptors
+ {
+ // IMPORTANT: Do not make these into properties!
+ // The AnalyzerReleases release management analyzers do not recognize them
+ // and will report RS2002 warnings if it cannot read the DiagnosticDescriptor
+ // instance through a field.
+
+ // 6001: Missing StringComparison argument
+ public static readonly DiagnosticDescriptor LuceneDev6001_MissingStringComparison =
+ Diagnostic(
+ "LuceneDev6001_1",
+ Usage,
+ Error
+ );
+
+ // 6001: Invalid StringComparison value (not Ordinal or OrdinalIgnoreCase)
+ public static readonly DiagnosticDescriptor LuceneDev6001_InvalidStringComparison =
+ Diagnostic(
+ "LuceneDev6001_2",
+ Usage,
+ Error
+ );
+
+ // 6002: Redundant Ordinal (StringComparison.Ordinal on span-like)
+ public static readonly DiagnosticDescriptor LuceneDev6002_RedundantOrdinal =
+ Diagnostic(
+ "LuceneDev6002_1",
+ Usage,
+ Warning
+ );
+
+ // 6002: Invalid comparison on span (e.g., CurrentCulture, InvariantCulture)
+ public static readonly DiagnosticDescriptor LuceneDev6002_InvalidComparison =
+ Diagnostic(
+ "LuceneDev6002_2",
+ Usage,
+ Error
+ );
+ public static readonly DiagnosticDescriptor LuceneDev6003_SingleCharStringAnalyzer =
+ Diagnostic(
+ "LuceneDev6003",
+ Usage,
+ Info
+ );
+ }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.cs b/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.cs
index 629c56c..0e5e62e 100644
--- a/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.cs
+++ b/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.cs
@@ -6,9 +6,9 @@
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
- *
+ *
* http://www.apache.org/licenses/LICENSE-2.0
- *
+ *
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev6xxx/TestLuceneDev6001_StringComparisonCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev6xxx/TestLuceneDev6001_StringComparisonCodeFixProvider.cs
new file mode 100644
index 0000000..8c926ba
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev6xxx/TestLuceneDev6001_StringComparisonCodeFixProvider.cs
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev6xxx;
+using Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx;
+using Lucene.Net.CodeAnalysis.Dev.TestUtilities;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+
+namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests.LuceneDev6xxx
+{
+ [TestFixture]
+ public class TestLuceneDev6001_StringComparisonCodeFixProvider
+ {
+ [Test]
+ public async Task TestFix_IndexOf_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""Hello"");
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""Hello"", StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_StartsWith_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ bool starts = text.StartsWith(""Hello"");
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ bool starts = text.StartsWith(""Hello"", StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("StartsWith")
+ .WithLocation("/0/Test0.cs", line: 9, column: 28);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_EndsWith_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ bool ends = text.EndsWith(""World"");
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ bool ends = text.EndsWith(""World"", StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("EndsWith")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_LastIndexOf_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World Hello"";
+ int index = text.LastIndexOf(""Hello"");
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World Hello"";
+ int index = text.LastIndexOf(""Hello"", StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("LastIndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_IndexOf_WithStartIndex_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""World"", 5);
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""World"", 5, StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_IndexOf_WithStartIndexAndCount_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""World"", 0, 11);
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""World"", 0, 11, StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_IndexOf_InvalidStringComparison_CurrentCulture()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""Hello"", StringComparison.CurrentCulture);
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""Hello"", StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_InvalidStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_InvalidStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 43);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_StartsWith_InvalidStringComparison_InvariantCulture()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ bool starts = text.StartsWith(""Hello"", StringComparison.InvariantCulture);
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ bool starts = text.StartsWith(""Hello"", StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_InvalidStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_InvalidStringComparison.MessageFormat)
+ .WithArguments("StartsWith")
+ .WithLocation("/0/Test0.cs", line: 9, column: 48);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_EndsWith_InvalidStringComparison_CurrentCultureIgnoreCase()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ bool ends = text.EndsWith(""WORLD"", StringComparison.CurrentCultureIgnoreCase);
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ bool ends = text.EndsWith(""WORLD"", StringComparison.OrdinalIgnoreCase);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_InvalidStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_InvalidStringComparison.MessageFormat)
+ .WithArguments("EndsWith")
+ .WithLocation("/0/Test0.cs", line: 9, column: 44);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestNoError_WithOrdinal()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""Hello"", StringComparison.Ordinal);
+ bool starts = text.StartsWith(""Hello"", StringComparison.Ordinal);
+ bool ends = text.EndsWith(""World"", StringComparison.Ordinal);
+ int lastIndex = text.LastIndexOf(""World"", StringComparison.Ordinal);
+ }
+}";
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = testCode,
+ ExpectedDiagnostics = { } // No diagnostics expected
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestNoError_WithOrdinalIgnoreCase()
+ {
+ var testCode = @"
+using System;
+
+public class MyClass
+{
+ public void MyMethod()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""hello"", StringComparison.OrdinalIgnoreCase);
+ bool starts = text.StartsWith(""HELLO"", StringComparison.OrdinalIgnoreCase);
+ bool ends = text.EndsWith(""WORLD"", StringComparison.OrdinalIgnoreCase);
+ int lastIndex = text.LastIndexOf(""world"", StringComparison.OrdinalIgnoreCase);
+ }
+}";
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6001_StringComparisonAnalyzer(),
+ () => new LuceneDev6001_StringComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = testCode,
+ ExpectedDiagnostics = { } // No diagnostics expected
+ };
+
+ await test.RunAsync();
+ }
+
+ }
+}
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev6xxx/TestLuceneDev6002_SpanComparisonCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev6xxx/TestLuceneDev6002_SpanComparisonCodeFixProvider.cs
new file mode 100644
index 0000000..3729559
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev6xxx/TestLuceneDev6002_SpanComparisonCodeFixProvider.cs
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev6xxx;
+using Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx;
+using Lucene.Net.CodeAnalysis.Dev.TestUtilities;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+
+namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests.LuceneDev6xxx
+{
+ [TestFixture]
+ public class TestLuceneDev6002_SpanComparisonCodeFixProvider
+ {
+ [Test]
+ public async Task TestFix_RemoveRedundantOrdinal()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""test"", StringComparison.Ordinal);
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""test"");
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_RedundantOrdinal)
+ .WithSeverity(DiagnosticSeverity.Warning)
+ .WithSpan(9, 42, 9, 66)
+ .WithArguments("IndexOf");
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6002_SpanComparisonAnalyzer(),
+ () => new LuceneDev6002_SpanComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_InvalidToOptimalRemoval_CaseSensitive()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""test"", StringComparison.CurrentCulture);
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""test"");
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_InvalidComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithSpan(9, 42, 9, 73)
+ .WithArguments("IndexOf", "CurrentCulture");
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6002_SpanComparisonAnalyzer(),
+ () => new LuceneDev6002_SpanComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected },
+ // The new logic offers "Optimize to default Ordinal" as CodeActionIndex = 0
+ CodeActionIndex = 0,
+ // CRITICAL FIX: The smarter fix takes only 1 iteration now.
+ NumberOfFixAllIterations = 1
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_InvalidToOptimalOrdinalIgnoreCase_CaseInsensitive()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""test"", StringComparison.CurrentCultureIgnoreCase);
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""test"", StringComparison.OrdinalIgnoreCase);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_InvalidComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithSpan(9, 42, 9, 83)
+ .WithArguments("IndexOf", "CurrentCultureIgnoreCase");
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6002_SpanComparisonAnalyzer(),
+ () => new LuceneDev6002_SpanComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected },
+ // The new logic should offer "OrdinalIgnoreCase" as CodeActionIndex = 0 for case-insensitive inputs
+ CodeActionIndex = 0,
+ // The fixed code does not trigger RedundantOrdinal, so 1 iteration is sufficient.
+ NumberOfFixAllIterations = 1
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task TestFix_RemoveRedundantOrdinal_Simple()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hi World"".AsSpan();
+ int index = span.IndexOf(""x"", StringComparison.Ordinal);
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hi World"".AsSpan();
+ int index = span.IndexOf(""x"");
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_RedundantOrdinal)
+ .WithSeverity(DiagnosticSeverity.Warning)
+ .WithSpan(9, 39, 9, 63)
+ .WithArguments("IndexOf");
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6002_SpanComparisonAnalyzer(),
+ () => new LuceneDev6002_SpanComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoDiagnostic_For_CharOverload()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ Span span = stackalloc char[5];
+ int index = span.IndexOf('t');
+ }
+}";
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6002_SpanComparisonAnalyzer(),
+ () => new LuceneDev6002_SpanComparisonCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = testCode,
+ ExpectedDiagnostics = { } // no diagnostics expected
+ };
+
+ await test.RunAsync();
+ }
+ }
+}
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev6xxx/TestLuceneDev6003_SingleCharStringCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev6xxx/TestLuceneDev6003_SingleCharStringCodeFixProvider.cs
new file mode 100644
index 0000000..1ecaffd
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev6xxx/TestLuceneDev6003_SingleCharStringCodeFixProvider.cs
@@ -0,0 +1,249 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx;
+using Lucene.Net.CodeAnalysis.Dev.TestUtilities;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+
+namespace Lucene.Net.CodeAnalysis.Dev.Tests.LuceneDev6xxx
+{
+ [TestFixture]
+ public class TestLuceneDev6003_SingleCharStringCodeFixProvider
+ {
+ [Test]
+ public async Task Fix_SingleCharacter_StringLiteral()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int index = text.IndexOf(""H"");
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int index = text.IndexOf('H');
+ }
+}";
+
+ // "H" starts at column 39 and ends at column 42 (3 chars wide)
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6003_SingleCharStringAnalyzer)
+ .WithSeverity(DiagnosticSeverity.Info)
+ .WithArguments("IndexOf", "\"H\"")
+ .WithSpan(10, 39, 10, 42);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6003_SingleCharStringAnalyzer(),
+ () => new LuceneDev6003_SingleCharStringCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected },
+ CodeActionIndex = 0
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Fix_EscapedCharacter_StringLiteral()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int index = text.IndexOf(""\"""");
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int index = text.IndexOf('""');
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6003_SingleCharStringAnalyzer)
+ .WithSeverity(DiagnosticSeverity.Info)
+ .WithArguments("IndexOf", "\"\\\"\"")
+ .WithSpan(10, 39, 10, 43);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6003_SingleCharStringAnalyzer(),
+ () => new LuceneDev6003_SingleCharStringCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected },
+ CodeActionIndex = 0
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task FixAll_SingleCharacterStringLiterals()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int i1 = text.IndexOf(""H"");
+ int i2 = text.IndexOf(""\n"");
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int i1 = text.IndexOf('H');
+ int i2 = text.IndexOf('\n');
+ }
+}";
+
+ // First: "H" (line 10, columns 38–41 → 3 chars)
+ var expected1 = new DiagnosticResult(Descriptors.LuceneDev6003_SingleCharStringAnalyzer)
+ .WithSeverity(DiagnosticSeverity.Info)
+ .WithArguments("IndexOf", "\"H\"")
+ .WithSpan(10, 38, 10, 41);
+
+ // Second: "\n" (line 11, columns 38–42 → 4 chars)
+ var expected2 = new DiagnosticResult(Descriptors.LuceneDev6003_SingleCharStringAnalyzer)
+ .WithSeverity(DiagnosticSeverity.Info)
+ .WithArguments("IndexOf", "\"\\n\"")
+ .WithSpan(11, 38, 11, 42);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6003_SingleCharStringAnalyzer(),
+ () => new LuceneDev6003_SingleCharStringCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected1, expected2 },
+ CodeActionIndex = 0
+ };
+
+ await test.RunAsync();
+ }
+ [Test]
+ public async Task Fix_Span_IndexOf_SingleCharacter()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M(ReadOnlySpan span)
+ {
+ int index = span.IndexOf(""X"");
+ }
+}";
+
+ var fixedCode = @"
+using System;
+
+public class Sample
+{
+ public void M(ReadOnlySpan span)
+ {
+ int index = span.IndexOf('X');
+ }
+}";
+
+ // "X" starts at column 30 and ends at column 33 (3 chars wide)
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6003_SingleCharStringAnalyzer)
+ .WithSeverity(DiagnosticSeverity.Info)
+ .WithArguments("IndexOf", "\"X\"")
+ .WithSpan(9, 30, 9, 33);
+
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6003_SingleCharStringAnalyzer(),
+ () => new LuceneDev6003_SingleCharStringCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = fixedCode,
+ ExpectedDiagnostics = { expected },
+ CodeActionIndex = 0
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoFix_Span_StartsWith_SingleCharacter()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M(ReadOnlySpan span)
+ {
+ bool starts = span.StartsWith(""X"");
+ }
+}";
+
+ // This test expects NO diagnostic, ensuring the Analyzer correctly skips
+ // ReadOnlySpan.StartsWith/EndsWith calls when the argument is a single-character string literal.
+ var test = new InjectableCodeFixTest(
+ () => new LuceneDev6003_SingleCharStringAnalyzer(),
+ () => new LuceneDev6003_SingleCharStringCodeFixProvider())
+ {
+ TestCode = testCode,
+ FixedCode = testCode, // Fixed code is the same as test code
+ ExpectedDiagnostics = { },
+ CodeActionIndex = 0
+ };
+
+ await test.RunAsync();
+ }
+ }
+}
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_StringComparisonAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_StringComparisonAnalyzer.cs
new file mode 100644
index 0000000..8589191
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6001_StringComparisonAnalyzer.cs
@@ -0,0 +1,511 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx;
+using Lucene.Net.CodeAnalysis.Dev.TestUtilities;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+
+namespace Lucene.Net.CodeAnalysis.Dev.Tests.LuceneDev6xxx
+{
+ [TestFixture]
+ public class TestLuceneDev6001_StringComparisonAnalyzer
+ {
+ [Test]
+ public async Task Skips_SingleCharStringLiteral_Alone()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int index = text.IndexOf(""H""); // Single-character string
+ }
+}";
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ // Expect no diagnostics because 6001 should skip single-character string literal alone
+ ExpectedDiagnostics = { } // No diagnostics expected
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoDiagnostic_For_SingleCharString_MissingComparison()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int index = text.IndexOf(""H"", 0, 5); // Single-character string with startIndex/count
+ }
+}";
+
+ // Change the test to use InjectableAnalyzerTest (no CodeFix)
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { } // Asserting NO diagnostics are expected
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_IndexOf_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""Hello"");
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_StartsWith_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ bool starts = text.StartsWith(""Hello"");
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("StartsWith")
+ .WithLocation("/0/Test0.cs", line: 9, column: 28);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_EndsWith_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ bool ends = text.EndsWith(""World"");
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("EndsWith")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_LastIndexOf_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World Hello"";
+ int index = text.LastIndexOf(""Hello"");
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("LastIndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_IndexOf_WithStartIndex_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""World"", 5);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_IndexOf_WithStartIndexAndCount_MissingStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""World"", 0, 11);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 26);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_InvalidStringComparison_CurrentCulture()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""Hello"", StringComparison.CurrentCulture);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_InvalidStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_InvalidStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 43);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_InvalidStringComparison_CurrentCultureIgnoreCase()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ bool starts = text.StartsWith(""hello"", StringComparison.CurrentCultureIgnoreCase);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_InvalidStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_InvalidStringComparison.MessageFormat)
+ .WithArguments("StartsWith")
+ .WithLocation("/0/Test0.cs", line: 9, column: 48);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_InvalidStringComparison_InvariantCulture()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ bool ends = text.EndsWith(""World"", StringComparison.InvariantCulture);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_InvalidStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_InvalidStringComparison.MessageFormat)
+ .WithArguments("EndsWith")
+ .WithLocation("/0/Test0.cs", line: 9, column: 44);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_InvalidStringComparison_InvariantCultureIgnoreCase()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ int index = text.LastIndexOf(""World"", StringComparison.InvariantCultureIgnoreCase);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6001_InvalidStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_InvalidStringComparison.MessageFormat)
+ .WithArguments("LastIndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 47);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoError_WithOrdinal()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""Hello"", StringComparison.Ordinal);
+ bool starts = text.StartsWith(""Hello"", StringComparison.Ordinal);
+ bool ends = text.EndsWith(""World"", StringComparison.Ordinal);
+ int lastIndex = text.LastIndexOf(""World"", StringComparison.Ordinal);
+ }
+}";
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { } // No diagnostics expected
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoError_WithOrdinalIgnoreCase()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ int index = text.IndexOf(""hello"", StringComparison.OrdinalIgnoreCase);
+ bool starts = text.StartsWith(""HELLO"", StringComparison.OrdinalIgnoreCase);
+ bool ends = text.EndsWith(""WORLD"", StringComparison.OrdinalIgnoreCase);
+ int lastIndex = text.LastIndexOf(""world"", StringComparison.OrdinalIgnoreCase);
+ }
+}";
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { } // No diagnostics expected
+ };
+
+ await test.RunAsync();
+ }
+
+
+ [Test]
+ public async Task Detects_MultipleViolations_InSameMethod()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello World"";
+ int index1 = text.IndexOf(""Hello"");
+ int index2 = text.IndexOf(""World"", StringComparison.CurrentCulture);
+ bool starts = text.StartsWith(""Hello"");
+ }
+}";
+
+ var expected1 = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 9, column: 27);
+
+ var expected2 = new DiagnosticResult(Descriptors.LuceneDev6001_InvalidStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_InvalidStringComparison.MessageFormat)
+ .WithArguments("IndexOf")
+ .WithLocation("/0/Test0.cs", line: 10, column: 44);
+
+ var expected3 = new DiagnosticResult(Descriptors.LuceneDev6001_MissingStringComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithMessageFormat(Descriptors.LuceneDev6001_MissingStringComparison.MessageFormat)
+ .WithArguments("StartsWith")
+ .WithLocation("/0/Test0.cs", line: 11, column: 28);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected1, expected2, expected3 }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoWarning_OnNonStringTypes()
+ {
+ var testCode = @"
+using System;
+
+public class CustomType
+{
+ public int IndexOf(string value) => 0;
+ public bool StartsWith(string value) => false;
+}
+
+public class Sample
+{
+ public void M()
+ {
+ var custom = new CustomType();
+ int index = custom.IndexOf(""test"");
+ bool starts = custom.StartsWith(""test"");
+ }
+}";
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6001_StringComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { } // No diagnostics expected - not on System.String
+ };
+
+ await test.RunAsync();
+ }
+ }
+}
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6002_SpanComparisonAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6002_SpanComparisonAnalyzer.cs
new file mode 100644
index 0000000..c851f94
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6002_SpanComparisonAnalyzer.cs
@@ -0,0 +1,339 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx;
+using Lucene.Net.CodeAnalysis.Dev.TestUtilities;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+
+namespace Lucene.Net.CodeAnalysis.Dev.Tests.LuceneDev6xxx
+{
+ [TestFixture]
+ public class TestLuceneDev6002_SpanComparisonAnalyzer
+ {
+ [Test]
+ public async Task Detects_RedundantOrdinal_OnReadOnlySpan_IndexOf()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""test"", StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_RedundantOrdinal)
+ .WithSeverity(DiagnosticSeverity.Warning)
+ .WithArguments("IndexOf")
+ .WithSpan(9, 42, 9, 66);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_RedundantOrdinal_OnSpan_StartsWith()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = stackalloc char[5];
+ bool starts = span.StartsWith(""test"", StringComparison.Ordinal);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_RedundantOrdinal)
+ .WithSeverity(DiagnosticSeverity.Warning)
+ .WithArguments("StartsWith")
+ .WithSpan(9, 47, 9, 71);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+
+ [Test]
+ public async Task Detects_InvalidComparison_CurrentCulture()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""test"", StringComparison.CurrentCulture);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_InvalidComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithArguments("IndexOf", "CurrentCulture")
+ .WithSpan(9, 42, 9, 73);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_InvalidComparison_CurrentCultureIgnoreCase()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.LastIndexOf(""test"", StringComparison.CurrentCultureIgnoreCase);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_InvalidComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithArguments("LastIndexOf", "CurrentCultureIgnoreCase")
+ .WithSpan(9, 46, 9, 87);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_InvalidComparison_InvariantCulture()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ bool ends = span.EndsWith(""test"", StringComparison.InvariantCulture);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_InvalidComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithArguments("EndsWith", "InvariantCulture")
+ .WithSpan(9, 43, 9, 76);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_InvalidComparison_InvariantCultureIgnoreCase()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ bool ends = span.EndsWith(""test"", StringComparison.InvariantCultureIgnoreCase);
+ }
+}";
+
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6002_InvalidComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithArguments("EndsWith", "InvariantCultureIgnoreCase")
+ .WithSpan(9, 43, 9, 86);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoWarning_WithoutStringComparison()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""test"");
+ bool starts = span.StartsWith(""Hello"");
+ bool ends = span.EndsWith(""lo"");
+ int lastIndex = span.LastIndexOf(""ll"");
+ }
+}";
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoWarning_WithOrdinalIgnoreCase()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""TEST"", StringComparison.OrdinalIgnoreCase);
+ bool starts = span.StartsWith(""HELLO"", StringComparison.OrdinalIgnoreCase);
+ }
+}";
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoWarning_OnStringType()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ // String types are handled by LuceneDev6001, not 6002
+ int index = text.IndexOf(""test"", StringComparison.Ordinal);
+ }
+}";
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode
+ };
+
+ await test.RunAsync();
+ }
+ [Test]
+ public async Task NoWarning_CharOverloads()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index = span.IndexOf(""H"");
+ bool starts = span.StartsWith(""H"");
+ }
+}";
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { } // No diagnostics
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_MultipleViolations()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ ReadOnlySpan span = ""Hello"".AsSpan();
+ int index1 = span.IndexOf(""test"", StringComparison.Ordinal);
+ int index2 = span.LastIndexOf(""test"", StringComparison.CurrentCulture);
+ }
+}";
+
+ var expected1 = new DiagnosticResult(Descriptors.LuceneDev6002_RedundantOrdinal)
+ .WithSeverity(DiagnosticSeverity.Warning)
+ .WithArguments("IndexOf")
+ .WithSpan(9, 43, 9, 67);
+
+ var expected2 = new DiagnosticResult(Descriptors.LuceneDev6002_InvalidComparison)
+ .WithSeverity(DiagnosticSeverity.Error)
+ .WithArguments("LastIndexOf", "CurrentCulture")
+ .WithSpan(10, 47, 10, 78);
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6002_SpanComparisonAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected1, expected2 }
+ };
+
+ await test.RunAsync();
+ }
+ }
+}
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6003_SingleCharStringAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6003_SingleCharStringAnalyzer.cs
new file mode 100644
index 0000000..4866e89
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev6xxx/TestLuceneDev6003_SingleCharStringAnalyzer.cs
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using Lucene.Net.CodeAnalysis.Dev.LuceneDev6xxx;
+using Lucene.Net.CodeAnalysis.Dev.TestUtilities;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+
+namespace Lucene.Net.CodeAnalysis.Dev.Tests.LuceneDev6xxx
+{
+ [TestFixture]
+ public class TestLuceneDev6003_SingleCharStringAnalyzer
+ {
+ [Test]
+ public async Task Detects_SingleCharacter_StringLiteral()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int index = text.IndexOf(""H"");
+ }
+}";
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6003_SingleCharStringAnalyzer)
+ .WithSeverity(DiagnosticSeverity.Info)
+ .WithSpan(9, 34, 9, 37)
+ .WithArguments("IndexOf", "\"H\"");
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6003_SingleCharStringAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task Detects_EscapedCharacter_StringLiteral()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int index = text.IndexOf(""\""""); // Added missing semicolon
+ }
+}";
+ var expected = new DiagnosticResult(Descriptors.LuceneDev6003_SingleCharStringAnalyzer)
+ .WithSeverity(DiagnosticSeverity.Info)
+ .WithSpan(9, 34, 9, 38)
+ .WithArguments("IndexOf", "\"\\\"\"");
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6003_SingleCharStringAnalyzer())
+ {
+ TestCode = testCode,
+ ExpectedDiagnostics = { expected }
+ };
+
+ await test.RunAsync();
+ }
+
+ [Test]
+ public async Task NoDiagnostic_For_MultiCharacterString()
+ {
+ var testCode = @"
+using System;
+
+public class Sample
+{
+ public void M()
+ {
+ string text = ""Hello"";
+ int index = text.IndexOf(""He"");
+ }
+}";
+
+ var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev6003_SingleCharStringAnalyzer())
+ {
+ TestCode = testCode
+ };
+
+ await test.RunAsync();
+ }
+ }
+}