Skip to content

Commit fce5bf8

Browse files
authored
Merge pull request #1009 from CommunityToolkit/dev/consistent-compilation-diagnostic-location
Emit consistent locations for 'MVVMTK0051' diagnostics
2 parents 3533b97 + 421ad36 commit fce5bf8

File tree

2 files changed

+117
-6
lines changed

2 files changed

+117
-6
lines changed

src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/Analyzers/WinRTObservablePropertyOnFieldsIsNotAotCompatibleAnalyzer.cs

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
#if ROSLYN_4_12_0_OR_GREATER
66

7+
using System;
78
using System.Collections.Immutable;
9+
using System.Diagnostics.CodeAnalysis;
810
using System.Linq;
911
using System.Threading;
1012
using CommunityToolkit.Mvvm.SourceGenerators.Extensions;
@@ -68,11 +70,11 @@ public override void Initialize(AnalysisContext context)
6870
fieldSymbol.ContainingType,
6971
fieldSymbol.Name));
7072

71-
// Notify that we did produce at least one diagnostic. Note: callbacks can run in parallel, so the order
72-
// is not guaranteed. As such, there's no point in using an interlocked compare exchange operation here,
73-
// since we couldn't rely on the value being written actually being the "first" occurrence anyway.
74-
// So we can just do a normal volatile read for better performance.
75-
Volatile.Write(ref firstObservablePropertyAttribute, observablePropertyAttribute);
73+
// Track the attribute data to use as target for the diagnostic. This method takes care of effectively
74+
// sorting all incoming values, so that the final one is deterministic across runs. This ensures that
75+
// the actual location will be the same across recompilations, instead of jumping around all over the
76+
// place. This also makes it possible to more easily suppress it, since its location would not change.
77+
SetOrUpdateAttributeDataBySourceLocation(ref firstObservablePropertyAttribute, observablePropertyAttribute);
7678
}
7779
}, SymbolKind.Field);
7880

@@ -95,6 +97,59 @@ public override void Initialize(AnalysisContext context)
9597
});
9698
});
9799
}
100+
101+
/// <summary>
102+
/// Sets or updates the <see cref="AttributeData"/> instance to use for compilation diagnostics, sorting by source location.
103+
/// </summary>
104+
/// <param name="oldAttributeDataLocation">The location of the previous value to potentially overwrite.</param>
105+
/// <param name="newAttributeData">Thew new <see cref="AttributeData"/> instance.</param>
106+
private static void SetOrUpdateAttributeDataBySourceLocation([NotNull] ref AttributeData? oldAttributeDataLocation, AttributeData newAttributeData)
107+
{
108+
bool hasReplacedOriginalValue;
109+
110+
do
111+
{
112+
AttributeData? oldAttributeData = Volatile.Read(ref oldAttributeDataLocation);
113+
114+
// If the old attribute data is null, it means this is the first time we called this method with a new
115+
// attribute candidate. In that case, there is nothing to check: we should always store the new instance.
116+
if (oldAttributeData is not null)
117+
{
118+
// Sort by file paths, alphabetically
119+
int filePathRelativeSortIndex = string.Compare(
120+
newAttributeData.ApplicationSyntaxReference?.SyntaxTree.FilePath,
121+
oldAttributeData.ApplicationSyntaxReference?.SyntaxTree.FilePath,
122+
StringComparison.OrdinalIgnoreCase);
123+
124+
// Also sort by location (this is a tie-breaker if two values are from the same file)
125+
bool isTextSpanLowerSorted =
126+
(newAttributeData.ApplicationSyntaxReference?.Span.Start ?? 0) <
127+
(oldAttributeData.ApplicationSyntaxReference?.Span.Start ?? 0);
128+
129+
// The new candidate can be dropped if it's from a file that's alphabetically sorted after
130+
// the old value, or whether the location is after the previous one, within the same file.
131+
if (filePathRelativeSortIndex == 1 || (filePathRelativeSortIndex == 0 && !isTextSpanLowerSorted))
132+
{
133+
break;
134+
}
135+
}
136+
137+
// Attempt to actually replace the old value, without taking locks
138+
AttributeData? originalValue = Interlocked.CompareExchange(
139+
location1: ref oldAttributeDataLocation,
140+
value: newAttributeData,
141+
comparand: oldAttributeData);
142+
143+
// This call might have raced against other threads, since all symbol actions can run in parallel.
144+
// If the original value is the old value we read at the start of the method, it means no other
145+
// thread raced against this one, so we are done. If it's different, then we failed to write the
146+
// new candidate. We can discard the work done in this iteration and simply try again.
147+
hasReplacedOriginalValue = oldAttributeData == originalValue;
148+
}
149+
while (!hasReplacedOriginalValue);
150+
#pragma warning disable CS8777 // The loop always ensures that 'oldAttributeDataLocation' is not null on exit
151+
}
152+
#pragma warning restore CS8777
98153
}
99154

