diff --git a/Atc.Analyzer.slnx b/Atc.Analyzer.slnx index 0072935..02ee0ed 100644 --- a/Atc.Analyzer.slnx +++ b/Atc.Analyzer.slnx @@ -3,13 +3,16 @@ + + + diff --git a/README.md b/README.md index 1600e4b..4e2b957 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Once installed, the analyzer will automatically run during compilation and highl | [ATC201](docs/rules/ATC201.md) | Style | Single parameter should be kept inline when declaration is short | ⚠️ Warning | ✔️ Yes | ✅ Yes | | [ATC202](docs/rules/ATC202.md) | Style | Multi parameters should be separated on individual lines | ⚠️ Warning | ✔️ Yes | ✅ Yes | | [ATC203](docs/rules/ATC203.md) | Style | Method chains with 2 or more calls should be placed on separate lines | ℹ️ Suggestion | ✔️ Yes | ✅ Yes | +| [ATC204](docs/rules/ATC204.md) | Style | Chained method calls in interpolated strings should be simplified | ℹ️ Suggestion | ✔️ Yes | ✅ Yes | | [ATC210](docs/rules/ATC210.md) | Style | Use expression body syntax when appropriate | ⚠️ Warning | ✔️ Yes | ✅ Yes | | [ATC220](docs/rules/ATC220.md) | Style | Use global usings for all namespaces (strict policy) | ⚠️ Warning | ✔️ Yes | ✅ Yes | | [ATC221](docs/rules/ATC221.md) | Style | Use global usings for common namespaces (lenient policy) | ⚠️ Warning | ✔️ Yes | ✅ Yes | diff --git a/docs/rules/ATC203.md b/docs/rules/ATC203.md index ffc97c7..ec82293 100644 --- a/docs/rules/ATC203.md +++ b/docs/rules/ATC203.md @@ -20,10 +20,11 @@ Enforces that method chains with 2 or more method calls should be broken down to 4. **Method calls with arguments**: A method call with arguments followed by a single chained call (e.g., `MethodA(value).ToString()`) is allowed on one line, as the method with arguments acts as the base expression rather than part of the chain 5. **Method-to-method chains**: A method call (instance or static) followed by a single chained method (e.g., `GetDataAsync().ConfigureAwait(false)` or `Task.Run(() => 42).ContinueWith(...)`) is allowed on one line 6. **If-statement conditions**: Method chains with exactly 2 chained methods in if-statement conditions are allowed on one line for improved readability (e.g., `if (rootElement.Attributes("Sdk").Count() == 1)`) -7. **Applies to**: All method invocations including LINQ, async/await, StringBuilder, Task operations, etc. -8. **Works with await**: When using `await` with method chains, the same rules apply -9. **FluentAssertions Exception**: Any pattern starting with `.Should()` followed by exactly one assertion method (e.g., `actual.Should().Be(...)`, `actual.Should().NotBeNull()`, `actual.Should().BeEquivalentTo(...)`) is allowed on one line for test readability - - Chains with 3+ methods (e.g., `.Should().Be(...).And.NotBeNull()`) must still be placed on separate lines +7. **Interpolated strings**: Method chains inside interpolated strings are excluded from this rule - see [ATC204](ATC204.md) for interpolation-specific analysis +8. **Applies to**: All method invocations including LINQ, async/await, StringBuilder, Task operations, etc. +9. **Works with await**: When using `await` with method chains, the same rules apply +10. **FluentAssertions Exception**: Any pattern starting with `.Should()` followed by exactly one assertion method (e.g., `actual.Should().Be(...)`, `actual.Should().NotBeNull()`, `actual.Should().BeEquivalentTo(...)`) is allowed on one line for test readability + - Chains with 3+ methods (e.g., `.Should().Be(...).And.NotBeNull()`) must still be placed on separate lines ## 💡 Motivation @@ -253,6 +254,16 @@ public void TestMethod() } ``` +### Interpolated strings (allowed - handled by ATC204) + +```csharp +// compliant for ATC203 - method chains in interpolated strings are excluded +// (See ATC204 for interpolation-specific suggestions) +var myVar = "test"; +var result = $"Hello {myVar.ToString().ToLower()} world"; +var message = $"{firstName.Trim()} {lastName.Trim().ToUpper()}"; +``` + ## ⚙️ Configuration Configure the minimum chain length threshold in `.editorconfig`: @@ -287,6 +298,7 @@ dotnet_diagnostic.ATC203.min_chain_length = 4 1. **Method-to-method chains**: Method chains starting with a method call (instance or static) followed by a single chained method are allowed on one line (e.g., `GetDataAsync().ConfigureAwait(false)`, `Task.Run(() => 42).ContinueWith(...)`) 2. **If-statement conditions**: Method chains with exactly 2 chained methods in if-statement conditions are allowed on one line for improved readability (e.g., `if (rootElement.Attributes("Sdk").Count() == 1)`) 3. **FluentAssertions pattern**: Any pattern starting with `.Should()` followed by exactly one assertion method (e.g., `actual.Should().Be(...)`, `actual.Should().NotBeNull()`, `actual.Should().BeEquivalentTo(...)`) is permitted on one line for test readability. This recognizes the idiomatic nature of simple FluentAssertions test assertions in xUnit and other test frameworks. +4. **Interpolated strings**: Method chains inside interpolated strings are excluded from this rule. See [ATC204](ATC204.md) for interpolation-specific analysis that suggests extracting complex expressions to variables. ## 🤖 Generated Code @@ -377,5 +389,6 @@ var result = await dataTask - [ATC201: Single parameter should be kept inline when declaration is short](ATC201.md) - Parameter formatting for single parameters - [ATC202: Multi parameters should be separated on individual lines](ATC202.md) - Parameter formatting for multiple parameters +- [ATC204: Chained method calls in interpolated strings should be simplified](ATC204.md) - Interpolation-specific method chain analysis - [ATC210: Use expression body syntax when appropriate](ATC210.md) - Expression body formatting - [ATC230: Require exactly one blank line between code blocks](ATC230.md) - Blank line formatting diff --git a/docs/rules/ATC204.md b/docs/rules/ATC204.md new file mode 100644 index 0000000..82d1789 --- /dev/null +++ b/docs/rules/ATC204.md @@ -0,0 +1,182 @@ +# ATC204: Chained method calls in interpolated strings should be simplified + +## Category + +Style + +## Severity + +Suggestion (Info) + +## Description + +Suggests simplifying chained method calls within string interpolation expressions by extracting them to a variable or creating an extension method. When you have 2 or more chained method calls inside an interpolation expression, the code can become difficult to read and maintain. + +## Rules + +1. **2+ chained method calls**: When an interpolation expression contains 2 or more chained method calls, a suggestion is raised +2. **Single method calls**: No suggestion - single method calls are perfectly acceptable +3. **Property access**: Property access after a method call (e.g., `.Trim().Length`) counts as only one method call +4. **Applies to**: All interpolated string types including regular (`$""`), verbatim (`$@""`), and raw (`$""" """`​) strings +5. **Nested interpolations**: Each interpolation is analyzed independently + +## Motivation + +Extracting chained method calls from interpolated strings: + +- Improves code readability by separating data transformation from string formatting +- Makes debugging easier - you can inspect intermediate values +- Allows for better error handling and null checking +- Reduces cognitive load when reading the code +- Makes the code more maintainable + +## Examples + +### Two chained method calls + +```csharp +// non-compliant +var result = $"Hello {myVar.ToString().ToLower()} world"; + +// compliant - extract to a variable +var formattedValue = myVar.ToString().ToLower(); +var result = $"Hello {formattedValue} world"; + +// compliant - create an extension method +var result = $"Hello {myVar.ToFormattedString()} world"; +``` + +### Three or more chained method calls + +```csharp +// non-compliant +var result = $"Value: {myVar.ToString().Trim().ToLower()}"; + +// compliant - extract to a variable +var formattedValue = myVar + .ToString() + .Trim() + .ToLower(); +var result = $"Value: {formattedValue}"; +``` + +### Multiple interpolations with chains + +```csharp +// non-compliant +var result = $"{firstName.Trim().ToUpper()} {lastName.Trim().ToUpper()}"; + +// compliant - extract both to variables +var first = firstName.Trim().ToUpper(); +var last = lastName.Trim().ToUpper(); +var result = $"{first} {last}"; +``` + +### Single method call (allowed) + +```csharp +// compliant - single method call is fine +var result = $"Hello {myVar.ToString()} world"; +var result2 = $"Length: {text.Trim().Length}"; // Property access doesn't count +``` + +### Property access (allowed) + +```csharp +// compliant - property access only +var result = $"Length is {myVar.Length}"; +``` + +### Variable access (allowed) + +```csharp +// compliant - just a variable +var result = $"Value is {myVar}"; +``` + +### LINQ in interpolation + +```csharp +// non-compliant +var result = $"First item: {items.First().ToUpper()}"; + +// compliant +var firstItem = items.First().ToUpper(); +var result = $"First item: {firstItem}"; +``` + +### Verbatim interpolated strings + +```csharp +// non-compliant +var path = $@"C:\Users\{name.Trim().ToLower()}\Documents"; + +// compliant +var userName = name.Trim().ToLower(); +var path = $@"C:\Users\{userName}\Documents"; +``` + +### Raw interpolated strings (C# 11+) + +```csharp +// non-compliant +var json = $""" + { + "name": "{data.GetName().ToLower()}" + } + """; + +// compliant +var formattedName = data.GetName().ToLower(); +var json = $""" + { + "name": "{formattedName}" + } + """; +``` + +## How to Fix + +There are two recommended approaches to fix this suggestion: + +### 1. Extract to a variable + +Extract the chained method calls to a local variable before the interpolated string: + +```csharp +// Before +var message = $"Hello {user.GetName().Trim().ToLower()}!"; + +// After +var formattedName = user.GetName().Trim().ToLower(); +var message = $"Hello {formattedName}!"; +``` + +### 2. Create an extension method + +If you frequently use the same chain of method calls, consider creating an extension method: + +```csharp +// Extension method +public static class StringExtensions +{ + public static string ToFormattedName(this string name) + => name.Trim().ToLower(); +} + +// Usage +var message = $"Hello {user.GetName().ToFormattedName()}!"; +``` + +## Generated Code + +This rule automatically skips analysis of generated code. Generated code is identified by: + +1. **GeneratedCode Attribute**: Classes or types marked with `[GeneratedCode]` attribute from `System.CodeDom.Compiler` +2. **Auto-generated Headers**: Files containing "auto-generated" in header comments (case-insensitive) + +## Related Rules + +- [ATC203: Method chains with 2 or more calls should be placed on separate lines](ATC203.md) - Method chain formatting (excludes interpolated strings) +- [ATC201: Single parameter should be kept inline when declaration is short](ATC201.md) - Parameter formatting +- [ATC202: Multi parameters should be separated on individual lines](ATC202.md) - Parameter formatting diff --git a/docs/rules/ATC210.md b/docs/rules/ATC210.md index a862217..724b1c5 100644 --- a/docs/rules/ATC210.md +++ b/docs/rules/ATC210.md @@ -20,6 +20,7 @@ Enforces the use of expression body syntax (=>) for methods and properties with 4. **Multi-line expressions**: Complex expressions (like ternary operators) that span multiple lines should have the `=>` arrow on a new line for better readability 5. **Applies to**: Methods, property accessors (get/set), and property declarations 6. **Does NOT apply to**: Void methods (methods with no return value), methods with multiple statements, constructors, or methods with complex logic +7. **Property accessor exception**: The line length check is **not applied** to property accessor expression bodies (`get =>` and `set =>`). Property accessors are constrained by the property structure, and forcing a newline for the arrow may reduce readability ## 💡 Motivation @@ -142,6 +143,26 @@ public string Name public string Name => name; ``` +### Long property accessor expression bodies + +Property accessor expression bodies (`get =>` and `set =>`) are **excluded** from the line length check. This allows compact property definitions even when the expression is long: + +```csharp +// compliant - long accessor expression bodies are allowed (no line length enforcement) +public bool ShowOverlayTitle +{ + get => Camera.Overrides?.ShowOverlayTitle ?? settingsService.CameraDisplay.ShowOverlayTitle; + set => Camera.Overrides!.ShowOverlayTitle = value; +} + +// compliant - even very long expressions in accessors don't trigger the line length warning +public string Value +{ + get => value; + set => this.value = value ?? throw new ArgumentNullException(nameof(value), "Value cannot be null"); +} +``` + ### Properties with multi-line format ```csharp diff --git a/sample/.editorconfig b/sample/.editorconfig index f4f44c3..d49ae4c 100644 --- a/sample/.editorconfig +++ b/sample/.editorconfig @@ -50,4 +50,13 @@ dotnet_diagnostic.S1075.severity = none # Refactor your code not to # Custom - Code Analyzers Rules ########################################## -dotnet_diagnostic.S2325.severity = none # Make a static method. \ No newline at end of file +dotnet_diagnostic.CA1303.severity = none # Do not pass literals as localized parameters. +dotnet_diagnostic.CA1304.severity = none # The behavior of 'string.ToUpper()' could vary based on the current user's locale settings. +dotnet_diagnostic.CA1307.severity = none # Do not pass literals as localized parameters. +dotnet_diagnostic.CA1311.severity = none # Specify a culture or use an invariant version to avoid implicit dependency on current culture. + +dotnet_diagnostic.CS0649.severity = none # Field is never assigned to, and will always have its default value + +dotnet_diagnostic.S2325.severity = none # Make a static method. +dotnet_diagnostic.S3459.severity = none # +dotnet_diagnostic.S6608.severity = none # \ No newline at end of file diff --git a/sample/Atc.Analyzer.Sample.Atc204/Atc.Analyzer.Sample.Atc204.csproj b/sample/Atc.Analyzer.Sample.Atc204/Atc.Analyzer.Sample.Atc204.csproj new file mode 100644 index 0000000..edfa940 --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc204/Atc.Analyzer.Sample.Atc204.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + false + false + + + + + false + Analyzer + + + + diff --git a/sample/Atc.Analyzer.Sample.Atc204/GlobalUsings.cs b/sample/Atc.Analyzer.Sample.Atc204/GlobalUsings.cs new file mode 100644 index 0000000..ad0ed40 --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc204/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; \ No newline at end of file diff --git a/sample/Atc.Analyzer.Sample.Atc204/InterpolationMethodChainExamples.cs b/sample/Atc.Analyzer.Sample.Atc204/InterpolationMethodChainExamples.cs new file mode 100644 index 0000000..41e615a --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc204/InterpolationMethodChainExamples.cs @@ -0,0 +1,48 @@ +namespace Atc.Analyzer.Sample.Atc204; + +/// +/// Examples that should trigger ATC204 - Chained method calls in interpolated strings. +/// +public static class InterpolationMethodChainExamples +{ + // Should trigger ATC204: Two chained method calls in interpolation + public static void TwoChainedMethodCalls() + { + const string name = "John Doe"; + var message = $"Hello {name.Trim().ToUpper()}!"; + Console.WriteLine(message); + } + + // Should trigger ATC204: Three chained method calls in interpolation + public static void ThreeChainedMethodCalls() + { + const string input = " HELLO WORLD "; + var result = $"Processed: {input.Trim().ToLower().Replace(" ", "-")}"; + Console.WriteLine(result); + } + + // Should trigger ATC204: Chained calls with LINQ + public static void LinqChainedCalls() + { + var items = new List { "apple", "banana", "cherry" }; + var output = $"First item: {items.First().ToUpper()}"; + Console.WriteLine(output); + } + + // Should trigger ATC204: Multiple interpolations with chains + public static void MultipleInterpolationsWithChains() + { + const string firstName = "john"; + const string lastName = "doe"; + var fullName = $"{firstName.Trim().ToUpper()} {lastName.Trim().ToUpper()}"; + Console.WriteLine(fullName); + } + + // Should trigger ATC204: Verbatim interpolated string + public static void VerbatimInterpolatedString() + { + const string path = @"C:\Users\Test"; + var message = $@"Path is: {path.Trim().ToLower()}"; + Console.WriteLine(message); + } +} \ No newline at end of file diff --git a/sample/Atc.Analyzer.Sample.Atc204/Program.cs b/sample/Atc.Analyzer.Sample.Atc204/Program.cs new file mode 100644 index 0000000..7bb03d7 --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc204/Program.cs @@ -0,0 +1,9 @@ +namespace Atc.Analyzer.Sample.Atc204; + +public static class Program +{ + public static void Main() + { + Console.WriteLine("ATC204 Sample - Interpolation Method Chain Analyzer"); + } +} \ No newline at end of file diff --git a/sample/Atc.Analyzer.Sample.Atc204/ValidInterpolationExamples.cs b/sample/Atc.Analyzer.Sample.Atc204/ValidInterpolationExamples.cs new file mode 100644 index 0000000..122bc0f --- /dev/null +++ b/sample/Atc.Analyzer.Sample.Atc204/ValidInterpolationExamples.cs @@ -0,0 +1,48 @@ +namespace Atc.Analyzer.Sample.Atc204; + +/// +/// Examples that should NOT trigger ATC204 - Valid interpolation patterns. +/// +public static class ValidInterpolationExamples +{ + // Valid: Single method call - no chain + public static void SingleMethodCall() + { + const string name = "John Doe"; + var message = $"Hello {name.ToUpper()}!"; + Console.WriteLine(message); + } + + // Valid: Simple variable in interpolation + public static void SimpleVariable() + { + const string name = "John"; + var message = $"Hello {name}!"; + Console.WriteLine(message); + } + + // Valid: Property access without method calls + public static void PropertyAccess() + { + const string text = "Hello"; + var message = $"Length is: {text.Length}"; + Console.WriteLine(message); + } + + // Valid: Already extracted to variable + public static void ExtractedToVariable() + { + const string name = " john doe "; + var formattedName = name.Trim().ToUpper(); + var message = $"Hello {formattedName}!"; + Console.WriteLine(message); + } + + // Valid: Single LINQ call + public static void SingleLinqCall() + { + var items = new List { "apple", "banana" }; + var message = $"First: {items.First()}"; + Console.WriteLine(message); + } +} \ No newline at end of file diff --git a/sample/Atc.Analyzer.Sample.Atc210/ValidExpressionBodyExamples.cs b/sample/Atc.Analyzer.Sample.Atc210/ValidExpressionBodyExamples.cs index 87fa459..ca2c7ff 100644 --- a/sample/Atc.Analyzer.Sample.Atc210/ValidExpressionBodyExamples.cs +++ b/sample/Atc.Analyzer.Sample.Atc210/ValidExpressionBodyExamples.cs @@ -89,7 +89,7 @@ public string Description // Example 6: Method with multiple statements (block body is correct) public static string GetProcessedData() { - var data = "Sample"; + const string data = "Sample"; return data.ToUpper(); } @@ -124,6 +124,17 @@ public string PropertyWithAccessors set => backingField = value; } + // Example 16: Long accessor expression bodies are valid (no line length enforcement for accessors) + private string? longValue1; + private string? longValue2; + private string? longValue3; + + public string LongAccessorExpressionBody + { + get => longValue1 ?? longValue2 ?? longValue3 ?? "This is a very long default value for the property getter"; + set => longValue1 = value ?? throw new ArgumentNullException(nameof(value), "LongAccessorExpressionBody cannot be null"); + } + // Example 12: Method with method call public static string GetUpperCase(string input) => input.ToUpper(); diff --git a/src/Atc.Analyzer/AnalyzerReleases.Unshipped.md b/src/Atc.Analyzer/AnalyzerReleases.Unshipped.md index 2589023..f60778b 100644 --- a/src/Atc.Analyzer/AnalyzerReleases.Unshipped.md +++ b/src/Atc.Analyzer/AnalyzerReleases.Unshipped.md @@ -5,6 +5,7 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +ATC204 | Style | Info | Chained method calls in interpolated strings should be simplified ### Changed Rules diff --git a/src/Atc.Analyzer/RuleIdentifierConstants.cs b/src/Atc.Analyzer/RuleIdentifierConstants.cs index cc0c080..c143969 100644 --- a/src/Atc.Analyzer/RuleIdentifierConstants.cs +++ b/src/Atc.Analyzer/RuleIdentifierConstants.cs @@ -25,6 +25,11 @@ internal static class Style /// internal const string MethodChainSeparation = "ATC203"; + /// + /// Chained method calls in interpolated strings should be simplified. + /// + internal const string InterpolationMethodChain = "ATC204"; + /// /// Use expression body syntax when appropriate. /// diff --git a/src/Atc.Analyzer/Rules/Style/ExpressionBodyAnalyzer.cs b/src/Atc.Analyzer/Rules/Style/ExpressionBodyAnalyzer.cs index 84be8e1..e9948cc 100644 --- a/src/Atc.Analyzer/Rules/Style/ExpressionBodyAnalyzer.cs +++ b/src/Atc.Analyzer/Rules/Style/ExpressionBodyAnalyzer.cs @@ -213,10 +213,6 @@ private static void AnalyzeAccessorExpressionBodyFormatting( return; } - // Get the configured max line length from .editorconfig - var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree); - var maxLineLength = options.GetMaxLineLength(RuleIdentifierConstants.Style.ExpressionBody); - var sourceText = accessorDeclaration.SyntaxTree.GetText(); var arrowToken = accessorDeclaration.ExpressionBody.ArrowToken; var expression = accessorDeclaration.ExpressionBody.Expression; @@ -233,31 +229,20 @@ private static void AnalyzeAccessorExpressionBodyFormatting( var hasComplexExpression = expression is ConditionalExpressionSyntax; - if (arrowLineNumber == keywordLineNumber) + // Note: Line length check is intentionally skipped for property accessors. + // Property accessors are constrained by the property structure, and forcing + // a newline for the arrow in accessors may reduce readability. + // Only check for multi-line complex expressions. + if (arrowLineNumber == keywordLineNumber && + expressionLineNumber > arrowLineNumber && + hasComplexExpression) { - var declarationLineText = arrowLine.ToString(); - var declarationLineLength = declarationLineText.TrimEnd().Length; - - if (declarationLineLength > maxLineLength) - { - var diagnostic = Diagnostic.Create( - Rule, - arrowToken.GetLocation(), - $"Expression body exceeds {maxLineLength} characters, move '=>' to a new line"); - - context.ReportDiagnostic(diagnostic); - return; - } - - if (expressionLineNumber > arrowLineNumber && hasComplexExpression) - { - var diagnostic = Diagnostic.Create( - Rule, - arrowToken.GetLocation(), - "For multi-line expressions, move '=>' to a new line"); - - context.ReportDiagnostic(diagnostic); - } + var diagnostic = Diagnostic.Create( + Rule, + arrowToken.GetLocation(), + "For multi-line expressions, move '=>' to a new line"); + + context.ReportDiagnostic(diagnostic); } } } \ No newline at end of file diff --git a/src/Atc.Analyzer/Rules/Style/InterpolationMethodChainAnalyzer.cs b/src/Atc.Analyzer/Rules/Style/InterpolationMethodChainAnalyzer.cs new file mode 100644 index 0000000..13db49e --- /dev/null +++ b/src/Atc.Analyzer/Rules/Style/InterpolationMethodChainAnalyzer.cs @@ -0,0 +1,138 @@ +namespace Atc.Analyzer.Rules.Style; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class InterpolationMethodChainAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifierConstants.Style.InterpolationMethodChain, + title: "Chained method calls in interpolated strings should be simplified", + messageFormat: "Consider extracting {0} chained method calls to a variable or extension method for improved readability", + RuleCategoryConstants.Style, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Suggests simplifying chained method calls within string interpolation expressions by extracting them to a variable or creating an extension method.", + helpLinkUri: RuleIdentifierHelper.GetHelpUri(RuleIdentifierConstants.Style.InterpolationMethodChain)); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeInterpolation, SyntaxKind.Interpolation); + } + + private static void AnalyzeInterpolation(SyntaxNodeAnalysisContext context) + { + if (context.Node.SyntaxTree.IsAutoGeneratedFile()) + { + return; + } + + var interpolation = (InterpolationSyntax)context.Node; + + // Get all invocation expressions directly within this interpolation + // (excluding those in nested interpolated strings) + var invocations = interpolation.DescendantNodes() + .OfType() + .Where(inv => !IsInNestedInterpolation(inv, interpolation)) + .ToList(); + + if (invocations.Count == 0) + { + return; + } + + // Find the outermost (root) invocation in each chain + var chainRoots = invocations + .Where(inv => !invocations.Any(other => other != inv && other.DescendantNodes().Contains(inv))) + .ToList(); + + foreach (var chainRoot in chainRoots) + { + var chainedMethodCount = CountChainedMethods(chainRoot); + + if (chainedMethodCount >= 2) + { + var diagnostic = Diagnostic.Create( + Rule, + chainRoot.GetLocation(), + chainedMethodCount); + context.ReportDiagnostic(diagnostic); + } + } + } + + private static bool IsInNestedInterpolation(InvocationExpressionSyntax invocation, InterpolationSyntax parentInterpolation) + { + // Check if the invocation is inside a nested InterpolationSyntax + // (different from the parent we're currently analyzing) + var current = invocation.Parent; + while (current is not null && current != parentInterpolation) + { + if (current is InterpolationSyntax) + { + return true; + } + + current = current.Parent; + } + + return false; + } + + private static int CountChainedMethods(InvocationExpressionSyntax invocation) + { + var count = 0; + var current = invocation.Expression; + + while (current is not null) + { + if (current is MemberAccessExpressionSyntax memberAccess) + { + // Check if this member access leads to an invocation + if (invocation.Expression == current || + IsPartOfInvocationChain(memberAccess, invocation)) + { + count++; + } + + current = memberAccess.Expression; + } + else if (current is InvocationExpressionSyntax nestedInvocation) + { + // Continue traversing through the nested invocation + current = nestedInvocation.Expression; + } + else + { + break; + } + } + + return count; + } + + private static bool IsPartOfInvocationChain(MemberAccessExpressionSyntax memberAccess, InvocationExpressionSyntax rootInvocation) + { + // Check if this member access is part of an invocation expression in the chain + var parent = memberAccess.Parent; + while (parent is not null && parent != rootInvocation.Parent) + { + if (parent is InvocationExpressionSyntax) + { + return true; + } + + parent = parent.Parent; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Atc.Analyzer/Rules/Style/InterpolationMethodChainCodeFixProvider.cs b/src/Atc.Analyzer/Rules/Style/InterpolationMethodChainCodeFixProvider.cs new file mode 100644 index 0000000..0110011 --- /dev/null +++ b/src/Atc.Analyzer/Rules/Style/InterpolationMethodChainCodeFixProvider.cs @@ -0,0 +1,245 @@ +namespace Atc.Analyzer.Rules.Style; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(InterpolationMethodChainCodeFixProvider))] +[Shared] +public sealed class InterpolationMethodChainCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds + => [RuleIdentifierConstants.Style.InterpolationMethodChain]; + + public override FixAllProvider GetFixAllProvider() + => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + // Find the invocation expression at the diagnostic location + var node = root.FindNode(diagnosticSpan); + if (node is not InvocationExpressionSyntax invocation) + { + // Try to find the invocation in descendants + invocation = node.DescendantNodesAndSelf() + .OfType() + .FirstOrDefault(); + + if (invocation is null) + { + return; + } + } + + context.RegisterCodeFix( + CodeAction.Create( + title: "Extract to local variable", + createChangedDocument: c => ExtractToVariableAsync(context.Document, invocation, c), + equivalenceKey: nameof(InterpolationMethodChainCodeFixProvider)), + context.Diagnostics); + } + + private static async Task ExtractToVariableAsync( + Document document, + InvocationExpressionSyntax invocation, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + // Find the containing statement where we'll insert the variable declaration + var containingStatement = invocation.Ancestors() + .OfType() + .FirstOrDefault(); + + if (containingStatement is null) + { + return document; + } + + // Get semantic model to generate unique variable name + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + + // Generate a unique variable name + var variableName = GenerateUniqueVariableName(invocation, containingStatement, semanticModel); + + // Detect line ending from document + var endOfLine = DetectEndOfLine(root); + + // Get the indentation of the containing statement + var indentation = GetIndentation(containingStatement); + + // Create the variable declaration: var variableName = ; + var variableDeclaration = SyntaxFactory.LocalDeclarationStatement( + SyntaxFactory.VariableDeclaration( + SyntaxFactory.IdentifierName("var")) + .WithVariables( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.VariableDeclarator( + SyntaxFactory.Identifier(variableName)) + .WithInitializer( + SyntaxFactory.EqualsValueClause(invocation.WithoutTrivia()))))) + .WithLeadingTrivia(SyntaxFactory.Whitespace(indentation)) + .WithTrailingTrivia(endOfLine); + + // Create the identifier to replace the invocation + var identifierReplacement = SyntaxFactory.IdentifierName(variableName) + .WithTriviaFrom(invocation); + + // Build new root with both changes + var editor = new SyntaxEditor(root, document.Project.Solution.Workspace.Services); + + // Insert the variable declaration before the containing statement + editor.InsertBefore(containingStatement, variableDeclaration); + + // Replace the invocation with the variable identifier + editor.ReplaceNode(invocation, identifierReplacement); + + var newRoot = editor.GetChangedRoot(); + return document.WithSyntaxRoot(newRoot); + } + + private static string GenerateUniqueVariableName( + InvocationExpressionSyntax invocation, + StatementSyntax containingStatement, + SemanticModel? semanticModel) + { + // Try to derive a meaningful name from the expression + var baseName = DeriveVariableName(invocation); + + if (semanticModel is null) + { + return baseName; + } + + // Get all existing identifiers in scope to avoid conflicts + var identifierNames = containingStatement.Parent? + .DescendantNodes() + .OfType() + .Select(i => i.Identifier.Text) ?? []; + + var existingNames = new HashSet(identifierNames, StringComparer.Ordinal); + + // Also check local declarations + var localDeclarations = containingStatement.Parent? + .DescendantNodes() + .OfType() + .Select(v => v.Identifier.Text) ?? []; + + foreach (var name in localDeclarations) + { + existingNames.Add(name); + } + + // Make sure the name is unique + var uniqueName = baseName; + var counter = 1; + while (existingNames.Contains(uniqueName)) + { + uniqueName = $"{baseName}{counter}"; + counter++; + } + + return uniqueName; + } + + private static string DeriveVariableName(InvocationExpressionSyntax invocation) + { + // Try to get a meaningful name from the last method in the chain + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + var methodName = memberAccess.Name.Identifier.Text; + + // Common transformations for readable names + return methodName switch + { + "ToString" => "stringValue", + "ToLower" or "ToLowerInvariant" => "lowerCaseValue", + "ToUpper" or "ToUpperInvariant" => "upperCaseValue", + "Trim" or "TrimStart" or "TrimEnd" => "trimmedValue", + "Replace" => "replacedValue", + "Substring" => "substringValue", + "Format" => "formattedValue", + "First" or "FirstOrDefault" => "firstItem", + "Last" or "LastOrDefault" => "lastItem", + "Single" or "SingleOrDefault" => "singleItem", + "Count" => "itemCount", + "Sum" => "totalSum", + "Average" => "averageValue", + "Max" => "maxValue", + "Min" => "minValue", + _ => "formattedValue", + }; + } + + return "formattedValue"; + } + + private static string GetIndentation(SyntaxNode node) + { + var leadingTrivia = node.GetLeadingTrivia(); + var lastWhitespace = leadingTrivia + .Reverse() + .FirstOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia)); + + if (lastWhitespace != default) + { + return lastWhitespace.ToString(); + } + + // If no whitespace found in leading trivia, calculate from the tree + var sourceText = node.SyntaxTree.GetText(); + var lineSpan = node.GetLocation().GetLineSpan(); + var startLine = lineSpan.StartLinePosition.Line; + var line = sourceText.Lines[startLine]; + var lineText = line.ToString(); + + // Count leading whitespace + var whitespaceCount = 0; + foreach (var ch in lineText) + { + if (ch == ' ') + { + whitespaceCount++; + } + else if (ch == '\t') + { + whitespaceCount += 4; // Treat tabs as 4 spaces + } + else + { + break; + } + } + + return new string(' ', whitespaceCount); + } + + private static SyntaxTrivia DetectEndOfLine(SyntaxNode root) + { + var sourceText = root.SyntaxTree.GetText(); + foreach (var line in sourceText.Lines) + { + if (line.EndIncludingLineBreak > line.End) + { + var lineBreakText = sourceText.ToString( + new Microsoft.CodeAnalysis.Text.TextSpan( + line.End, + line.EndIncludingLineBreak - line.End)); + return lineBreakText == "\r\n" + ? SyntaxFactory.CarriageReturnLineFeed + : SyntaxFactory.LineFeed; + } + } + + return SyntaxFactory.LineFeed; + } +} \ No newline at end of file diff --git a/src/Atc.Analyzer/Rules/Style/MethodChainSeparationAnalyzer.cs b/src/Atc.Analyzer/Rules/Style/MethodChainSeparationAnalyzer.cs index 00fef3d..61d0ba7 100644 --- a/src/Atc.Analyzer/Rules/Style/MethodChainSeparationAnalyzer.cs +++ b/src/Atc.Analyzer/Rules/Style/MethodChainSeparationAnalyzer.cs @@ -82,6 +82,12 @@ private static bool ShouldAnalyzeMethodChain( { invocationNodes = []; + // Skip method chains inside interpolated strings - these are handled by ATC204 + if (IsInInterpolatedString(invocationExpression)) + { + return false; + } + // Find the root of the method chain var chainRoot = invocationExpression.GetMethodChainRoot(); @@ -216,4 +222,20 @@ private static bool HasInconsistentMultiLineFormatting( // subsequent methods on different lines, this is inconsistent formatting return baseExpressionLine == firstMethodLine; } + + private static bool IsInInterpolatedString(InvocationExpressionSyntax invocationExpression) + { + var current = invocationExpression.Parent; + while (current is not null) + { + if (current is InterpolatedStringExpressionSyntax) + { + return true; + } + + current = current.Parent; + } + + return false; + } } \ No newline at end of file diff --git a/test/Atc.Analyzer.Tests/Rules/Style/ExpressionBodyAnalyzerNoDiagnosticTests.cs b/test/Atc.Analyzer.Tests/Rules/Style/ExpressionBodyAnalyzerNoDiagnosticTests.cs index bef93d8..110ec72 100644 --- a/test/Atc.Analyzer.Tests/Rules/Style/ExpressionBodyAnalyzerNoDiagnosticTests.cs +++ b/test/Atc.Analyzer.Tests/Rules/Style/ExpressionBodyAnalyzerNoDiagnosticTests.cs @@ -361,4 +361,103 @@ public class Sample await test.RunAsync(); } + + [Fact] + public async Task NoDiagnostic_GetAccessorWithLongExpressionBody() + { + // Property accessor expression bodies are excluded from line length checks + const string code = """ + public class Sample + { + private readonly ISettingsService settingsService = null!; + private readonly Camera Camera = null!; + + public bool ShowOverlayTitle + { + get => Camera.Overrides?.ShowOverlayTitle ?? settingsService.CameraDisplay.ShowOverlayTitle; + set { } + } + } + + public class Camera + { + public CameraOverrides? Overrides { get; set; } + } + + public class CameraOverrides + { + public bool? ShowOverlayTitle { get; set; } + } + + public interface ISettingsService + { + ICameraDisplay CameraDisplay { get; } + } + + public interface ICameraDisplay + { + bool ShowOverlayTitle { get; } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_SetAccessorWithLongExpressionBody() + { + // Property accessor expression bodies are excluded from line length checks + const string code = """ + using System; + + public class Sample + { + private string value = string.Empty; + + public string Value + { + get => value; + set => this.value = value ?? throw new ArgumentNullException(nameof(value), "Value cannot be null"); + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_AccessorWithVeryLongNullCoalescingExpression() + { + // Even very long accessor expression bodies should not trigger diagnostics + const string code = """ + public class Sample + { + private readonly IService service = null!; + + public string DisplayName + { + get => service.Configuration?.Settings?.DisplayName ?? service.Configuration?.Settings?.DefaultName ?? "Default"; + set { } + } + } + + public interface IService + { + IConfiguration? Configuration { get; } + } + + public interface IConfiguration + { + ISettings? Settings { get; } + } + + public interface ISettings + { + string? DisplayName { get; } + string? DefaultName { get; } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } } \ No newline at end of file diff --git a/test/Atc.Analyzer.Tests/Rules/Style/InterpolationMethodChainAnalyzerNoDiagnosticTests.cs b/test/Atc.Analyzer.Tests/Rules/Style/InterpolationMethodChainAnalyzerNoDiagnosticTests.cs new file mode 100644 index 0000000..568490b --- /dev/null +++ b/test/Atc.Analyzer.Tests/Rules/Style/InterpolationMethodChainAnalyzerNoDiagnosticTests.cs @@ -0,0 +1,159 @@ +namespace Atc.Analyzer.Tests.Rules.Style; + +#pragma warning disable SA1135 // Using directives must be qualified +using AnalyzerVerifier = CSharpAnalyzerVerifier; +#pragma warning restore SA1135 // Using directives must be qualified + +[SuppressMessage("", "AsyncFixer01:The method does not need to use async/await", Justification = "OK - Test code")] +[SuppressMessage("", "SA1135::The method does not need to use async/await", Justification = "OK - Test code")] +[SuppressMessage("Naming", "MA0048:File name must match type name", Justification = "OK - Partial class")] +public sealed partial class InterpolationMethodChainAnalyzerTests +{ + [Fact] + public async Task NoDiagnostic_SingleMethodCallInInterpolation() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {myVar.ToString()} world"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_PropertyAccessInInterpolation() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Length is {myVar.Length}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_VariableAccessInInterpolation() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Value is {myVar}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_NonChainedMethodCallsInInterpolation() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var result = $"Hello {Method1()} {Method2()}"; + } + + private string Method1() => "method1"; + private string Method2() => "method2"; + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_SingleMethodCallWithPropertyInInterpolation() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Length is {myVar.Trim().Length}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_EmptyInterpolation() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var result = $"Hello world"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_GeneratedCodeClass_WithMethodChainInInterpolation() + { + const string code = """ + using System.CodeDom.Compiler; + + [GeneratedCode("MyGen", "1.0.1")] + public class GeneratedSample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {myVar.ToString().ToLower()} world"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_AutoGeneratedHeader_WithMethodChainInInterpolation() + { + const string code = """ + //------------------------------------------------------------------------------ + // This code was auto-generated by ApiGenerator 2.0. + // + // Changes to this file may cause incorrect behavior and will be lost if + // the code is regenerated. + //------------------------------------------------------------------------------ + namespace MyNamespace; + + public class GeneratedClass + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {myVar.ToString().ToLower()} world"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } +} \ No newline at end of file diff --git a/test/Atc.Analyzer.Tests/Rules/Style/InterpolationMethodChainAnalyzerReportsDiagnosticTests.cs b/test/Atc.Analyzer.Tests/Rules/Style/InterpolationMethodChainAnalyzerReportsDiagnosticTests.cs new file mode 100644 index 0000000..43512a5 --- /dev/null +++ b/test/Atc.Analyzer.Tests/Rules/Style/InterpolationMethodChainAnalyzerReportsDiagnosticTests.cs @@ -0,0 +1,186 @@ +namespace Atc.Analyzer.Tests.Rules.Style; + +#pragma warning disable SA1135 // Using directives must be qualified +using AnalyzerVerifier = CSharpAnalyzerVerifier; +#pragma warning restore SA1135 // Using directives must be qualified + +[SuppressMessage("", "AsyncFixer01:The method does not need to use async/await", Justification = "OK - Test code")] +[SuppressMessage("", "SA1135::The method does not need to use async/await", Justification = "OK - Test code")] +[SuppressMessage("Naming", "MA0048:File name must match type name", Justification = "OK - Partial class")] +public sealed partial class InterpolationMethodChainAnalyzerTests +{ + [Fact] + public async Task ReportsDiagnostic_TwoChainedMethodCallsInInterpolation() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {[|myVar.ToString().ToLower()|]} world"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task ReportsDiagnostic_ThreeChainedMethodCallsInInterpolation() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {[|myVar.ToString().Trim().ToLower()|]} world"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task ReportsDiagnostic_MultipleChainedMethodCallsWithReplace() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Value: {[|myVar.Trim().Replace("a", "b")|]}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task ReportsDiagnostic_MultipleInterpolationsWithChains() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var a = "first"; + var b = "second"; + var result = $"{[|a.Trim().ToLower()|]} and {[|b.Trim().ToUpper()|]}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task ReportsDiagnostic_ChainedMethodCallsInVerbatimInterpolatedString() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $@"Hello {[|myVar.ToString().ToLower()|]} world"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task ReportsDiagnostic_ChainedMethodCallsInRawInterpolatedString() + { + const string code = """" + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"""Hello {[|myVar.ToString().ToLower()|]} world"""; + } + } + """"; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task ReportsDiagnostic_ChainedMethodCallsWithFormat() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var number = 42.5; + var result = $"Value: {[|number.ToString().Trim()|]:N2}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task ReportsDiagnostic_ChainedMethodCallsStartingFromProperty() + { + const string code = """ + public class Sample + { + public string Name { get; set; } = "test"; + + public void TestMethod() + { + var result = $"Hello {[|Name.Trim().ToLower()|]}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task ReportsDiagnostic_ChainedMethodCallsInNestedInterpolation() + { + const string code = """ + public class Sample + { + public void TestMethod() + { + var inner = "inner"; + var result = $"Outer: {$"Inner: {[|inner.Trim().ToLower()|]}"}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task ReportsDiagnostic_ChainedLinqMethodsInInterpolation() + { + const string code = """ + using System.Linq; + using System.Collections.Generic; + + public class Sample + { + public void TestMethod() + { + var items = new List { "a", "b", "c" }; + var result = $"First: {[|items.First().ToUpper()|]}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } +} \ No newline at end of file diff --git a/test/Atc.Analyzer.Tests/Rules/Style/InterpolationMethodChainCodeFixProviderTests.cs b/test/Atc.Analyzer.Tests/Rules/Style/InterpolationMethodChainCodeFixProviderTests.cs new file mode 100644 index 0000000..290f5e3 --- /dev/null +++ b/test/Atc.Analyzer.Tests/Rules/Style/InterpolationMethodChainCodeFixProviderTests.cs @@ -0,0 +1,475 @@ +namespace Atc.Analyzer.Tests.Rules.Style; + +#pragma warning disable SA1135 // Using directives must be qualified +using CodeFixVerifier = CSharpCodeFixVerifier; +#pragma warning restore SA1135 // Using directives must be qualified + +[SuppressMessage("", "AsyncFixer01:The method does not need to use async/await", Justification = "OK - Test code")] +public sealed class InterpolationMethodChainCodeFixProviderTests +{ + [Fact] + public Task FixTwoChainedMethodsInInterpolation() + { + const string source = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {myVar.ToString().ToLower()} world"; + } + } + """; + + const string fixedSource = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var lowerCaseValue = myVar.ToString().ToLower(); + var result = $"Hello {lowerCaseValue} world"; + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(6, 31, 6, 57), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixThreeChainedMethodsInInterpolation() + { + const string source = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {myVar.ToString().Trim().ToLower()} world"; + } + } + """; + + const string fixedSource = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var lowerCaseValue = myVar.ToString().Trim().ToLower(); + var result = $"Hello {lowerCaseValue} world"; + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(6, 31, 6, 64), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixChainedMethodsInVerbatimString() + { + const string source = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $@"Hello {myVar.ToString().ToLower()} world"; + } + } + """; + + const string fixedSource = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var lowerCaseValue = myVar.ToString().ToLower(); + var result = $@"Hello {lowerCaseValue} world"; + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(6, 32, 6, 58), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixChainedMethodsInRawString() + { + const string source = """" + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"""Hello {myVar.ToString().ToLower()} world"""; + } + } + """"; + + const string fixedSource = """" + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var lowerCaseValue = myVar.ToString().ToLower(); + var result = $"""Hello {lowerCaseValue} world"""; + } + } + """"; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(6, 33, 6, 59), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixChainedMethodsInReturnStatement() + { + const string source = """ + public class Sample + { + public string TestMethod() + { + var myVar = "test"; + return $"Hello {myVar.ToString().ToLower()} world"; + } + } + """; + + const string fixedSource = """ + public class Sample + { + public string TestMethod() + { + var myVar = "test"; + var lowerCaseValue = myVar.ToString().ToLower(); + return $"Hello {lowerCaseValue} world"; + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(6, 25, 6, 51), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixChainedMethodsInMethodArgument() + { + const string source = """ + using System; + + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + Console.WriteLine($"Hello {myVar.ToString().ToLower()} world"); + } + } + """; + + const string fixedSource = """ + using System; + + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var lowerCaseValue = myVar.ToString().ToLower(); + Console.WriteLine($"Hello {lowerCaseValue} world"); + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(8, 36, 8, 62), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixChainedMethodsWithTrimMethod() + { + const string source = """ + public class Sample + { + public void TestMethod() + { + var myVar = " test "; + var result = $"Hello {myVar.ToString().Trim()} world"; + } + } + """; + + const string fixedSource = """ + public class Sample + { + public void TestMethod() + { + var myVar = " test "; + var trimmedValue = myVar.ToString().Trim(); + var result = $"Hello {trimmedValue} world"; + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(6, 31, 6, 54), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixChainedMethodsWithReplaceMethod() + { + const string source = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {myVar.Trim().Replace("a", "b")} world"; + } + } + """; + + const string fixedSource = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var replacedValue = myVar.Trim().Replace("a", "b"); + var result = $"Hello {replacedValue} world"; + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(6, 31, 6, 61), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixChainedMethodsWithToUpperMethod() + { + const string source = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {myVar.ToString().ToUpper()} world"; + } + } + """; + + const string fixedSource = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var upperCaseValue = myVar.ToString().ToUpper(); + var result = $"Hello {upperCaseValue} world"; + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(6, 31, 6, 57), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixChainedLinqMethodsInInterpolation() + { + const string source = """ + using System.Linq; + using System.Collections.Generic; + + public class Sample + { + public void TestMethod() + { + var items = new List { "a", "b", "c" }; + var result = $"First: {items.First().ToUpper()}"; + } + } + """; + + const string fixedSource = """ + using System.Linq; + using System.Collections.Generic; + + public class Sample + { + public void TestMethod() + { + var items = new List { "a", "b", "c" }; + var upperCaseValue = items.First().ToUpper(); + var result = $"First: {upperCaseValue}"; + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(9, 32, 9, 55), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixNestedInterpolationChainedMethods() + { + const string source = """ + public class Sample + { + public void TestMethod() + { + var inner = "inner"; + var result = $"Outer: {$"Inner: {inner.Trim().ToLower()}"}"; + } + } + """; + + const string fixedSource = """ + public class Sample + { + public void TestMethod() + { + var inner = "inner"; + var lowerCaseValue = inner.Trim().ToLower(); + var result = $"Outer: {$"Inner: {lowerCaseValue}"}"; + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(6, 42, 6, 64), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } + + [Fact] + public Task FixChainedMethodsDeepNested() + { + const string source = """ + public class Sample + { + public void TestMethod() + { + if (true) + { + if (true) + { + var myVar = "test"; + var result = $"Hello {myVar.ToString().ToLower()} world"; + } + } + } + } + """; + + const string fixedSource = """ + public class Sample + { + public void TestMethod() + { + if (true) + { + if (true) + { + var myVar = "test"; + var lowerCaseValue = myVar.ToString().ToLower(); + var result = $"Hello {lowerCaseValue} world"; + } + } + } + } + """; + + var expected = new[] + { + new DiagnosticResult( + "ATC204", + DiagnosticSeverity.Info) + .WithSpan(10, 39, 10, 65), + }; + + return CodeFixVerifier.VerifyCodeFixAsync(source, expected, fixedSource); + } +} \ No newline at end of file diff --git a/test/Atc.Analyzer.Tests/Rules/Style/MethodChainSeparationAnalyzerNoDiagnosticTests.cs b/test/Atc.Analyzer.Tests/Rules/Style/MethodChainSeparationAnalyzerNoDiagnosticTests.cs index ab6cf2b..e7f11be 100644 --- a/test/Atc.Analyzer.Tests/Rules/Style/MethodChainSeparationAnalyzerNoDiagnosticTests.cs +++ b/test/Atc.Analyzer.Tests/Rules/Style/MethodChainSeparationAnalyzerNoDiagnosticTests.cs @@ -622,4 +622,42 @@ public void TestMethod(string str) await test.RunAsync(); } + + [Fact] + public async Task NoDiagnostic_MethodChainInsideInterpolatedString() + { + // Method chains inside interpolated strings should be skipped by ATC203 + // They are handled by ATC204 instead + const string code = """ + public class Sample + { + public void TestMethod() + { + var myVar = "test"; + var result = $"Hello {myVar.ToString().ToLower()} world"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task NoDiagnostic_MultipleMethodChainsInsideInterpolatedString() + { + // Multiple method chains inside interpolated strings should be skipped by ATC203 + const string code = """ + public class Sample + { + public void TestMethod() + { + var a = "first"; + var b = "second"; + var result = $"{a.Trim().ToLower()} and {b.Trim().ToUpper()}"; + } + } + """; + + await AnalyzerVerifier.VerifyAnalyzerAsync(code); + } } \ No newline at end of file