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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,9 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>

<PropertyGroup Label="Shared NuGet Package Reference Versions">
<RoslynAnalyzerPackageVersion>4.14.0</RoslynAnalyzerPackageVersion>
</PropertyGroup>

<ItemGroup Label="NuGet Package Reference Versions">
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.8.118" />
<PackageVersion Include="NUnit" Version="4.4.0" />
Expand All @@ -46,5 +44,4 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageVersion Include="Microsoft.VSSDK.BuildTools" Version="17.14.2094" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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<string> FixableDiagnosticIds =>
ImmutableArray.Create(
Descriptors.LuceneDev6001_MissingStringComparison.Id,
Descriptors.LuceneDev6001_InvalidStringComparison.Id);

public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

/// <summary>
/// Registers available code fixes for all diagnostics in the context.
/// </summary>
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<InvocationExpressionSyntax>()
.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);
}

/// <summary>
/// Determines the appropriate ordinal replacement (Ordinal or OrdinalIgnoreCase)
/// for an existing culture-sensitive StringComparison argument.
/// Only operates on constant argument values.
/// </summary>
/// <returns>True if a valid replacement was determined, false otherwise (e.g., if argument is non-constant).</returns>
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;
}

/// <summary>
/// Creates the new document by either replacing an existing StringComparison argument
/// or adding a new one, based on the fix action.
/// </summary>
private static async Task<Document> 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);
}

/// <summary>
/// Ensures a 'using System;' directive is present in the document.
/// </summary>
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;
}
}
}
Loading
Loading