Skip to content

Commit 45ef75f

Browse files
committed
Add SSS007 analyzer: a Span<char>/ReadOnlySpan<char> switch-expression arm written as a _ when scrutinee.SequenceEqual("literal") guard should be the constant string pattern "literal" (matched directly since C# 11); the enforcement companion to SSS003, which creates the stackalloc-span scrutinee that invites the guard-form mistake.
1 parent 243663b commit 45ef75f

3 files changed

Lines changed: 237 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Six scopes, one home each. **Add new state to whichever class matches its true s
8585
- **SSS004**: two or more `if`/`else if` branches with conditions of the shape `<sameScrutinee> is <SameType> { <SameProperty>: ... }` should be a single `switch`. The `switch` form fuses isinst + ldfld; the if-chain repeats both per arm.
8686
- **SSS005**: a `switch` (expr or statement) whose arms are all single string/numeric compile-time constants must list them sorted — strings ordinal, numbers numerically (`_`/`null` excluded). Exempts any arm that's a guard, or/relational/recursive/`var` pattern, or an **enum/`char`/`bool`** constant (those order by meaning, not value). A switch deliberately ordered by meaning (time-unit magnitude, host level, …): wrap in `#pragma warning disable SSS005` + one-line rationale rather than sorting.
8787
- **SSS006**: 2+ consecutive statements that each call a self-returning `StringBuilder` method (containing-type and return-type both `StringBuilder``Append`/`Insert`/`Replace`/… ) on the same builder and discard the result (bare or `_ =`) should be one fluent chain. An already-chained statement is peeled to its base receiver, so `sb.Append(a).Append(b)` beside `sb.Append(c)` merges. Only fires when that base is side-effect-free (identifier / `this.field` / dotted); call-valued roots exempt. Comments between the statements don't exempt — slot them between the chained calls.
88+
- **SSS007**: a `switch` **expression** over `Span<char>` / `ReadOnlySpan<char>` whose arm is a discard guard `_ when <governing>.SequenceEqual("literal")` should be the constant string pattern `"literal"` — a span-of-char switch matches string constants directly since C# 11 (the compiler lowers the pattern to the same comparison). Only the pure single-invocation guard whose receiver is the switch's own governing expression is flagged (negated / `&&`-combined conditions, or ones probing a different span, are left alone). This is the enforcement companion to SSS003, which puts the uppercased scrutinee on a `stackalloc Span<char>` in the first place; the `ResolveBuiltIn` keyword tables in `Parser/Expression.cs` are the reference shape.
8889
- **MSTEST0049**: async tests must thread `TestContext.CancellationToken`. Pattern: `public TestContext TestContext { get; set; } = null!;`.
8990
- **MSTEST0037**: prefer `Assert.IsEmpty(values)` over `Assert.AreEqual(0, values.Count)`; typed asserts over generic.
9091

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using Microsoft.CodeAnalysis.CSharp.Testing;
2+
using Microsoft.CodeAnalysis.Testing;
3+
4+
namespace SqlServerSimulator.Analyzers;
5+
6+
[TestClass]
7+
public sealed class SpanCharSequenceEqualGuardAnalyzerTests
8+
{
9+
public TestContext TestContext { get; set; } = null!;
10+
11+
private Task RunAsync(string source) =>
12+
new CSharpAnalyzerTest<SpanCharSequenceEqualGuardAnalyzer, DefaultVerifier>
13+
{ TestCode = source }.RunAsync(this.TestContext.CancellationToken);
14+
15+
[TestMethod]
16+
public Task ReadOnlySpanGuard_Reports() =>
17+
RunAsync("""
18+
using System;
19+
internal static class Holder
20+
{
21+
public static int Resolve(ReadOnlySpan<char> s) => s switch
22+
{
23+
_ when {|SSS007:s.SequenceEqual("X")|} => 1,
24+
_ => 0,
25+
};
26+
}
27+
""");
28+
29+
[TestMethod]
30+
public Task WritableSpanGuard_Reports() =>
31+
RunAsync("""
32+
using System;
33+
internal static class Holder
34+
{
35+
public static int Resolve(string input)
36+
{
37+
Span<char> s = stackalloc char[input.Length];
38+
_ = input.AsSpan().ToUpperInvariant(s);
39+
return s switch
40+
{
41+
_ when {|SSS007:s.SequenceEqual("ABC")|} => 1,
42+
_ => 0,
43+
};
44+
}
45+
}
46+
""");
47+
48+
[TestMethod]
49+
public Task ConstantPatternForm_DoesNotReport() =>
50+
RunAsync("""
51+
using System;
52+
internal static class Holder
53+
{
54+
public static int Resolve(ReadOnlySpan<char> s) => s switch
55+
{
56+
"X" => 1,
57+
_ => 0,
58+
};
59+
}
60+
""");
61+
62+
[TestMethod]
63+
public Task GuardOnDifferentSpan_DoesNotReport() =>
64+
RunAsync("""
65+
using System;
66+
internal static class Holder
67+
{
68+
public static int Resolve(ReadOnlySpan<char> s, ReadOnlySpan<char> other) => s switch
69+
{
70+
_ when other.SequenceEqual("X") => 1,
71+
_ => 0,
72+
};
73+
}
74+
""");
75+
76+
[TestMethod]
77+
public Task NonConstantArgument_DoesNotReport() =>
78+
RunAsync("""
79+
using System;
80+
internal static class Holder
81+
{
82+
public static int Resolve(ReadOnlySpan<char> s, string other) => s switch
83+
{
84+
_ when s.SequenceEqual(other) => 1,
85+
_ => 0,
86+
};
87+
}
88+
""");
89+
90+
[TestMethod]
91+
public Task NegatedGuard_DoesNotReport() =>
92+
RunAsync("""
93+
using System;
94+
internal static class Holder
95+
{
96+
public static int Resolve(ReadOnlySpan<char> s) => s switch
97+
{
98+
_ when !s.SequenceEqual("X") => 0,
99+
_ => 1,
100+
};
101+
}
102+
""");
103+
104+
[TestMethod]
105+
public Task CombinedGuard_DoesNotReport() =>
106+
RunAsync("""
107+
using System;
108+
internal static class Holder
109+
{
110+
public static int Resolve(ReadOnlySpan<char> s) => s switch
111+
{
112+
_ when s.SequenceEqual("X") && s.Length > 0 => 1,
113+
_ => 0,
114+
};
115+
}
116+
""");
117+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using System.Collections.Immutable;
2+
using System.Threading;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using Microsoft.CodeAnalysis.Diagnostics;
7+
8+
namespace SqlServerSimulator.Analyzers;
9+
10+
/// <summary>
11+
/// Flags a <c>switch</c> over a <see cref="System.Span{Char}"/> /
12+
/// <see cref="System.ReadOnlySpan{Char}"/> whose arm is written as a discard
13+
/// guard <c>_ when &lt;governing&gt;.SequenceEqual("literal")</c> instead of
14+
/// the equivalent constant string pattern <c>"literal"</c>. Since C# 11 a
15+
/// span-of-char switch matches string constants directly (the compiler
16+
/// lowers the pattern to the same element-wise comparison), so the
17+
/// hand-written <c>SequenceEqual</c> guard is pure noise — and, being a
18+
/// guard, it also opts the whole switch out of the
19+
/// <c>SortedConstantSwitchAnalyzer</c> (SSS005) ordering check that the
20+
/// constant-pattern form would enjoy.
21+
/// </summary>
22+
/// <remarks>
23+
/// This is the enforcement companion to <c>TransientUpperLowerInvariant</c>
24+
/// (SSS003): that rule pushes an uppercased switch scrutinee onto a
25+
/// <c>stackalloc</c> <c>Span&lt;char&gt;</c>, and the natural next mistake is
26+
/// to reach for <c>SequenceEqual</c> guards — the pre-C#-11 idiom — rather
27+
/// than plain string-literal arms. The <c>ResolveBuiltIn</c> keyword-dispatch
28+
/// tables in <c>Parser/Expression.cs</c> are the reference for the intended
29+
/// shape: a <c>Span&lt;char&gt;</c> scrutinee with bare <c>"NAME" =&gt; …</c>
30+
/// arms. Only the pure single-invocation guard is flagged (a negated or
31+
/// <c>&amp;&amp;</c>-combined condition has no single-pattern equivalent and
32+
/// is left alone), and only when the receiver is the switch's own governing
33+
/// expression (otherwise no constant pattern can replace it).
34+
/// </remarks>
35+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
36+
public sealed class SpanCharSequenceEqualGuardAnalyzer : DiagnosticAnalyzer
37+
{
38+
private static readonly DiagnosticDescriptor Rule = new(
39+
id: "SSS007",
40+
title: "Span<char> switch guard should be a constant string pattern",
41+
messageFormat: "Replace the guard '_ when {0}.SequenceEqual(\"{1}\")' with the constant pattern \"{1}\" — a Span<char>/ReadOnlySpan<char> switch matches string constants directly since C# 11",
42+
category: "Maintainability",
43+
defaultSeverity: DiagnosticSeverity.Warning,
44+
isEnabledByDefault: true,
45+
description: "A switch over Span<char> / ReadOnlySpan<char> matches string-literal patterns directly since C# 11 — the compiler lowers a constant string pattern to the same element-wise comparison a hand-written SequenceEqual performs. Writing the arm as a '_ when scrutinee.SequenceEqual(\"literal\")' guard is therefore redundant, and because it is a guard it also exempts the whole switch from the SSS005 sorted-arms check that the constant-pattern form would be held to. Only the pure single-invocation guard whose receiver is the switch's governing expression is flagged; a negated or combined condition, or one probing a different span, has no single-pattern equivalent and is left alone.");
46+
47+
/// <inheritdoc/>
48+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];
49+
50+
/// <inheritdoc/>
51+
public override void Initialize(AnalysisContext context)
52+
{
53+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
54+
context.EnableConcurrentExecution();
55+
// Switch expressions only. A discard-with-guard case label
56+
// (`case _ when …`) doesn't parse as a discard in a switch statement
57+
// — `_` reads as an identifier there — so the anti-pattern this rule
58+
// targets can only be written in the expression form (which is also
59+
// where the clean span-constant-pattern alternative applies).
60+
context.RegisterSyntaxNodeAction(AnalyzeSwitchExpression, SyntaxKind.SwitchExpression);
61+
}
62+
63+
private static void AnalyzeSwitchExpression(SyntaxNodeAnalysisContext context)
64+
{
65+
var switchExpression = (SwitchExpressionSyntax)context.Node;
66+
var governing = switchExpression.GoverningExpression;
67+
if (!IsSpanOfChar(governing, context.SemanticModel, context.CancellationToken))
68+
return;
69+
70+
foreach (var arm in switchExpression.Arms)
71+
{
72+
if (arm.Pattern is DiscardPatternSyntax && arm.WhenClause is { Condition: { } condition })
73+
InspectGuard(context, governing, condition);
74+
}
75+
}
76+
77+
// Reports when `condition` is exactly `<governing>.SequenceEqual(<const
78+
// string>)` — the manual spelling of a constant string pattern.
79+
private static void InspectGuard(SyntaxNodeAnalysisContext context, ExpressionSyntax governing, ExpressionSyntax condition)
80+
{
81+
// netstandard2.0 (the analyzer TFM) has no System.Index, so list
82+
// patterns aren't available — match arity explicitly.
83+
if (condition is not InvocationExpressionSyntax
84+
{
85+
Expression: MemberAccessExpressionSyntax
86+
{
87+
Name.Identifier.ValueText: "SequenceEqual",
88+
Expression: { } receiver,
89+
},
90+
ArgumentList.Arguments: { Count: 1 } arguments,
91+
})
92+
{
93+
return;
94+
}
95+
96+
var argument = arguments[0].Expression;
97+
98+
// The receiver must be the switch's own scrutinee — a constant
99+
// pattern can only replace a guard that probes the governing value.
100+
if (!SyntaxFactory.AreEquivalent(receiver, governing))
101+
return;
102+
103+
if (context.SemanticModel.GetConstantValue(argument, context.CancellationToken).Value is not string text)
104+
return;
105+
106+
context.ReportDiagnostic(Diagnostic.Create(Rule, condition.GetLocation(), receiver.ToString(), text));
107+
}
108+
109+
// True when the switch scrutinee is System.Span<char> or
110+
// System.ReadOnlySpan<char> — the two types C# 11's constant-string
111+
// pattern support applies to.
112+
private static bool IsSpanOfChar(ExpressionSyntax expression, SemanticModel model, CancellationToken cancellationToken) =>
113+
model.GetTypeInfo(expression, cancellationToken).Type is INamedTypeSymbol
114+
{
115+
Name: "Span" or "ReadOnlySpan",
116+
ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true },
117+
TypeArguments: { Length: 1 } typeArguments,
118+
} && typeArguments[0].SpecialType == SpecialType.System_Char;
119+
}

0 commit comments

Comments
 (0)