diff --git a/ChangeLog.md b/ChangeLog.md index 9fc22206a9..cd986e67d9 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add analyzer "Dispose resource asynchronously" ([RCS1261](https://josefpihrt.github.io/docs/roslynator/analyzers/RCS1261)) ([PR](https://github.com/dotnet/roslynator/pull/1285)). +- Add analyzer "Unnecessary raw string literal" ([RCS1262](https://josefpihrt.github.io/docs/roslynator/analyzers/RCS1262)) ([PR](https://github.com/dotnet/roslynator/pull/1293)). ### Changed diff --git a/src/Analyzers.CodeFixes/CSharp/CodeFixes/UnnecessaryRawStringLiteralCodeFixProvider.cs b/src/Analyzers.CodeFixes/CSharp/CodeFixes/UnnecessaryRawStringLiteralCodeFixProvider.cs new file mode 100644 index 0000000000..0c6964325e --- /dev/null +++ b/src/Analyzers.CodeFixes/CSharp/CodeFixes/UnnecessaryRawStringLiteralCodeFixProvider.cs @@ -0,0 +1,80 @@ +// Copyright (c) .NET Foundation and Contributors. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Roslynator.CodeFixes; + +namespace Roslynator.CSharp.CodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UnnecessaryRawStringLiteralCodeFixProvider))] +[Shared] +public sealed class UnnecessaryRawStringLiteralCodeFixProvider : BaseCodeFixProvider +{ + private const string Title = "Unnecessary raw string literal"; + + public override ImmutableArray FixableDiagnosticIds + { + get { return ImmutableArray.Create(DiagnosticIdentifiers.UnnecessaryRawStringLiteral); } + } + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + SyntaxNode root = await context.GetSyntaxRootAsync().ConfigureAwait(false); + + if (!TryFindFirstAncestorOrSelf(root, context.Span, out SyntaxNode node, predicate: f => f.IsKind(SyntaxKind.StringLiteralExpression, SyntaxKind.InterpolatedStringExpression))) + return; + + Diagnostic diagnostic = context.Diagnostics[0]; + Document document = context.Document; + + if (node is LiteralExpressionSyntax literalExpression) + { + CodeAction codeAction = CodeAction.Create( + Title, + ct => RefactorAsync(document, literalExpression, ct), + GetEquivalenceKey(diagnostic)); + + context.RegisterCodeFix(codeAction, diagnostic); + } + else if (node is InterpolatedStringExpressionSyntax interpolatedString) + { + CodeAction codeAction = CodeAction.Create( + Title, + ct => RefactorAsync(document, interpolatedString, ct), + GetEquivalenceKey(diagnostic)); + + context.RegisterCodeFix(codeAction, diagnostic); + } + } + + private static Task RefactorAsync( + Document document, + LiteralExpressionSyntax literalExpression, + CancellationToken cancellationToken) + { + RawStringLiteralInfo info = RawStringLiteralInfo.Create(literalExpression); + + string newText = info.Text.Substring(info.QuoteCount - 1, info.Text.Length - ((info.QuoteCount * 2) - 2)); + + return document.WithTextChangeAsync(literalExpression.Span, newText, cancellationToken); + } + + private static Task RefactorAsync( + Document document, + InterpolatedStringExpressionSyntax interpolatedString, + CancellationToken cancellationToken) + { + string newText = interpolatedString.ToString(); + int startIndex = interpolatedString.StringStartToken.Text.Length; + newText = "$\"" + newText.Substring(startIndex, newText.Length - startIndex - interpolatedString.StringEndToken.Text.Length) + "\""; + + return document.WithTextChangeAsync(interpolatedString.Span, newText, cancellationToken); + } +} diff --git a/src/Analyzers.xml b/src/Analyzers.xml index 667be75270..5573a9c8bb 100644 --- a/src/Analyzers.xml +++ b/src/Analyzers.xml @@ -7490,6 +7490,27 @@ void M() + + RCS1262 + UnnecessaryRawStringLiteral + Unnecessary raw string literal. + Info + true + true + 11.0 + + + + + + + + + https://learn.microsoft.com/dotnet/csharp/language-reference/tokens/raw-string + Raw string literal text - """ in string literals + + + RCS9001 UsePatternMatching diff --git a/src/Analyzers/CSharp/Analysis/UnnecessaryRawStringLiteralAnalyzer.cs b/src/Analyzers/CSharp/Analysis/UnnecessaryRawStringLiteralAnalyzer.cs new file mode 100644 index 0000000000..6bd89abb5c --- /dev/null +++ b/src/Analyzers/CSharp/Analysis/UnnecessaryRawStringLiteralAnalyzer.cs @@ -0,0 +1,100 @@ +// Copyright (c) .NET Foundation and Contributors. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Roslynator.CSharp.Analysis; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UnnecessaryRawStringLiteralAnalyzer : BaseDiagnosticAnalyzer +{ + private static ImmutableArray _supportedDiagnostics; + + public override ImmutableArray SupportedDiagnostics + { + get + { + if (_supportedDiagnostics.IsDefault) + Immutable.InterlockedInitialize(ref _supportedDiagnostics, DiagnosticRules.UnnecessaryRawStringLiteral); + + return _supportedDiagnostics; + } + } + + public override void Initialize(AnalysisContext context) + { + base.Initialize(context); + + context.RegisterCompilationStartAction(startContext => + { + if (((CSharpCompilation)startContext.Compilation).LanguageVersion >= LanguageVersion.CSharp11) + { + startContext.RegisterSyntaxNodeAction(f => AnalyzeStringLiteralExpression(f), SyntaxKind.StringLiteralExpression); + startContext.RegisterSyntaxNodeAction(f => AnalyzeInterpolatedStringExpression(f), SyntaxKind.InterpolatedStringExpression); + } + }); + } + + private static void AnalyzeStringLiteralExpression(SyntaxNodeAnalysisContext context) + { + var literalExpression = (LiteralExpressionSyntax)context.Node; + + if (!RawStringLiteralInfo.TryCreate(literalExpression, out RawStringLiteralInfo info)) + return; + + string text = info.Text; + + if (ContainsBackSlashQuote(text, info.QuoteCount, text.Length - (info.QuoteCount * 2))) + return; + + DiagnosticHelpers.ReportDiagnostic( + context, + DiagnosticRules.UnnecessaryRawStringLiteral, + Location.Create(literalExpression.SyntaxTree, new TextSpan(literalExpression.SpanStart + 1, info.QuoteCount - 1))); + } + + private static void AnalyzeInterpolatedStringExpression(SyntaxNodeAnalysisContext context) + { + var interpolatedString = (InterpolatedStringExpressionSyntax)context.Node; + + SyntaxToken startToken = interpolatedString.StringStartToken; + + if (!startToken.IsKind(SyntaxKind.InterpolatedSingleLineRawStringStartToken)) + return; + + foreach (InterpolatedStringContentSyntax content in interpolatedString.Contents) + { + if (content is InterpolatedStringTextSyntax interpolatedStringText) + { + string text = interpolatedStringText.TextToken.Text; + + if (ContainsBackSlashQuote(text, 0, text.Length)) + return; + } + } + + DiagnosticHelpers.ReportDiagnostic( + context, + DiagnosticRules.UnnecessaryRawStringLiteral, + Location.Create(interpolatedString.SyntaxTree, new TextSpan(startToken.SpanStart + 2, startToken.Span.Length - 2))); + } + + private static bool ContainsBackSlashQuote(string text, int start, int length) + { + for (int pos = start; pos < start + length; pos++) + { + switch (text[pos]) + { + case '\\': + case '"': + return true; + } + } + + return false; + } +} diff --git a/src/Analyzers/CSharp/DiagnosticIdentifiers.Generated.cs b/src/Analyzers/CSharp/DiagnosticIdentifiers.Generated.cs index 7665cff120..bb38916463 100644 --- a/src/Analyzers/CSharp/DiagnosticIdentifiers.Generated.cs +++ b/src/Analyzers/CSharp/DiagnosticIdentifiers.Generated.cs @@ -218,5 +218,6 @@ public static partial class DiagnosticIdentifiers public const string RemoveEmptySyntax = "RCS1259"; public const string AddOrRemoveTrailingComma = "RCS1260"; public const string DisposeResourceAsynchronously = "RCS1261"; + public const string UnnecessaryRawStringLiteral = "RCS1262"; } } \ No newline at end of file diff --git a/src/Analyzers/CSharp/DiagnosticRules.Generated.cs b/src/Analyzers/CSharp/DiagnosticRules.Generated.cs index 702de01938..4e05ef08c0 100644 --- a/src/Analyzers/CSharp/DiagnosticRules.Generated.cs +++ b/src/Analyzers/CSharp/DiagnosticRules.Generated.cs @@ -2581,5 +2581,17 @@ public static partial class DiagnosticRules helpLinkUri: DiagnosticIdentifiers.DisposeResourceAsynchronously, customTags: Array.Empty()); + /// RCS1262 + public static readonly DiagnosticDescriptor UnnecessaryRawStringLiteral = DiagnosticDescriptorFactory.Create( + id: DiagnosticIdentifiers.UnnecessaryRawStringLiteral, + title: "Unnecessary raw string literal.", + messageFormat: "Unnecessary raw string literal.", + category: DiagnosticCategories.Roslynator, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: null, + helpLinkUri: DiagnosticIdentifiers.UnnecessaryRawStringLiteral, + customTags: WellKnownDiagnosticTags.Unnecessary); + } } \ No newline at end of file diff --git a/src/Analyzers/CSharp/RawStringLiteralInfo.cs b/src/Analyzers/CSharp/RawStringLiteralInfo.cs new file mode 100644 index 0000000000..db4ebee87c --- /dev/null +++ b/src/Analyzers/CSharp/RawStringLiteralInfo.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation and Contributors. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Roslynator.CSharp; + +internal readonly struct RawStringLiteralInfo +{ + public RawStringLiteralInfo(LiteralExpressionSyntax expression, int quoteCount) + { + Expression = expression; + QuoteCount = quoteCount; + } + + public LiteralExpressionSyntax Expression { get; } + + public string Text => Expression.Token.Text; + + public int QuoteCount { get; } + + public bool IsDefault => Expression is null; + + public static bool TryCreate(LiteralExpressionSyntax literalExpression, out RawStringLiteralInfo info) + { + info = default; + + if (!literalExpression.IsKind(SyntaxKind.StringLiteralExpression)) + return false; + + SyntaxToken token = literalExpression.Token; + + if (!token.IsKind(SyntaxKind.SingleLineRawStringLiteralToken)) + return false; + + string text = token.Text; + int startCount = 0; + int endCount = 0; + + int i = 0; + while (i < text.Length + && text[i] == '"') + { + startCount++; + i++; + } + + i = text.Length - 1; + while (i >= startCount + && text[i] == '"') + { + endCount++; + i--; + } + + if (startCount < 3 + || startCount != endCount) + { + return false; + } + + info = new RawStringLiteralInfo(literalExpression, startCount); + return true; + } + + public static RawStringLiteralInfo Create(LiteralExpressionSyntax literalExpression) + { + if (TryCreate(literalExpression, out RawStringLiteralInfo info)) + return info; + + throw new InvalidOperationException(); + } +} diff --git a/src/Tests/Analyzers.Tests/RCS1262UnnecessaryRawStringLiteralTests.cs b/src/Tests/Analyzers.Tests/RCS1262UnnecessaryRawStringLiteralTests.cs new file mode 100644 index 0000000000..c4c7f82295 --- /dev/null +++ b/src/Tests/Analyzers.Tests/RCS1262UnnecessaryRawStringLiteralTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation and Contributors. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Roslynator.CSharp.CodeFixes; +using Roslynator.Testing.CSharp; +using Xunit; + +namespace Roslynator.CSharp.Analysis.Tests; + +public class RCS1262UnnecessaryRawStringLiteralTests : AbstractCSharpDiagnosticVerifier +{ + public override DiagnosticDescriptor Descriptor { get; } = DiagnosticRules.UnnecessaryRawStringLiteral; + + [Fact, Trait(Traits.Analyzer, DiagnosticIdentifiers.UnnecessaryRawStringLiteral)] + public async Task Test_SingleLineRawStringLiteral() + { + await VerifyDiagnosticAndFixAsync(@" +class C +{ + void M() + { + string s = ""[|""""|]foo""""""; + } +} +", @" +class C +{ + void M() + { + string s = ""foo""; + } +} +"); + } + + [Fact, Trait(Traits.Analyzer, DiagnosticIdentifiers.UnnecessaryRawStringLiteral)] + public async Task Test_InterpolatedSingleLineRawString() + { + await VerifyDiagnosticAndFixAsync(@" +class C +{ + void M() + { + string s = """"; + string s1 = $""[|""""|] {s} foo """"""; + } +} +", @" +class C +{ + void M() + { + string s = """"; + string s1 = $"" {s} foo ""; + } +} +"); + } + + [Fact, Trait(Traits.Analyzer, DiagnosticIdentifiers.UnnecessaryRawStringLiteral)] + public async Task TestNoDiagnostic_ContainsQuote() + { + await VerifyNoDiagnosticAsync(@" +class C +{ + void M() + { + string s = """""" "" """"""; + } +} +"); + } + + [Fact, Trait(Traits.Analyzer, DiagnosticIdentifiers.UnnecessaryRawStringLiteral)] + public async Task TestNoDiagnostic_ContainsEscape() + { + await VerifyNoDiagnosticAsync(@" +class C +{ + void M() + { + string s = """""" \t """"""; + } +} +"); + } + + [Fact, Trait(Traits.Analyzer, DiagnosticIdentifiers.UnnecessaryRawStringLiteral)] + public async Task TestNoDiagnostic_InterpolatedString_ContainsQuote() + { + await VerifyNoDiagnosticAsync(@" +class C +{ + void M() + { + string s = $"""""" {""""} "" """"""; + } +} +"); + } + + [Fact, Trait(Traits.Analyzer, DiagnosticIdentifiers.UnnecessaryRawStringLiteral)] + public async Task TestNoDiagnostic_InterpolatedString_ContainsEscape() + { + await VerifyNoDiagnosticAsync(@" +class C +{ + void M() + { + string s = $"""""" {""""} \t """"""; + } +} +"); + } +} diff --git a/src/VisualStudioCode/package/src/configurationFiles.generated.ts b/src/VisualStudioCode/package/src/configurationFiles.generated.ts index f73aaaa890..e3416234c8 100644 --- a/src/VisualStudioCode/package/src/configurationFiles.generated.ts +++ b/src/VisualStudioCode/package/src/configurationFiles.generated.ts @@ -906,6 +906,9 @@ roslynator_analyzers.enabled_by_default = true|false # Resource can be disposed asynchronously #dotnet_diagnostic.rcs1261.severity = suggestion +# Unnecessary raw string literal +#dotnet_diagnostic.rcs1262.severity = suggestion + # Use pattern matching #dotnet_diagnostic.rcs9001.severity = silent