Skip to content

Commit db6a0c6

Browse files
Add suppressor for pattern matching false positives
- Introduce StringComparablePatternMatchingSuppressor to hide IDE pattern matching suggestions (IDE0078, IDE0083, IDE0260, RCS1246) for StringComparable/SpanComparable types, preventing false positives. - Add manual verification guide and automated config test for the suppressor. - Update XML docs for StringComparable/SpanComparable to warn about pattern matching limitations and recommend the analyzer. - Add PatternMatchingAssumptionTests to document .NET/IDE behavior and limitations. - Reference analyzer in test project to ensure suppressor is active during tests. - Clarify OPENTXT002/OPENTXT008 diagnostics and update EXAMPLES.md/README.md to better explain allocation behavior and alternatives for SplitAsSegments and SplitToEnumerable. - Add full test suites for EnumValueCaseIgnored<T> and StringBuilderHelper. - Clean up usings, comments, and performance notes; ensure analyzer suppressions are respected in tests.
1 parent a60ec4e commit db6a0c6

24 files changed

Lines changed: 682 additions & 254 deletions

Analyzers.Tests/SmartDetectionTests.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
using System;
21
using System.Threading.Tasks;
32
using Xunit;
4-
using VerifySubstring = Open.Text.Analyzers.Tests.CSharpAnalyzerVerifier<Open.Text.Analyzers.SubstringAnalyzer>;
5-
using VerifySplit = Open.Text.Analyzers.Tests.CSharpAnalyzerVerifier<Open.Text.Analyzers.SplitAnalyzer>;
63
using VerifyConcat = Open.Text.Analyzers.Tests.CSharpAnalyzerVerifier<Open.Text.Analyzers.StringConcatenationAnalyzer>;
4+
using VerifySplit = Open.Text.Analyzers.Tests.CSharpAnalyzerVerifier<Open.Text.Analyzers.SplitAnalyzer>;
5+
using VerifySubstring = Open.Text.Analyzers.Tests.CSharpAnalyzerVerifier<Open.Text.Analyzers.SubstringAnalyzer>;
76
using VerifyTrim = Open.Text.Analyzers.Tests.CSharpAnalyzerVerifier<Open.Text.Analyzers.TrimEqualsAnalyzer>;
87

98
namespace Open.Text.Analyzers.Tests;

Analyzers.Tests/SplitAnalyzerTests.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
using System.Threading.Tasks;
22
using Xunit;
33
using VerifyAnalyzer = Open.Text.Analyzers.Tests.CSharpAnalyzerVerifier<Open.Text.Analyzers.SplitAnalyzer>;
4-
using VerifyCodeFix = Open.Text.Analyzers.Tests.CSharpCodeFixVerifier<
5-
Open.Text.Analyzers.SplitAnalyzer,
6-
Open.Text.Analyzers.SplitCodeFixProvider>;
74

