Skip to content

Latest commit

 

History

History
394 lines (303 loc) · 12.3 KB

File metadata and controls

394 lines (303 loc) · 12.3 KB

🔗 ATC203: Method chains with 2 or more calls should be placed on separate lines

📂 Category

Style

⚠️ Severity

Suggestion (Info)

📖 Description

Enforces that method chains with 2 or more method calls should be broken down to separate lines for better readability. This rule ensures consistent and readable code by requiring each method in a chain to be on its own line when you have multiple chained calls.

📋 Rules

  1. Method chains with 2+ calls from variables/properties: When starting from a variable, property, or field, method chains with 2 or more calls must have each method call on a separate line
  2. Single method calls: No formatting restrictions - can remain on one line
  3. Property access: Accessing a property after a single method call (e.g., str.Trim().Length) is allowed on one line
  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. Interpolated strings: Method chains inside interpolated strings are excluded from this rule - see ATC204 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

Placing method chains on separate lines:

  • Significantly improves code readability for complex chains
  • Makes each step of the operation clearly visible
  • Easier to debug by setting breakpoints on individual calls
  • Reduces horizontal scrolling
  • Makes code reviews easier by showing one operation per line
  • Follows common C# formatting conventions

📝 Examples

Two method calls

// non-compliant
var result = str.Trim().ToLower();

// compliant
var result = str
    .Trim()
    .ToLower();

Multiple method calls

// non-compliant
var result = str.Trim().Replace("xxx", "x").Replace("yyy", "y").Trim();

// compliant
var result = str
    .Trim()
    .Replace("xxx", "x")
    .Replace("yyy", "y")
    .Trim();

LINQ chains

// non-compliant
var result = numbers.Where(x => x > 0).Select(x => x * 2).ToList();

// compliant
var result = numbers
    .Where(x => x > 0)
    .Select(x => x * 2)
    .ToList();

Await with ConfigureAwait - From variable/property

// non-compliant - starting from a variable with 2+ chained methods
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);

// compliant
var root = await document
    .GetSyntaxRootAsync(cancellationToken)
    .ConfigureAwait(false);

Await with ConfigureAwait - From method call

// compliant - starting from a method call with only 1 chained method
var result = await GetDataAsync().ConfigureAwait(false);

// also compliant when discard is used
_ = GetDataAsync().ConfigureAwait(false);

StringBuilder chains

// non-compliant
var result = new StringBuilder().Append("hello").Append(" world").ToString();

// compliant
var result = new StringBuilder()
    .Append("hello")
    .Append(" world")
    .ToString();

Single method call (allowed)

// compliant - single method call is allowed on one line
var result = str.Trim();

Property access after method call (allowed)

// compliant - property access after single method is allowed
var length = str.Trim().Length;

Method call followed by single chained method (allowed)

// compliant - method call (with or without arguments) followed by a single chained method is allowed
var v = MethodA(value).ToString(Thread.CurrentThread.CurrentCulture);
var result = CalculateSum(a, b).ToString();
var data = GetDataAsync().ConfigureAwait(false);
var task = Task.Run(() => 42).ContinueWith(t => t.Result * 2);

// non-compliant - starting from a variable with 2+ chained methods
var lower = str.Trim().ToLower(); // Should be on separate lines

If-statement conditions with 2 method chains (allowed)

// compliant - 2 method chains in if-statement condition
using System.Linq;
using System.Xml.Linq;

public void ProcessElement(XElement rootElement)
{
    if (rootElement.Attributes("Sdk").Count() == 1)
    {
        // Process element
    }

    if (text.Trim().ToLower() == "test")
    {
        // Handle test case
    }
}

// non-compliant - 3+ method chains in if-statement must still be on separate lines
public void ProcessText(string text)
{
    if (text.Trim().ToLower().Replace("a", "b") == "test")  // Not allowed
    {
    }

    // Should be written as:
    if (text
        .Trim()
        .ToLower()
        .Replace("a", "b") == "test")
    {
    }
}

