Skip to content

Commit 8b708f6

Browse files
committed
Added initial version of ObservablePropertyAttribute
1 parent d51b0ab commit 8b708f6

File tree

5 files changed

+368
-0
lines changed

5 files changed

+368
-0
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.CSharp;
9+
using Microsoft.CodeAnalysis.CSharp.Syntax;
10+
11+
namespace Microsoft.Toolkit.Mvvm.SourceGenerators
12+
{
13+
/// <inheritdoc cref="ObservablePropertyGenerator"/>
14+
public sealed partial class ObservablePropertyGenerator
15+
{
16+
/// <summary>
17+
/// An <see cref="ISyntaxContextReceiver"/> that selects candidate nodes to process.
18+
/// </summary>
19+
private sealed class SyntaxReceiver : ISyntaxContextReceiver
20+
{
21+
/// <summary>
22+
/// The list of info gathered during exploration.
23+
/// </summary>
24+
private readonly List<Item> gatheredInfo = new();
25+
26+
/// <summary>
27+
/// Gets the collection of gathered info to process.
28+
/// </summary>
29+
public IReadOnlyCollection<Item> GatheredInfo => this.gatheredInfo;
30+
31+
/// <inheritdoc/>
32+
public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
33+
{
34+
if (context.Node is FieldDeclarationSyntax { AttributeLists: { Count: > 0 } } fieldDeclaration &&
35+
context.SemanticModel.Compilation.GetTypeByMetadataName("Microsoft.Toolkit.Mvvm.ComponentModel.ObservablePropertyAttribute") is INamedTypeSymbol attributeSymbol)
36+
{
37+
foreach (VariableDeclaratorSyntax variableDeclarator in fieldDeclaration.Declaration.Variables)
38+
{
39+
if (context.SemanticModel.GetDeclaredSymbol(variableDeclarator) is IFieldSymbol fieldSymbol &&
40+
fieldSymbol.GetAttributes().FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attributeSymbol)) is AttributeData attributeData &&
41+
attributeData.ApplicationSyntaxReference is SyntaxReference syntaxReference &&
42+
syntaxReference.GetSyntax() is AttributeSyntax attributeSyntax)
43+
{
44+
this.gatheredInfo.Add(new Item(variableDeclarator, fieldSymbol, attributeSyntax, attributeData));
45+
}
46+
}
47+
}
48+
}
49+
50+
/// <summary>
51+
/// A model for a group of item representing a discovered type to process.
52+
/// </summary>
53+
/// <param name="FieldDeclarator">The <see cref="VariableDeclaratorSyntax"/> instance for the target field variable declaration.</param>
54+
/// <param name="FieldSymbol">The <see cref="IFieldSymbol"/> instance for <paramref name="FieldDeclarator"/>.</param>
55+
/// <param name="AttributeSyntax">The <see cref="AttributeSyntax"/> instance for the target attribute over <paramref name="FieldDeclarator"/>.</param>
56+
/// <param name="AttributeData">The <see cref="AttributeData"/> instance for <paramref name="AttributeSyntax"/>.</param>
57+
public sealed record Item(
58+
VariableDeclaratorSyntax FieldDeclarator,
59+
IFieldSymbol FieldSymbol,
60+
AttributeSyntax AttributeSyntax,
61+
AttributeData AttributeData);
62+
}
63+
}
64+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Collections.Generic;
6+
using System.Diagnostics.Contracts;
7+
using System.Linq;
8+
using System.Text;
9+
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.CSharp;
11+
using Microsoft.CodeAnalysis.CSharp.Syntax;
12+
using Microsoft.CodeAnalysis.Text;
13+
using Microsoft.Toolkit.Mvvm.ComponentModel;
14+
using Microsoft.Toolkit.Mvvm.SourceGenerators.Extensions;
15+
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
16+
using static Microsoft.CodeAnalysis.SymbolDisplayTypeQualificationStyle;
17+
18+
namespace Microsoft.Toolkit.Mvvm.SourceGenerators
19+
{
20+
/// <summary>
21+
/// A source generator for the <see cref="ObservablePropertyAttribute"/> type.
22+
/// </summary>
23+
[Generator]
24+
public sealed partial class ObservablePropertyGenerator : ISourceGenerator
25+
{
26+
/// <inheritdoc/>
27+
public void Initialize(GeneratorInitializationContext context)
28+
{
29+
context.RegisterForSyntaxNotifications(static () => new SyntaxReceiver());
30+
}
31+
32+
/// <inheritdoc/>
33+
public void Execute(GeneratorExecutionContext context)
34+
{
35+
// Get the syntax receiver with the candidate nodes
36+
if (context.SyntaxContextReceiver is not SyntaxReceiver syntaxReceiver ||
37+
syntaxReceiver.GatheredInfo.Count == 0)
38+
{
39+
return;
40+
}
41+
42+
foreach (var items in syntaxReceiver.GatheredInfo.GroupBy<SyntaxReceiver.Item, INamedTypeSymbol>(static item => item.FieldSymbol.ContainingType, SymbolEqualityComparer.Default))
43+
{
44+
if (items.Key.DeclaringSyntaxReferences.Length > 0 &&
45+
items.Key.DeclaringSyntaxReferences.First().GetSyntax() is ClassDeclarationSyntax classDeclaration)
46+
{
47+
OnExecute(context, classDeclaration, items.Key, items);
48+
}
49+
}
50+
}
51+
52+
/// <summary>
53+
/// Processes a given target type.
54+
/// </summary>
55+
/// <param name="context">The input <see cref="GeneratorExecutionContext"/> instance to use.</param>
56+
/// <param name="classDeclaration">The <see cref="ClassDeclarationSyntax"/> node to process.</param>
57+
/// <param name="classDeclarationSymbol">The <see cref="INamedTypeSymbol"/> for <paramref name="classDeclaration"/>.</param>
58+
/// <param name="items">The sequence of fields to process.</param>
59+
private static void OnExecute(
60+
GeneratorExecutionContext context,
61+
ClassDeclarationSyntax classDeclaration,
62+
INamedTypeSymbol classDeclarationSymbol,
63+
IEnumerable<SyntaxReceiver.Item> items)
64+
{
65+
// Create the class declaration for the user type. This will produce a tree as follows:
66+
//
67+
// <MODIFIERS> <CLASS_NAME>
68+
// {
69+
// <MEMBERS>
70+
// }
71+
var classDeclarationSyntax =
72+
ClassDeclaration(classDeclarationSymbol.Name)
73+
.WithModifiers(classDeclaration.Modifiers)
74+
.AddMembers(items.Select(static item => CreatePropertyDeclaration(item.FieldSymbol)).ToArray());
75+
76+
TypeDeclarationSyntax typeDeclarationSyntax = classDeclarationSyntax;
77+
78+
// Add all parent types in ascending order, if any
79+
foreach (var parentType in classDeclaration.Ancestors().OfType<TypeDeclarationSyntax>())
80+
{
81+
typeDeclarationSyntax = parentType
82+
.WithMembers(SingletonList<MemberDeclarationSyntax>(typeDeclarationSyntax))
83+
.WithConstraintClauses(List<TypeParameterConstraintClauseSyntax>())
84+
.WithBaseList(null)
85+
.WithAttributeLists(List<AttributeListSyntax>())
86+
.WithoutTrivia();
87+
}
88+
89+
// Create the compilation unit with the namespace and target member.
90+
// From this, we can finally generate the source code to output.
91+
var namespaceName = classDeclarationSymbol.ContainingNamespace.ToDisplayString(new(typeQualificationStyle: NameAndContainingTypesAndNamespaces));
92+
93+
// Create the final compilation unit to generate (with leading trivia)
94+
var source =
95+
CompilationUnit().AddUsings(
96+
UsingDirective(IdentifierName("System.Collections.Generic")).WithLeadingTrivia(TriviaList(
97+
Comment("// Licensed to the .NET Foundation under one or more agreements."),
98+
Comment("// The .NET Foundation licenses this file to you under the MIT license."),
99+
Comment("// See the LICENSE file in the project root for more information."),
100+
Trivia(PragmaWarningDirectiveTrivia(Token(SyntaxKind.DisableKeyword), true)))),
101+
UsingDirective(IdentifierName("System.Diagnostics")),
102+
UsingDirective(IdentifierName("System.Diagnostics.CodeAnalysis"))).AddMembers(
103+
NamespaceDeclaration(IdentifierName(namespaceName))
104+
.AddMembers(typeDeclarationSyntax))
105+
.NormalizeWhitespace()
106+
.ToFullString();
107+
108+
// Add the partial type
109+
context.AddSource($"[{typeof(ObservablePropertyAttribute).Name}]_[{classDeclarationSymbol.GetFullMetadataNameForFileName()}].cs", SourceText.From(source, Encoding.UTF8));
110+
}
111+
112+
/// <summary>
113+
/// Creates a <see cref="PropertyDeclarationSyntax"/> instance for a specified field.
114+
/// </summary>
115+
/// <param name="fieldSymbol">The input <see cref="IFieldSymbol"/> instance to process.</param>
116+
/// <returns>A generated <see cref="PropertyDeclarationSyntax"/> instance for the input field.</returns>
117+
[Pure]
118+
private static PropertyDeclarationSyntax CreatePropertyDeclaration(IFieldSymbol fieldSymbol)
119+
{
120+
// Get the field type and the target property name
121+
string
122+
typeName = fieldSymbol.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
123+
propertyName = fieldSymbol.Name;
124+
125+
if (propertyName.StartsWith("m_"))
126+
{
127+
propertyName = propertyName.Substring(2);
128+
}
129+
else if (propertyName.StartsWith("_"))
130+
{
131+
propertyName = propertyName.TrimStart('_');
132+
}
133+
134+
propertyName = $"{char.ToUpper(propertyName[0])}{propertyName.Substring(1)}";
135+
136+
BlockSyntax setter = Block();
137+
138+
// Add the OnPropertyChanging() call if necessary
139+
setter = setter.AddStatements(ExpressionStatement(InvocationExpression(IdentifierName("OnPropertyChanging"))));
140+
141+
// Add the following statements:
142+
//
143+
// <FIELD_NAME> = value;
144+
// OnPropertyChanged();
145+
setter = setter.AddStatements(
146+
ExpressionStatement(
147+
AssignmentExpression(
148+
SyntaxKind.SimpleAssignmentExpression,
149+
IdentifierName(fieldSymbol.Name),
150+
IdentifierName("value"))),
151+
ExpressionStatement(InvocationExpression(IdentifierName("OnPropertyChanged"))));
152+
153+
// Construct the generated property as follows:
154+
//
155+
// [DebuggerNonUserCode]
156+
// [ExcludeFromCodeCoverage]
157+
// public <FIELD_TYPE> <PROPERTY_NAME>
158+
// {
159+
// get => <FIELD_NAME>;
160+
// set
161+
// {
162+
// if (!EqualityComparer<<FIELD_TYPE>>.Default.Equals(<FIELD_NAME>, value))
163+
// {
164+
// OnPropertyChanging(); // Optional
165+
// <FIELD_NAME> = value;
166+
// OnPropertyChanged();
167+
// }
168+
// }
169+
// }
170+
return
171+
PropertyDeclaration(IdentifierName(typeName), Identifier(propertyName))
172+
.AddAttributeLists(
173+
AttributeList(SingletonSeparatedList(Attribute(IdentifierName("DebuggerNonUserCode")))),
174+
AttributeList(SingletonSeparatedList(Attribute(IdentifierName("ExcludeFromCodeCoverage")))))
175+
.AddModifiers(Token(SyntaxKind.PublicKeyword))
176+
.AddAccessorListAccessors(
177+
AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
178+
.WithExpressionBody(ArrowExpressionClause(IdentifierName(fieldSymbol.Name)))
179+
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken)),
180+
AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
181+
.AddBodyStatements(
182+
IfStatement(
183+
PrefixUnaryExpression(
184+
SyntaxKind.LogicalNotExpression,
185+
InvocationExpression(
186+
MemberAccessExpression(
187+
SyntaxKind.SimpleMemberAccessExpression,
188+
MemberAccessExpression(
189+
SyntaxKind.SimpleMemberAccessExpression,
190+
GenericName(Identifier("EqualityComparer"))
191+
.AddTypeArgumentListArguments(IdentifierName(typeName)),
192+
IdentifierName("Default")),
193+
IdentifierName("Equals")))
194+
.AddArgumentListArguments(
195+
Argument(IdentifierName(fieldSymbol.Name)),
196+
Argument(IdentifierName("value")))),
197+
setter)));
198+
}
199+
}
200+
}