85
namespace Open.Text.Analyzers.Tests;
96

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using Xunit;
3+
4+
namespace Open.Text.Analyzers.Tests;
5+
6+
/// <summary>
7+
/// <para>MANUAL VERIFICATION TESTS for StringComparablePatternMatchingSuppressor</para>
8+
/// <para>To verify the suppressor works:</para>
9+
/// <para>
10+
/// 1. Create a new test console app:
11+
/// dotnet new console -n SuppressorTest
12+
/// cd SuppressorTest
13+
/// </para>
14+
/// <para>
15+
/// 2. Add Open.Text package:
16+
/// dotnet add package Open.Text
17+
/// </para>
18+
/// <para>3. Add this code to Program.cs:</para>
19+
/// <para> using Open.Text;</para>
20+
/// <para> string text = "HELLO";</para>
21+
/// <para>
22+
/// // TEST 1: Regular string - SHOULD show IDE0078 suggestion
23+
/// bool test1 = text == "hello" || text == "world";
24+
/// // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
25+
/// // You SHOULD see: "Use pattern matching" suggestion
26+
/// </para>
27+
/// <para>
28+
/// // TEST 2: StringComparable - should NOT show suggestion (initially)
29+
/// var comparable = text.AsCaseInsensitive();
30+
/// bool test2 = comparable == "hello" || comparable == "world";
31+
/// // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
32+
/// // You WILL see: "Use pattern matching" suggestion (without analyzer)
33+
/// </para>
34+
/// <para>4. Verify the IDE shows suggestion for BOTH (this is the problem!)</para>
35+
/// <para>
36+
/// 5. Now install the analyzer:
37+
/// dotnet add package Open.Text.Analyzers
38+
/// </para>
39+
/// <para>
40+
/// 6. Rebuild and check:
41+
/// - TEST 1: Still shows suggestion ? (correct)
42+
/// - TEST 2: NO suggestion anymore ? (suppressed!)
43+
/// </para>
44+
/// <para>
45+
/// 7. Try to apply the suggestion on TEST 2 (it won't compile):
46+
/// bool test2 = comparable is "hello" or "world";
47+
/// // Error CS0029: Cannot implicitly convert type 'string' to 'Open.Text.StringComparable'
48+
/// </para>
49+
/// <para>
50+
/// EXPECTED RESULTS:
51+
/// - Without analyzer: Both show IDE0078
52+
/// - With analyzer: Only test1 shows IDE0078, test2 is suppressed
53+
/// </para>
54+
/// </summary>
55+
[ExcludeFromCodeCoverage]
56+
[SuppressMessage("Usage", "xUnit1004:Test methods should not be skipped")]
57+
public class StringComparablePatternMatchingSuppressorManualTests
58+
{
59+
[Fact(Skip = "Manual verification required - see class documentation")]
60+
public void ManualVerification_Instructions()
61+
{
62+
// This test is skipped - it documents how to manually verify the suppressor works
63+
// Follow the instructions in the class XML documentation above
64+
}
65+
66+
/// <summary>
67+
/// Automated test to verify the suppressor is properly configured.
68+
/// This doesn't test if it WORKS, but verifies it's set up correctly.
69+
/// </summary>
70+
[Fact]
71+
public void Suppressor_IsProperlyConfigured()
72+
{
73+
var suppressor = new StringComparablePatternMatchingSuppressor();
74+
75+
// Verify it has the expected suppressions
76+
var suppressions = suppressor.SupportedSuppressions;
77+
Assert.Equal(4, suppressions.Length);
78+
79+
// Verify it suppresses IDE0078
80+
Assert.Contains(suppressions, s => s.SuppressedDiagnosticId == "IDE0078");
81+
Assert.Contains(suppressions, s => s.SuppressedDiagnosticId == "IDE0083");
82+
Assert.Contains(suppressions, s => s.SuppressedDiagnosticId == "IDE0260");
83+
Assert.Contains(suppressions, s => s.SuppressedDiagnosticId == "RCS1246");
84+
}
85+
}

Analyzers/DiagnosticDescriptors.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ internal static class DiagnosticDescriptors
2222

2323
public static readonly DiagnosticDescriptor UseSplitAsSegments = new(
2424
id: "OPENTXT002",
25-
title: "Use SplitAsSegments for zero-allocation splitting",
26-
messageFormat: "Consider using '.SplitAsSegments({0})' instead of '.Split({0})' to avoid allocating an array and individual strings",
25+
title: "Use SplitAsSegments to reduce string allocations",
26+
messageFormat: "Consider using '.SplitAsSegments({0})' instead of '.Split({0})' to avoid allocating an array and individual strings. Use '.SplitToEnumerable({0})' if you still need strings with lazy evaluation.",
2727
category: Category,
2828
defaultSeverity: DiagnosticSeverity.Info,
2929
isEnabledByDefault: true,
30-
description: "SplitAsSegments returns IEnumerable<StringSegment> which avoids allocating strings until ToString() is called.",
30+
description: "SplitAsSegments returns IEnumerable<StringSegment> which defers string allocation until ToString() is called. SplitToEnumerable provides lazy string allocation with IEnumerable<string>.",
3131
helpLinkUri: HelpLinkUriBase + "OPENTXT002.md");
3232