// non-compliant - 2 method chains in assignment still require separate lines
public void AssignValue(string text)
{
    var result = text.Trim().ToLower();  // Not allowed

    // Should be written as:
    var result = text
        .Trim()
        .ToLower();
}

FluentAssertions - Simple assertions

// compliant - .Should() followed by any single assertion method is allowed on one line
[Fact]
public void TestMethod()
{
    var actual = "test";
    actual.Should().Be("test");
    actual.Should().NotBeNull();
    actual.Should().NotBeNullOrEmpty();

    var expected = new { Name = "test" };
    var obj = new { Name = "test" };
    obj.Should().BeEquivalentTo(expected);

    var number = 42;
    number.Should().BePositive();
}

FluentAssertions - Multiple assertions with .And

// non-compliant
[Fact]
public void TestMethod()
{
    var actual = "test";
    actual.Should().Be("test").And.NotBeNull(); // 3 methods chained
}

// compliant
[Fact]
public void TestMethod()
{
    var trimmed = "  Hello World  ".Trim();
    trimmed
        .Should().Be("Hello World")
        .And.NotBeNullOrWhiteSpace();
}

FluentAssertions - Multiple .And chains

// non-compliant
[Fact]
public void TestMethod()
{
    var upper = "test".ToUpperInvariant();
    upper.Should().Be("TEST").And.StartWith("TE").And.EndWith("ST");
}

// compliant
[Fact]
public void TestMethod()
{
    var upper = "test".ToUpperInvariant();
    upper
        .Should().Be("TEST")
        .And.StartWith("TE")
        .And.EndWith("ST");
}

Interpolated strings (allowed - handled by ATC204)

// 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:

[*.cs]
# Minimum chain length before requiring separation (default: 2, range: 2-10)
dotnet_diagnostic.ATC203.min_chain_length = 3
Option Default Range Description
min_chain_length 2 2-10 Minimum number of chained method calls before the rule requires separation

Example configurations:

# Default - 2+ method chains must be on separate lines
dotnet_diagnostic.ATC203.min_chain_length = 2

# Lenient - only 3+ method chains must be on separate lines
dotnet_diagnostic.ATC203.min_chain_length = 3

# Very lenient - only 4+ method chains must be on separate lines
dotnet_diagnostic.ATC203.min_chain_length = 4

Note: The special case exceptions (FluentAssertions, method-to-method chains, if-statement conditions) still apply regardless of this configuration.

Exceptions:

  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 for interpolation-specific analysis that suggests extracting complex expressions to variables.

🤖 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)

Example of skipped code:

using System.CodeDom.Compiler;

[GeneratedCode("MyCodeGenerator", "1.0")]
public class GeneratedClass
{
    public void Method()
    {
        // This would normally violate ATC203, but is ignored
        var result = str.Trim().ToLower();
    }
}
//------------------------------------------------------------------------------
// 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.
//------------------------------------------------------------------------------

public class AutoGeneratedClass
{
    public void Method()
    {
        // This would normally violate ATC203, but is ignored
        var result = str.Trim().ToLower();
    }
}

🔧 Code Fix

A code fix is available that automatically places method chains on separate lines with proper indentation.

How to apply:

  1. Position cursor on the method chain that violates the rule
  2. Click the lightbulb (💡) or press Ctrl+. (Windows/Linux) or Cmd+. (Mac)
  3. Select "Place method chain on separate lines"

The code fix will:

  • Place each method call in the chain on its own line
  • Apply consistent indentation (4 spaces per level based on the containing statement)
  • Preserve all method arguments, including complex expressions and lambda parameters
  • Handle await expressions correctly
  • Work with method chains in any context (assignments, return statements, arguments, etc.)

Example:

// Before code fix
var result = str.Trim().ToLower();

// After code fix (automatic)
var result = str
    .Trim()
    .ToLower();

Complex example with nested expressions:

// Before code fix
var result = await dataTask.ContinueWith(t => t.Result).ConfigureAwait(false);

// After code fix (automatic)
var result = await dataTask
    .ContinueWith(t => t.Result)
    .ConfigureAwait(false);

Note: The code fix correctly handles lambda expressions and does not break method chains inside lambda parameters.

🔗 Related Rules