Microsoft.Toolkit.Mvvm.SourceGenerators/Microsoft.Toolkit.Mvvm.SourceGenerators.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<ItemGroup>
1717
<Compile Include="..\Microsoft.Toolkit.Mvvm\ComponentModel\Attributes\INotifyPropertyChangedAttribute.cs" Link="Microsoft.Toolkit.Mvvm\ComponentModel\Attributes\INotifyPropertyChangedAttribute.cs" />
1818
<Compile Include="..\Microsoft.Toolkit.Mvvm\ComponentModel\Attributes\ObservableObjectAttribute.cs" Link="Microsoft.Toolkit.Mvvm\ComponentModel\Attributes\ObservableObjectAttribute.cs" />
19+
<Compile Include="..\Microsoft.Toolkit.Mvvm\ComponentModel\Attributes\ObservablePropertyAttribute.cs" Link="Microsoft.Toolkit.Mvvm\ComponentModel\Attributes\ObservablePropertyAttribute.cs" />
1920
<Compile Include="..\Microsoft.Toolkit.Mvvm\ComponentModel\Attributes\ObservableRecipientAttribute.cs" Link="Microsoft.Toolkit.Mvvm\ComponentModel\Attributes\ObservableRecipientAttribute.cs" />
2021
</ItemGroup>
2122

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
#pragma warning disable CS1574
6+
7+
using System;
8+
using System.ComponentModel;
9+
10+
namespace Microsoft.Toolkit.Mvvm.ComponentModel
11+
{
12+
/// <summary>
13+
/// An attribute that indicates that a given field should be wrapped by a generated observable property.
14+
/// In order to use this attribute, the containing type has to implement the <see cref="INotifyPropertyChanged"/> interface
15+
/// and expose a method with the same signature as <see cref="ObservableObject.OnPropertyChanged(string?)"/>. If the containing
16+
/// type also implements the <see cref="INotifyPropertyChanging"/> interface and exposes a method with the same signature as
17+
/// <see cref="ObservableObject.OnPropertyChanging(string?)"/>, then this method will be invoked as well by the property setter.
18+
/// <para>
19+
/// This attribute can be used as follows:
20+
/// <code>
21+
/// partial class MyViewModel : ObservableObject
22+
/// {
23+
/// [ObservableProperty]
24+
/// private string name;
25+
/// }
26+
/// </code>
27+
/// </para>
28+
/// And with this, code analogous to this will be generated:
29+
/// <code>
30+
/// partial class MyViewModel
31+
/// {
32+
/// public string Name
33+
/// {
34+
/// get => name;
35+
/// set => SetProperty(ref name, value);
36+
/// }
37+
/// }
38+
/// </code>
39+
/// </summary>
40+
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = false)]
41+
public sealed class ObservablePropertyAttribute : Attribute
42+
{
43+
}
44+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.ComponentModel;
6+
using Microsoft.Toolkit.Mvvm.ComponentModel;
7+
using Microsoft.VisualStudio.TestTools.UnitTesting;
8+
9+
namespace UnitTests.Mvvm
10+
{
11+
[TestClass]
12+
public partial class Test_ObservablePropertyAttribute
13+
{
14+
[TestCategory("Mvvm")]
15+
[TestMethod]
16+
public void Test_ObservablePropertyAttribute_Events()
17+
{
18+
var model = new SampleModel();
19+
20+
(PropertyChangingEventArgs, int) changing = default;
21+
(PropertyChangedEventArgs, int) changed = default;
22+
23+
model.PropertyChanging += (s, e) =>
24+
{
25+
Assert.IsNull(changing.Item1);
26+
Assert.IsNull(changed.Item1);
27+
Assert.AreSame(model, s);
28+
Assert.IsNotNull(s);
29+
Assert.IsNotNull(e);
30+
31+
changing = (e, model.Data);
32+
};
33+
34+
model.PropertyChanged += (s, e) =>
35+
{
36+
Assert.IsNotNull(changing.Item1);
37+
Assert.IsNull(changed.Item1);
38+
Assert.AreSame(model, s);
39+
Assert.IsNotNull(s);
40+
Assert.IsNotNull(e);
41+
42+
changed = (e, model.Data);
43+
};
44+
45+
model.Data = 42;
46+
47+
Assert.AreEqual(changing.Item1?.PropertyName, nameof(SampleModel.Data));
48+
Assert.AreEqual(changing.Item2, 0);
49+
Assert.AreEqual(changed.Item1?.PropertyName, nameof(SampleModel.Data));
50+
Assert.AreEqual(changed.Item2, 42);
51+
}
52+
53+
public partial class SampleModel : ObservableObject
54+
{
55+
[ObservableProperty]
56+
private int data;
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)