3333
public static readonly DiagnosticDescriptor UseSpanForIndexOfSubstring = new(
@@ -82,11 +82,11 @@ internal static class DiagnosticDescriptors
8282

8383
public static readonly DiagnosticDescriptor UseSplitToEnumerable = new(
8484
id: "OPENTXT008",
85-
title: "Use SplitToEnumerable for deferred execution",
86-
messageFormat: "Consider using '.SplitToEnumerable({0})' instead of '.Split({0})' when iterating results for lazy evaluation",
85+
title: "Use SplitToEnumerable for lazy string evaluation",
86+
messageFormat: "Consider using '.SplitToEnumerable({0})' instead of '.Split({0})' when iterating results to avoid allocating the full array upfront. For even less allocation, use '.SplitAsSegments({0})' which returns StringSegments.",
8787
category: Category,
8888
defaultSeverity: DiagnosticSeverity.Info,
8989
isEnabledByDefault: true,
90-
description: "SplitToEnumerable provides lazy evaluation and avoids allocating the full array upfront.",
90+
description: "SplitToEnumerable returns IEnumerable<string> with lazy evaluation, avoiding the upfront allocation of the full string array. Strings are still created on demand during iteration.",
9191
helpLinkUri: HelpLinkUriBase + "OPENTXT008.md");
9292
}

Analyzers/EXAMPLES.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public ReadOnlySpan<char> ExtractDomain(string email)
2323
int atIndex = email.IndexOf('@');
2424
if (atIndex == -1) return ReadOnlySpan<char>.Empty;
2525

26-
// Zero allocation! Returns a span view
26+
// No string allocation! Returns a span view
2727
return email.AsSpan(atIndex + 1);
2828
}
2929

@@ -39,8 +39,8 @@ public string ExtractDomainString(string email)
3939
```
4040

4141
**Performance Impact:**
42-
- Before: ~100ns, 40 bytes allocated
43-
- After: ~15ns, 0 bytes allocated (until ToString())
42+
- Before: ~100ns, 40 bytes allocated per call
43+
- After: ~15ns, 0 bytes allocated for string (until ToString())
4444

4545
---
4646

@@ -69,7 +69,7 @@ void ProcessColumn(string column)
6969
```csharp
7070
public void ProcessCsvLine(string line)
7171
{
72-
// Zero allocations! Returns IEnumerable<StringSegment>
72+
// Avoids array + string allocations! Returns IEnumerable<StringSegment>
7373
var columns = line.SplitAsSegments(',');
7474

7575
foreach (var column in columns)
@@ -87,7 +87,7 @@ void ProcessColumn(StringSegment column)
8787

8888
**Performance Impact:**
8989
- Before: ~500ns, 200+ bytes allocated (array + 5 strings)
90-
- After: ~150ns, 0 bytes allocated (lazy evaluation)
90+
- After: ~150ns, minimal allocations (enumerator only, no string array)
9191

9292
---
9393

Analyzers/IndexOfSubstringAnalyzer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ assignment.Left is IdentifierNameSyntax identifier &&
7676
if (IsIndexOfCall(indexOfCall, context))
7777
{
7878
var variableName = identifier.Identifier.Text;
79-
if (FindSubstringUsage(statements.Skip(i + 1), variableName, out var substringLocation))
79+
if (FindSubstringUsage(statements.Skip(i + 1), variableName, out var substringLocation))
8080
{
8181
var diagnostic = Diagnostic.Create(
8282
DiagnosticDescriptors.UseSpanForIndexOfSubstring,

0 commit comments

Comments
 (0)