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