100155
#endif

tests/CommunityToolkit.Mvvm.SourceGenerators.Roslyn4120.UnitTests/Test_SourceGeneratorsDiagnostics.cs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ namespace MyApp
468468
{
469469
public partial class SampleViewModel : ObservableObject
470470
{
471-
[{|MVVMTK0051:ObservableProperty|}]
471+
[{|MVVMTK0051:ObservableProperty|}]
472472
private string {|MVVMTK0045:name|};
473473
}
474474
}
@@ -480,6 +480,62 @@ await CSharpAnalyzerWithLanguageVersionTest<WinRTObservablePropertyOnFieldsIsNot
480480
editorconfig: [("_MvvmToolkitIsUsingWindowsRuntimePack", true), ("CsWinRTAotOptimizerEnabled", "auto")]);
481481
}
482482

483+
[TestMethod]
484+
public async Task WinRTObservablePropertyOnFieldsIsNotAotCompatibleAnalyzer_TargetingWindows_CsWinRTAotOptimizerEnabled_Auto_NotCSharpPreview_MultipleFields_Warns_WithCompilationWarning_ConsistentLocation()
485+
{
486+
const string source = """
487+
using CommunityToolkit.Mvvm.ComponentModel;
488+
489+
namespace MyApp
490+
{
491+
public partial class SampleViewModel : ObservableObject
492+
{
493+
[{|MVVMTK0051:ObservableProperty|}]
494+
private string {|MVVMTK0045:f1|};
495+
496+
[ObservableProperty]
497+
private string {|MVVMTK0045:f2|};
498+
499+
[ObservableProperty]
500+
private string {|MVVMTK0045:f3|};
501+
}
502+
503+
public partial class OtherViewModel : ObservableObject
504+
{
505+
[ObservableProperty]
506+
private string {|MVVMTK0045:f1|};
507+
508+
[ObservableProperty]
509+
private string {|MVVMTK0045:f2|};
510+
511+
[ObservableProperty]
512+
private string {|MVVMTK0045:f3|};
513+
}
514+
}
515+
516+
namespace OtherNamespace
517+
{
518+
public partial class YetAnotherViewModel : ObservableObject
519+
{
520+
[ObservableProperty]
521+
private string {|MVVMTK0045:f1|};
522+
523+
[ObservableProperty]
524+
private string {|MVVMTK0045:f2|};
525+
}
526+
}
527+
""";
528+
529+
// This test is non deterministic, so run it 10 times to ensure the likelihood of it passing just by luck is almost 0
530+
for (int i = 0; i < 10; i++)
531+
{
532+
await CSharpAnalyzerWithLanguageVersionTest<WinRTObservablePropertyOnFieldsIsNotAotCompatibleAnalyzer>.VerifyAnalyzerAsync(
533+
source,
534+
LanguageVersion.CSharp12,
535+
editorconfig: [("_MvvmToolkitIsUsingWindowsRuntimePack", true), ("CsWinRTAotOptimizerEnabled", "auto")]);
536+
}
537+
}
538+
483539
[TestMethod]
484540
public async Task WinRTObservablePropertyOnFieldsIsNotAotCompatibleAnalyzer_TargetingWindows_CsWinRTAotOptimizerEnabled_True_NoXaml_Level1_DoesNotWarn()
485541
{

0 commit comments

Comments
 (0)