Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/Controls/src/BindingSourceGen/BindingSourceGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,20 +210,30 @@ private static Result<ITypeSymbol> GetLambdaReturnType(LambdaExpressionSyntax la
var lambdaResultType = semanticModel.GetTypeInfo(lambdaBody, t).Type;
if (lambdaResultType == null || lambdaResultType is IErrorTypeSymbol)
{
// Try to infer the type from known patterns (e.g., RelayCommand properties)
// Try to infer the type from known patterns (e.g., RelayCommand, ObservableProperty)
if (lambdaBody is MemberAccessExpressionSyntax memberAccess)
{
var memberName = memberAccess.Name.Identifier.Text;
var expressionType = semanticModel.GetTypeInfo(memberAccess.Expression).Type;

if (expressionType != null &&
expressionType.TryGetRelayCommandPropertyType(memberName, semanticModel.Compilation, out var commandType) &&
commandType != null)

if (expressionType != null)
{
return Result<ITypeSymbol>.Success(commandType);
// Check for RelayCommand-generated properties
if (expressionType.TryGetRelayCommandPropertyType(memberName, semanticModel.Compilation, out var commandType) &&
commandType != null)
{
return Result<ITypeSymbol>.Success(commandType);
}

// Check for ObservableProperty-generated properties
if (expressionType.TryGetObservablePropertyType(memberName, semanticModel.Compilation, out var propertyType) &&
propertyType != null)
{
return Result<ITypeSymbol>.Success(propertyType);
}
}
}

return Result<ITypeSymbol>.Failure(DiagnosticsFactory.LambdaResultCannotBeResolved(lambdaBody.GetLocation()));
}

Expand Down
65 changes: 63 additions & 2 deletions src/Controls/src/BindingSourceGen/ITypeSymbolExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@ public static bool TryGetRelayCommandPropertyType(this ITypeSymbol symbol, strin

// Extract the method name (property name without "Command" suffix)
var methodName = propertyName.Substring(0, propertyName.Length - "Command".Length);

// Look for a method with the base name - search in the type and base types
var methods = GetAllMethods(symbol, methodName);

foreach (var method in methods)
{
// Check if the method has the RelayCommand attribute
Expand All @@ -93,6 +93,54 @@ public static bool TryGetRelayCommandPropertyType(this ITypeSymbol symbol, strin
return false;
}

/// <summary>
/// Checks if a property name could be generated by CommunityToolkit.Mvvm's [ObservableProperty] attribute,
/// and returns the inferred property type if found.
/// </summary>
/// <param name="symbol">The type to search</param>
/// <param name="propertyName">The name of the property to find</param>
/// <param name="compilation">The compilation (can be null)</param>
/// <param name="propertyType">The inferred property type if an ObservableProperty field is found</param>
/// <returns>True if an ObservableProperty field was found that would generate this property</returns>
public static bool TryGetObservablePropertyType(this ITypeSymbol symbol, string propertyName, Compilation? compilation, out ITypeSymbol? propertyType)
{
propertyType = null;

if (compilation == null || string.IsNullOrEmpty(propertyName))
return false;

// ObservableProperty generates a PascalCase property from a camelCase, _camelCase, or m_camelCase field
// Try common field naming patterns
var possibleFieldNames = new[]
{
char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1), // name from Name
"_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1), // _name from Name
"m_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1) // m_name from Name
};

// Look for a field with one of the possible names - search in the type and base types
foreach (var fieldName in possibleFieldNames)
{
var fields = GetAllFields(symbol, fieldName);

foreach (var field in fields)
{
// Check if the field has the ObservableProperty attribute
var hasObservableProperty = field.GetAttributes().Any(attr =>
attr.AttributeClass?.Name == "ObservablePropertyAttribute" ||
attr.AttributeClass?.ToDisplayString() == "CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute");

if (hasObservableProperty)
{
propertyType = field.Type;
return true;
}
}
}

return false;
}

private static System.Collections.Generic.IEnumerable<IMethodSymbol> GetAllMethods(ITypeSymbol symbol, string name)
{
// Search in current type
Expand All @@ -114,4 +162,17 @@ private static System.Collections.Generic.IEnumerable<IMethodSymbol> GetAllMetho
baseType = baseType.BaseType;
}
}

private static System.Collections.Generic.IEnumerable<IFieldSymbol> GetAllFields(ITypeSymbol? symbol, string name)
{
while (symbol != null)
{
foreach (var member in symbol.GetMembers(name))
{
if (member is IFieldSymbol field)
yield return field;
}
symbol = symbol.BaseType;
}
}
}
21 changes: 20 additions & 1 deletion src/Controls/src/BindingSourceGen/PathParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ private bool TryHandleSpecialCases(string memberName, ITypeSymbol expressionType
pathPart = null;

// Check for RelayCommand-generated properties
if (expressionType.TryGetRelayCommandPropertyType(memberName, _context.SemanticModel.Compilation, out var commandType)
if (expressionType.TryGetRelayCommandPropertyType(memberName, _context.SemanticModel.Compilation, out var commandType)
&& commandType != null)
{
var memberType = commandType.CreateTypeDescription(_enabledNullable);
Expand All @@ -105,6 +105,25 @@ private bool TryHandleSpecialCases(string memberName, ITypeSymbol expressionType
return true;
}

// Check for ObservableProperty-generated properties
if (expressionType.TryGetObservablePropertyType(memberName, _context.SemanticModel.Compilation, out var propertyType)
&& propertyType != null)
{
var memberType = propertyType.CreateTypeDescription(_enabledNullable);
var containingType = expressionType.CreateTypeDescription(_enabledNullable);

pathPart = new MemberAccess(
MemberName: memberName,
IsValueType: !propertyType.IsReferenceType,
ContainingType: containingType,
MemberType: memberType,
Kind: AccessorKind.Property,
IsGetterInaccessible: false, // Assume generated property is accessible
IsSetterInaccessible: false); // ObservableProperty properties have setters

return true;
}

return false;
}

Expand Down
20 changes: 13 additions & 7 deletions src/Controls/src/SourceGen/ITypeSymbolExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;

using Microsoft.CodeAnalysis;
using Microsoft.Maui.Controls.Xaml;
using Microsoft.Maui.Controls.BindingSourceGen;
using Microsoft.Maui.Controls.Xaml;

namespace Microsoft.Maui.Controls.SourceGen;

Expand Down Expand Up @@ -85,15 +84,15 @@ public static IEnumerable<IPropertySymbol> GetAllProperties(this ITypeSymbol sym
=> symbol.GetAllMembers(name, context).OfType<IPropertySymbol>();

/// <summary>
/// Tries to get a property by name, and if not found, checks if it could be inferred from a RelayCommand method.
/// Tries to get a property by name, and if not found, checks if it could be inferred from a RelayCommand method or ObservableProperty field.
/// Returns the property type if found or inferred.
/// </summary>
/// <param name="symbol">The type to search</param>
/// <param name="propertyName">The name of the property to find</param>
/// <param name="context">The source generation context</param>
/// <param name="property">The found property symbol (null if inferred from RelayCommand)</param>
/// <param name="propertyType">The property type (either from the property or inferred from RelayCommand)</param>
/// <returns>True if property exists or can be inferred from RelayCommand</returns>
/// <param name="property">The found property symbol (null if inferred from RelayCommand or ObservableProperty)</param>
/// <param name="propertyType">The property type (either from the property or inferred from RelayCommand/ObservableProperty)</param>
/// <returns>True if property exists or can be inferred from RelayCommand or ObservableProperty</returns>
public static bool TryGetProperty(
this ITypeSymbol symbol,
string propertyName,
Expand All @@ -103,7 +102,7 @@ public static bool TryGetProperty(
{
property = symbol.GetAllProperties(propertyName, context)
.FirstOrDefault(p => p.GetMethod != null && !p.GetMethod.IsStatic);

if (property != null)
{
propertyType = property.Type;
Expand All @@ -117,6 +116,13 @@ public static bool TryGetProperty(
return true;
}

// If property not found, check if it could be an ObservableProperty-generated property
// Call the BindingSourceGen extension method directly
if (symbol.TryGetObservablePropertyType(propertyName, context?.Compilation, out propertyType))
{
return true;
}

propertyType = null;
return false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.Maui.Controls.BindingSourceGen;
using Xunit;

namespace BindingSourceGen.UnitTests;

public class ObservablePropertyTests
{
// Note: Expression-based bindings work with ObservableProperty through type inference in
// GetLambdaReturnType. The generated interceptor code will be correct. In tests,
// we'll see compilation errors because the actual generated property doesn't exist
// (CommunityToolkit.Mvvm's source generator isn't running in the test environment),
// but the interceptor code itself is generated correctly.

[Fact]
public void GenerateBindingToObservablePropertyFromCamelCaseField()
{
var source = """
using Microsoft.Maui.Controls;
using System.Collections.ObjectModel;

namespace CommunityToolkit.Mvvm.ComponentModel
{
[System.AttributeUsage(System.AttributeTargets.Field)]
public class ObservablePropertyAttribute : System.Attribute { }
}

namespace TestApp
{
public class MyViewModel
{
[CommunityToolkit.Mvvm.ComponentModel.ObservableProperty]
private string? name;
// Name property will be generated by CommunityToolkit.Mvvm
}

public class TestCode
{
public void Test()
{
var label = new Label();
label.SetBinding(Label.TextProperty, static (MyViewModel vm) => vm.Name);
}
}
}
""";

var result = SourceGenHelpers.Run(source);

// The binding should be generated successfully with ObservableProperty inference
Assert.NotNull(result.Binding);

// Verify the generated interceptor code contains the correct getter and setter references
var allGeneratedCode = string.Join("\n\n", result.GeneratedFiles.Values);
// Check that the handler contains the property access
Assert.Contains("new(static source => source, \"Name\")", allGeneratedCode, System.StringComparison.Ordinal);
// Check that setter assigns to .Name
Assert.Contains("source.Name = value;", allGeneratedCode, System.StringComparison.Ordinal);

// Note: There will be compilation errors because Name doesn't actually exist,
// but the interceptor code itself is generated correctly. In real usage with
// CommunityToolkit.Mvvm, the property would exist and compile successfully.
}

[Fact]
public void GenerateBindingToObservablePropertyFromUnderscorePrefixedField()
{
var source = """
using Microsoft.Maui.Controls;
using System.Collections.ObjectModel;

namespace CommunityToolkit.Mvvm.ComponentModel
{
[System.AttributeUsage(System.AttributeTargets.Field)]
public class ObservablePropertyAttribute : System.Attribute { }
}

namespace TestApp
{
public class MyViewModel
{
[CommunityToolkit.Mvvm.ComponentModel.ObservableProperty]
private string? _title;
// Title property will be generated by CommunityToolkit.Mvvm
}

public class TestCode
{
public void Test()
{
var label = new Label();
label.SetBinding(Label.TextProperty, static (MyViewModel vm) => vm.Title);
}
}
}
""";

var result = SourceGenHelpers.Run(source);

// The binding should be generated successfully with ObservableProperty inference
Assert.NotNull(result.Binding);

// Verify the generated interceptor code contains the correct getter and setter references
var allGeneratedCode = string.Join("\n\n", result.GeneratedFiles.Values);
// Check that the handler contains the property access
Assert.Contains("new(static source => source, \"Title\")", allGeneratedCode, System.StringComparison.Ordinal);
// Check that setter assigns to .Title
Assert.Contains("source.Title = value;", allGeneratedCode, System.StringComparison.Ordinal);
}

[Fact]
public void GenerateBindingToObservablePropertyCollection()
{
var source = """
using Microsoft.Maui.Controls;
using System.Collections.ObjectModel;

namespace CommunityToolkit.Mvvm.ComponentModel
{
[System.AttributeUsage(System.AttributeTargets.Field)]
public class ObservablePropertyAttribute : System.Attribute { }
}

namespace TestApp
{
public class Tag { }

public class MyViewModel
{
[CommunityToolkit.Mvvm.ComponentModel.ObservableProperty]
private ObservableCollection<Tag> _tags = new();
// Tags property will be generated by CommunityToolkit.Mvvm
}

public class TestCode
{
public void Test()
{
var label = new Label();
label.SetBinding(Label.BindingContextProperty, static (MyViewModel vm) => vm.Tags);
}
}
}
""";

var result = SourceGenHelpers.Run(source);

// The binding should be generated successfully with ObservableProperty inference
Assert.NotNull(result.Binding);

// Verify the generated interceptor code contains the correct getter and setter references
var allGeneratedCode = string.Join("\n\n", result.GeneratedFiles.Values);
// Check that the handler contains the property access
Assert.Contains("new(static source => source, \"Tags\")", allGeneratedCode, System.StringComparison.Ordinal);
// Check that setter assigns to .Tags
Assert.Contains("source.Tags = value;", allGeneratedCode, System.StringComparison.Ordinal);
}
}
Loading