Skip to content

Commit 69e2fa7

Browse files
authored
Merge pull request #1181 from sharwell/faded-locations
Add validation for additional unnecessary locations
2 parents bc8a063 + cba08e2 commit 69e2fa7

File tree

6 files changed

+239
-21
lines changed

6 files changed

+239
-21
lines changed

src/Microsoft.CodeAnalysis.Testing/Microsoft.CodeAnalysis.Analyzer.Testing/AnalyzerTest`1.cs

+62-18
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using System.Reflection;
1313
using System.Runtime.CompilerServices;
1414
using System.Text;
15+
using System.Text.RegularExpressions;
1516
using System.Threading;
1617
using System.Threading.Tasks;
1718
using DiffPlex;
@@ -34,6 +35,7 @@ public abstract class AnalyzerTest<TVerifier>
3435
where TVerifier : IVerifier, new()
3536
{
3637
private static readonly ConditionalWeakTable<Diagnostic, object> NonLocalDiagnostics = new ConditionalWeakTable<Diagnostic, object>();
38+
private static readonly Regex EncodedIndicesSyntax = new Regex(@"^\s*\[\s*((?<Index>[0-9]+)\s*(,\s*(?<Index>[0-9]+)\s*)*)?\]\s*$", RegexOptions.ExplicitCapture | RegexOptions.Compiled);
3739

3840
/// <summary>
3941
/// Gets the default verifier for the test.
@@ -558,16 +560,43 @@ private void VerifyDiagnosticResults(IEnumerable<(Project project, Diagnostic di
558560
else
559561
{
560562
VerifyDiagnosticLocation(analyzers, actual.diagnostic, expected, actual.diagnostic.Location, expected.Spans[0], verifier);
563+
int[] unnecessaryIndices = { };
564+
if (actual.diagnostic.Properties.TryGetValue(WellKnownDiagnosticTags.Unnecessary, out var encodedUnnecessaryLocations))
565+
{
566+
verifier.True(actual.diagnostic.Descriptor.CustomTags.Contains(WellKnownDiagnosticTags.Unnecessary), "Diagnostic reported extended unnecessary locations, but the descriptor is not marked as unnecessary code.");
567+
var match = EncodedIndicesSyntax.Match(encodedUnnecessaryLocations);
568+
verifier.True(match.Success, $"Expected encoded unnecessary locations to be a valid JSON array of non-negative integers: {encodedUnnecessaryLocations}");
569+
unnecessaryIndices = match.Groups["Index"].Captures.OfType<Capture>().Select(capture => int.Parse(capture.Value)).ToArray();
570+
verifier.NotEmpty(nameof(unnecessaryIndices), unnecessaryIndices);
571+
foreach (var index in unnecessaryIndices)
572+
{
573+
if (index < 0 || index >= actual.diagnostic.AdditionalLocations.Count)
574+
{
575+
verifier.Fail($"All unnecessary indices in the diagnostic must be valid indices in AdditionalLocations [0-{actual.diagnostic.AdditionalLocations.Count}): {encodedUnnecessaryLocations}");
576+
}
577+
}
578+
}
579+
561580
if (!expected.Options.HasFlag(DiagnosticOptions.IgnoreAdditionalLocations))
562581
{
563582
var additionalLocations = actual.diagnostic.AdditionalLocations.ToArray();
564583

565584
message = FormatVerifierMessage(analyzers, actual.diagnostic, expected, $"Expected {expected.Spans.Length - 1} additional locations but got {additionalLocations.Length} for Diagnostic:");
566585
verifier.Equal(expected.Spans.Length - 1, additionalLocations.Length, message);
567586

568-
for (var j = 0; j < additionalLocations.Length; ++j)
587+
for (var j = 0; j < additionalLocations.Length; j++)
569588
{
570589
VerifyDiagnosticLocation(analyzers, actual.diagnostic, expected, additionalLocations[j], expected.Spans[j + 1], verifier);
590+
var isActualUnnecessary = unnecessaryIndices.Contains(j);
591+
var isExpectedUnnecessary = expected.Spans[j + 1].Options.HasFlag(DiagnosticLocationOptions.UnnecessaryCode);
592+
if (isExpectedUnnecessary)
593+
{
594+
verifier.True(isActualUnnecessary, $"Expected diagnostic additional location index \"{j}\" to be marked unnecessary, but was not.");
595+
}
596+
else
597+
{
598+
verifier.False(isActualUnnecessary, $"Expected diagnostic additional location index \"{j}\" to not be marked unnecessary, but was instead marked unnecessary.");
599+
}
571600
}
572601
}
573602
}
@@ -815,18 +844,17 @@ private void VerifyLinePosition(ImmutableArray<DiagnosticAnalyzer> analyzers, Di
815844
private static string FormatDiagnostics(ImmutableArray<DiagnosticAnalyzer> analyzers, string defaultFilePath, params Diagnostic[] diagnostics)
816845
{
817846
var builder = new StringBuilder();
818-
for (var i = 0; i < diagnostics.Length; ++i)
847+
foreach (var diagnostic in diagnostics)
819848
{
820-
var diagnosticsId = diagnostics[i].Id;
821-
var location = diagnostics[i].Location;
849+
var location = diagnostic.Location;
822850

823-
builder.Append("// ").AppendLine(diagnostics[i].ToString());
851+
builder.Append("// ").AppendLine(diagnostic.ToString());
824852

825-
var applicableAnalyzer = analyzers.FirstOrDefault(a => a.SupportedDiagnostics.Any(dd => dd.Id == diagnosticsId));
853+
var applicableAnalyzer = analyzers.FirstOrDefault(a => a.SupportedDiagnostics.Any(dd => dd.Id == diagnostic.Id));
826854
if (applicableAnalyzer != null)
827855
{
828856
var analyzerType = applicableAnalyzer.GetType();
829-
var rule = location != Location.None && location.IsInSource && applicableAnalyzer.SupportedDiagnostics.Length == 1 ? string.Empty : $"{analyzerType.Name}.{diagnosticsId}";
857+
var rule = location != Location.None && location.IsInSource && applicableAnalyzer.SupportedDiagnostics.Length == 1 ? string.Empty : $"{analyzerType.Name}.{diagnostic.Id}";
830858

831859
if (location == Location.None || !location.IsInSource)
832860
{
@@ -841,11 +869,11 @@ private static string FormatDiagnostics(ImmutableArray<DiagnosticAnalyzer> analy
841869
else
842870
{
843871
builder.Append(
844-
diagnostics[i].Severity switch
872+
diagnostic.Severity switch
845873
{
846-
DiagnosticSeverity.Error => $"{nameof(DiagnosticResult)}.{nameof(DiagnosticResult.CompilerError)}(\"{diagnostics[i].Id}\")",
847-
DiagnosticSeverity.Warning => $"{nameof(DiagnosticResult)}.{nameof(DiagnosticResult.CompilerWarning)}(\"{diagnostics[i].Id}\")",
848-
var severity => $"new {nameof(DiagnosticResult)}(\"{diagnostics[i].Id}\", {nameof(DiagnosticSeverity)}.{severity})",
874+
DiagnosticSeverity.Error => $"{nameof(DiagnosticResult)}.{nameof(DiagnosticResult.CompilerError)}(\"{diagnostic.Id}\")",
875+
DiagnosticSeverity.Warning => $"{nameof(DiagnosticResult)}.{nameof(DiagnosticResult.CompilerWarning)}(\"{diagnostic.Id}\")",
876+
var severity => $"new {nameof(DiagnosticResult)}(\"{diagnostic.Id}\", {nameof(DiagnosticSeverity)}.{severity})",
849877
});
850878
}
851879

@@ -855,22 +883,35 @@ private static string FormatDiagnostics(ImmutableArray<DiagnosticAnalyzer> analy
855883
}
856884
else
857885
{
858-
AppendLocation(diagnostics[i].Location);
859-
foreach (var additionalLocation in diagnostics[i].AdditionalLocations)
886+
// The unnecessary code designator is ignored for the primary diagnostic location.
887+
AppendLocation(diagnostic.Location, isUnnecessary: false);
888+
889+
int[] unnecessaryIndices = { };
890+
if (diagnostic.Properties.TryGetValue(WellKnownDiagnosticTags.Unnecessary, out var encodedUnnecessaryLocations))
891+
{
892+
var match = EncodedIndicesSyntax.Match(encodedUnnecessaryLocations);
893+
if (match.Success)
894+
{
895+
unnecessaryIndices = match.Groups["Index"].Captures.OfType<Capture>().Select(capture => int.Parse(capture.Value)).ToArray();
896+
}
897+
}
898+
899+
for (var i = 0; i < diagnostic.AdditionalLocations.Count; i++)
860900
{
861-
AppendLocation(additionalLocation);
901+
var additionalLocation = diagnostic.AdditionalLocations[i];
902+
AppendLocation(additionalLocation, isUnnecessary: unnecessaryIndices.Contains(i));
862903
}
863904
}
864905

865-
var arguments = diagnostics[i].Arguments();
906+
var arguments = diagnostic.Arguments();
866907
if (arguments.Count > 0)
867908
{
868909
builder.Append($".{nameof(DiagnosticResult.WithArguments)}(");
869910
builder.Append(string.Join(", ", arguments.Select(a => "\"" + a?.ToString() + "\"")));
870911
builder.Append(")");
871912
}
872913

873-
if (diagnostics[i].IsSuppressed())
914+
if (diagnostic.IsSuppressed())
874915
{
875916
builder.Append($".{nameof(DiagnosticResult.WithIsSuppressed)}(true)");
876917
}
@@ -881,13 +922,16 @@ private static string FormatDiagnostics(ImmutableArray<DiagnosticAnalyzer> analy
881922
return builder.ToString();
882923

883924
// Local functions
884-
void AppendLocation(Location location)
925+
void AppendLocation(Location location, bool isUnnecessary)
885926
{
886927
var lineSpan = location.GetLineSpan();
887928
var pathString = location.IsInSource && lineSpan.Path == defaultFilePath ? string.Empty : $"\"{lineSpan.Path}\", ";
888929
var linePosition = lineSpan.StartLinePosition;
889930
var endLinePosition = lineSpan.EndLinePosition;
890-
builder.Append($".WithSpan({pathString}{linePosition.Line + 1}, {linePosition.Character + 1}, {endLinePosition.Line + 1}, {endLinePosition.Character + 1})");
931+
var unnecessaryArgument = isUnnecessary
932+
? $", {nameof(DiagnosticLocationOptions)}.{nameof(DiagnosticLocationOptions.UnnecessaryCode)}"
933+
: string.Empty;
934+
builder.Append($".WithSpan({pathString}{linePosition.Line + 1}, {linePosition.Character + 1}, {endLinePosition.Line + 1}, {endLinePosition.Character + 1}{unnecessaryArgument})");
891935
}
892936
}
893937

src/Microsoft.CodeAnalysis.Testing/Microsoft.CodeAnalysis.Analyzer.Testing/DiagnosticLocationOptions.cs

+5
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,10 @@ public enum DiagnosticLocationOptions
3737
/// </list>
3838
/// </summary>
3939
InterpretAsMarkupKey = 2,
40+
41+
/// <summary>
42+
/// The diagnostic location is marked as unnecessary code.
43+
/// </summary>
44+
UnnecessaryCode = 4,
4045
}
4146
}

src/Microsoft.CodeAnalysis.Testing/Microsoft.CodeAnalysis.Analyzer.Testing/PublicAPI.Unshipped.txt

+1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Microsoft.CodeAnalysis.Testing.DiagnosticLocationOptions
6363
Microsoft.CodeAnalysis.Testing.DiagnosticLocationOptions.IgnoreLength = 1 -> Microsoft.CodeAnalysis.Testing.DiagnosticLocationOptions
6464
Microsoft.CodeAnalysis.Testing.DiagnosticLocationOptions.InterpretAsMarkupKey = 2 -> Microsoft.CodeAnalysis.Testing.DiagnosticLocationOptions
6565
Microsoft.CodeAnalysis.Testing.DiagnosticLocationOptions.None = 0 -> Microsoft.CodeAnalysis.Testing.DiagnosticLocationOptions
66+
Microsoft.CodeAnalysis.Testing.DiagnosticLocationOptions.UnnecessaryCode = 4 -> Microsoft.CodeAnalysis.Testing.DiagnosticLocationOptions
6667
Microsoft.CodeAnalysis.Testing.DiagnosticOptions
6768
Microsoft.CodeAnalysis.Testing.DiagnosticOptions.IgnoreAdditionalLocations = 1 -> Microsoft.CodeAnalysis.Testing.DiagnosticOptions
6869
Microsoft.CodeAnalysis.Testing.DiagnosticOptions.IgnoreSeverity = 2 -> Microsoft.CodeAnalysis.Testing.DiagnosticOptions

tests/Microsoft.CodeAnalysis.Testing/Microsoft.CodeAnalysis.Analyzer.Testing.UnitTests/DiagnosticLocationTests.cs

+163
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,120 @@ Expected diagnostic to start at column "18" was actually at column "17"
245245
new DefaultVerifier().EqualOrDiff(expected, exception.Message);
246246
}
247247

248+
[Fact]
249+
public async Task TestAdditionalUnnecessaryLocations()
250+
{
251+
await new CSharpAnalyzerTest<HighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer>
252+
{
253+
TestCode = @"class TestClass {|#0:{|} {|#1:}|}",
254+
ExpectedDiagnostics =
255+
{
256+
Diagnostic<HighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer>().WithLocation(0).WithLocation(1, DiagnosticLocationOptions.UnnecessaryCode),
257+
},
258+
}.RunAsync();
259+
}
260+
261+
[Fact]
262+
public async Task TestAdditionalUnnecessaryLocationsIgnored()
263+
{
264+
await new CSharpAnalyzerTest<HighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer>
265+
{
266+
TestCode = @"class TestClass {|#0:{|} {|#1:}|}",
267+
ExpectedDiagnostics =
268+
{
269+
Diagnostic<HighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer>().WithLocation(0).WithOptions(DiagnosticOptions.IgnoreAdditionalLocations),
270+
},
271+
}.RunAsync();
272+
}
273+
274+
[Fact]
275+
public async Task TestAdditionalUnnecessaryLocationRequiresExpectedMarkedUnnecessary()
276+
{
277+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
278+
{
279+
await new CSharpAnalyzerTest<HighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer>
280+
{
281+
TestCode = @"class TestClass {|#0:{|} {|#1:}|}",
282+
ExpectedDiagnostics =
283+
{
284+
Diagnostic<HighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer>().WithLocation(0).WithLocation(1),
285+
},
286+
}.RunAsync();
287+
});
288+
289+
var expected =
290+
"""
291+
Expected diagnostic additional location index "0" to not be marked unnecessary, but was instead marked unnecessary.
292+
""".ReplaceLineEndings();
293+
new DefaultVerifier().EqualOrDiff(expected, exception.Message);
294+
}
295+
296+
[Fact]
297+
public async Task TestAdditionalUnnecessaryLocationRequiresDescriptorMarkedUnnecessary()
298+
{
299+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
300+
{
301+
await new CSharpAnalyzerTest<HighlightBraceSpanWithEndMarkedUnnecessaryButDescriptorNotAnalyzer>
302+
{
303+
TestCode = @"class TestClass {|#0:{|} {|#1:}|}",
304+
ExpectedDiagnostics =
305+
{
306+
Diagnostic<HighlightBraceSpanWithEndMarkedUnnecessaryButDescriptorNotAnalyzer>().WithLocation(0).WithLocation(1, DiagnosticLocationOptions.UnnecessaryCode),
307+
},
308+
}.RunAsync();
309+
});
310+
311+
var expected =
312+
"""
313+
Diagnostic reported extended unnecessary locations, but the descriptor is not marked as unnecessary code.
314+
""".ReplaceLineEndings();
315+
new DefaultVerifier().EqualOrDiff(expected, exception.Message);
316+
}
317+
318+
[Fact]
319+
public async Task TestAdditionalUnnecessaryLocationNotJsonArray()
320+
{
321+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
322+
{
323+
await new CSharpAnalyzerTest<HighlightBraceSpanWithEndMarkedUnnecessaryNotJsonArrayAnalyzer>
324+
{
325+
TestCode = @"class TestClass {|#0:{|} {|#1:}|}",
326+
ExpectedDiagnostics =
327+
{
328+
Diagnostic<HighlightBraceSpanWithEndMarkedUnnecessaryNotJsonArrayAnalyzer>().WithLocation(0).WithLocation(1, DiagnosticLocationOptions.UnnecessaryCode),
329+
},
330+
}.RunAsync();
331+
});
332+
333+
var expected =
334+
"""
335+
Expected encoded unnecessary locations to be a valid JSON array of non-negative integers: Text
336+
""".ReplaceLineEndings();
337+
new DefaultVerifier().EqualOrDiff(expected, exception.Message);
338+
}
339+
340+
[Fact]
341+
public async Task TestAdditionalUnnecessaryLocationIndexOutOfRange()
342+
{
343+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
344+
{
345+
await new CSharpAnalyzerTest<HighlightBraceSpanWithEndMarkedUnnecessaryIndexOutOfRangeAnalyzer>
346+
{
347+
TestCode = @"class TestClass {|#0:{|} {|#1:}|}",
348+
ExpectedDiagnostics =
349+
{
350+
Diagnostic<HighlightBraceSpanWithEndMarkedUnnecessaryIndexOutOfRangeAnalyzer>().WithLocation(0).WithLocation(1, DiagnosticLocationOptions.UnnecessaryCode),
351+
},
352+
}.RunAsync();
353+
});
354+
355+
var expected =
356+
"""
357+
All unnecessary indices in the diagnostic must be valid indices in AdditionalLocations [0-1): [1]
358+
""".ReplaceLineEndings();
359+
new DefaultVerifier().EqualOrDiff(expected, exception.Message);
360+
}
361+
248362
[DiagnosticAnalyzer(LanguageNames.CSharp)]
249363
private class HighlightBracePositionAnalyzer : AbstractHighlightBracesAnalyzer
250364
{
@@ -264,6 +378,55 @@ protected override Diagnostic CreateDiagnostic(SyntaxToken token)
264378
}
265379
}
266380

381+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
382+
private class HighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer : AbstractHighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer
383+
{
384+
}
385+
386+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
387+
private class HighlightBraceSpanWithEndMarkedUnnecessaryNotJsonArrayAnalyzer : AbstractHighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer
388+
{
389+
protected override string UnnecessaryLocationsValue => "Text";
390+
}
391+
392+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
393+
private class HighlightBraceSpanWithEndMarkedUnnecessaryIndexOutOfRangeAnalyzer : AbstractHighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer
394+
{
395+
protected override string UnnecessaryLocationsValue => "[1]";
396+
}
397+
398+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
399+
private class HighlightBraceSpanWithEndMarkedUnnecessaryButDescriptorNotAnalyzer : AbstractHighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer
400+
{
401+
public HighlightBraceSpanWithEndMarkedUnnecessaryButDescriptorNotAnalyzer()
402+
: base(customTags: new string[0])
403+
{
404+
}
405+
}
406+
407+
private abstract class AbstractHighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer : AbstractHighlightBracesAnalyzer
408+
{
409+
public AbstractHighlightBraceSpanWithEndMarkedUnnecessaryAnalyzer(string[]? customTags = null)
410+
: base(customTags: customTags ?? new[] { WellKnownDiagnosticTags.Unnecessary })
411+
{
412+
}
413+
414+
protected virtual string UnnecessaryLocationsValue => "[0]";
415+
416+
protected override Diagnostic CreateDiagnostic(SyntaxToken token)
417+
{
418+
var endLocation = token.Parent switch
419+
{
420+
CSharp.Syntax.ClassDeclarationSyntax classDeclaration => classDeclaration.CloseBraceToken.GetLocation(),
421+
_ => throw new NotSupportedException(),
422+
};
423+
424+
var additionalLocations = new[] { endLocation };
425+
var properties = ImmutableDictionary.Create<string, string?>().Add(WellKnownDiagnosticTags.Unnecessary, UnnecessaryLocationsValue);
426+
return CodeAnalysis.Diagnostic.Create(Descriptor, token.GetLocation(), additionalLocations, properties);
427+
}
428+
}
429+
267430
[DiagnosticAnalyzer(LanguageNames.CSharp)]
268431
private class ReportCompilationDiagnosticAnalyzer : DiagnosticAnalyzer
269432
{

0 commit comments

Comments
 (0)