diff --git a/src/Controls/src/Build.Tasks/BuildException.cs b/src/Controls/src/Build.Tasks/BuildException.cs index ecdf63e2d033..dd6429bc8db9 100644 --- a/src/Controls/src/Build.Tasks/BuildException.cs +++ b/src/Controls/src/Build.Tasks/BuildException.cs @@ -81,6 +81,7 @@ class BuildExceptionCode public static BuildExceptionCode NamescopeDuplicate = new BuildExceptionCode("XC", 0064, nameof(NamescopeDuplicate), ""); public static BuildExceptionCode ContentPropertyAttributeMissing = new BuildExceptionCode("XC", 0065, nameof(ContentPropertyAttributeMissing), ""); public static BuildExceptionCode InvalidXaml = new BuildExceptionCode("XC", 0066, nameof(InvalidXaml), ""); + public static BuildExceptionCode DuplicatePropertyAssignment = new BuildExceptionCode("XC", 0067, nameof(DuplicatePropertyAssignment), ""); //warning //Extensions diff --git a/src/Controls/src/Build.Tasks/ErrorMessages.resx b/src/Controls/src/Build.Tasks/ErrorMessages.resx index 3eeabf1a9911..cf57f0d50975 100644 --- a/src/Controls/src/Build.Tasks/ErrorMessages.resx +++ b/src/Controls/src/Build.Tasks/ErrorMessages.resx @@ -270,8 +270,12 @@ A key is required in {StaticResource}. - + x:Shared is only supported with SourceGen inflator. Remove the attribute or switch to SourceGen. + + Property '{0}' is being set multiple times. Only the last value will be used. + 0 is property name (e.g. "Border.Content" or "Label.Text") + \ No newline at end of file diff --git a/src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs b/src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs index 7ff49a6a9b92..14954818e9c4 100644 --- a/src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs +++ b/src/Controls/src/Build.Tasks/SetPropertiesVisitor.cs @@ -43,6 +43,31 @@ public bool IsResourceDictionary(ElementNode node) ModuleDefinition Module { get; } = context.Body.Method.Module; + // Track properties that have been set to detect duplicates + readonly Dictionary> setProperties = new Dictionary>(); + + void CheckForDuplicateProperty(ElementNode parentNode, XmlName propertyName, IXmlLineInfo lineInfo) + { + if (!setProperties.TryGetValue(parentNode, out var props)) + { + props = new HashSet(); + setProperties[parentNode] = props; + } + + if (!props.Add(propertyName)) + { + // Property is being set multiple times + Context.LoggingHelper.LogWarningOrError( + DuplicatePropertyAssignment, + Context.XamlFilePath, + lineInfo.LineNumber, + lineInfo.LinePosition, + 0, + 0, + $"{parentNode.XmlType.Name}.{propertyName.LocalName}"); + } + } + public void Visit(ValueNode node, INode parentNode) { //TODO support Label text as element @@ -69,6 +94,12 @@ public void Visit(ValueNode node, INode parentNode) return; if (propertyName.Equals(XmlName.mcIgnorable)) return; + + // TODO: Add duplicate implicit content property checking here to match SourceGen behavior + // (SourceGen Visit(ValueNode) emits MAUIX2015 when a ValueNode assigns the same implicit + // content property that was already assigned by an ElementNode or another ValueNode). + // Without this check, text produces MAUIX2015 under SourceGen + // but no warning under XamlC. Context.IL.Append(SetPropertyValue(Context.Variables[(ElementNode)parentNode], propertyName, node, Context, node)); } @@ -140,8 +171,32 @@ public void Visit(ElementNode node, INode parentNode) var name = new XmlName(node.NamespaceURI, contentProperty); if (skips.Contains(name)) return; - if (parentNode is ElementNode node2 && node2.SkipProperties.Contains(propertyName)) + if (parentNode is ElementNode node2 && node2.SkipProperties.Contains(name)) return; + + // Resolve the content property type to determine if it is a collection. + // Use CanGet (CLR property resolution) rather than GetBindablePropertyReference+GetValue/Get + // to avoid side effects: GetBindablePropertyReference can emit ObsoleteProperty diagnostics + // and GetValue/Get generate IL instructions; both would be duplicated by SetPropertyValue. + // CanGet covers all content properties because every BP-backed property has a CLR wrapper. + var propLocalName = name.LocalName; + bool canResolveProperty = CanGet(parentVar, propLocalName, Context, out TypeReference contentPropType); + + // Skip duplicate check when the property cannot be resolved, is System.Object (unresolved + // generics would produce false positives), or is a collection type. Matches SourceGen behavior. + if (canResolveProperty && contentPropType != null) + { + bool isObject = contentPropType.FullName == "System.Object"; + bool isCollection = !isObject + && contentPropType.ImplementsInterface(Context.Cache, Module.ImportReference(Context.Cache, ("mscorlib", "System.Collections", "IEnumerable"))) + && contentPropType.GetMethods(Context.Cache, md => md.Name == "Add" && md.Parameters.Count == 1, Module).Any(); + + // Use parent namespace for duplicate tracking to match how explicit property assignments are keyed + var contentPropertyName = new XmlName(((ElementNode)parentNode).NamespaceURI, contentProperty); + if (!isCollection && !isObject) + CheckForDuplicateProperty((ElementNode)parentNode, contentPropertyName, node); + } + Context.IL.Append(SetPropertyValue(Context.Variables[(ElementNode)parentNode], name, node, Context, node)); } // Collection element, implicit content, or implicit collection element. @@ -539,7 +594,7 @@ static bool TryCompileBindingPath(ElementNode node, ILContext context, VariableD // continue compilation } - if ( dataTypeNode is ElementNode enode + if (dataTypeNode is ElementNode enode && enode.XmlType.NamespaceUri == XamlParser.X2009Uri && enode.XmlType.Name == nameof(Xaml.NullExtension)) { diff --git a/src/Controls/src/SourceGen/AnalyzerReleases.Unshipped.md b/src/Controls/src/SourceGen/AnalyzerReleases.Unshipped.md index 7618199f24cc..561c857d919a 100644 --- a/src/Controls/src/SourceGen/AnalyzerReleases.Unshipped.md +++ b/src/Controls/src/SourceGen/AnalyzerReleases.Unshipped.md @@ -30,3 +30,4 @@ MAUIX2010 | XamlParsing | Info | ExpressionNotSettable MAUIX2011 | XamlParsing | Warning | AmbiguousMemberWithStaticType MAUIX2012 | XamlParsing | Error | CSharpExpressionsRequirePreviewFeatures MAUIX2013 | XamlParsing | Error | AsyncLambdaNotSupported +MAUIX2015 | XamlInflation | Warning | DuplicatePropertyAssignment diff --git a/src/Controls/src/SourceGen/BindingContextDataType.cs b/src/Controls/src/SourceGen/BindingContextDataType.cs new file mode 100644 index 000000000000..43047e1ad408 --- /dev/null +++ b/src/Controls/src/SourceGen/BindingContextDataType.cs @@ -0,0 +1,36 @@ +using Microsoft.CodeAnalysis; +using Microsoft.Maui.Controls.Xaml; + +namespace Microsoft.Maui.Controls.SourceGen; + +/// +/// Distinguishes between different states of x:DataType resolution on a node. +/// +enum BindingContextDataTypeKind +{ + /// x:DataType resolved to a known type. + Resolved, + + /// x:DataType="{x:Null}" — explicit opt-out of compiled bindings for this scope. + ExplicitNull, + + /// x:DataType is present but couldn't be resolved (diagnostic already reported). + Unresolved, +} + +/// +/// Represents the resolved x:DataType for a node in the XAML tree. +/// Pre-computed by and stored in +/// . +/// +/// The resolution state. +/// The resolved type symbol. Non-null only when is . +/// The node where x:DataType was declared (for diagnostics). Null when inherited from a parent context. +/// True if the data type was inherited from an ancestor, false if declared on this node. +/// True if the data type was inherited across a DataTemplate boundary (likely a bug). +record BindingContextDataType( + BindingContextDataTypeKind Kind, + ITypeSymbol? Symbol, + INode? Origin, + bool IsInherited, + bool CrossedTemplateBoundary); diff --git a/src/Controls/src/SourceGen/CompiledBindingMarkup.cs b/src/Controls/src/SourceGen/CompiledBindingMarkup.cs index 6e80cb3d237a..abb896df9caf 100644 --- a/src/Controls/src/SourceGen/CompiledBindingMarkup.cs +++ b/src/Controls/src/SourceGen/CompiledBindingMarkup.cs @@ -29,15 +29,17 @@ public CompiledBindingMarkup(ElementNode node, string path, ILocalValue bindingE private Location GetLocation(INode node, string? context = null) => LocationHelpers.LocationCreate(_context.ProjectItem.RelativePath!, (IXmlLineInfo)node, context ?? "Binding"); - public bool TryCompileBinding(ITypeSymbol sourceType, bool isTemplateBinding, out string? newBindingExpression) + public bool TryCompileBinding(ITypeSymbol sourceType, bool isTemplateBinding, out string? newBindingExpression, out Diagnostic? propertyNotFoundDiagnostic) { newBindingExpression = null; + propertyNotFoundDiagnostic = null; if (!TryParsePath( sourceType, out ITypeSymbol? propertyType, out SetterOptions? setterOptions, - out EquatableArray? parsedPath) + out EquatableArray? parsedPath, + out propertyNotFoundDiagnostic) || propertyType is null || setterOptions is null || !parsedPath.HasValue) @@ -261,11 +263,13 @@ bool TryParsePath( ITypeSymbol sourceType, out ITypeSymbol? propertyType, out SetterOptions? setterOptions, - out EquatableArray? bindingPath) + out EquatableArray? bindingPath, + out Diagnostic? propertyNotFoundDiagnostic) { propertyType = sourceType; setterOptions = null; bindingPath = default; + propertyNotFoundDiagnostic = null; var isNullable = false; var path = _path.Trim('.', ' '); @@ -310,10 +314,9 @@ bool TryParsePath( if (!previousPartType.TryGetProperty(p, _context, out var property, out var currentPropertyType, out var isAssumedWritable) || currentPropertyType is null) { - // Report as WARNING (not Error) because the property might be generated by another source generator - // that runs after the MAUI XAML source generator. The binding will fall back to reflection at runtime. - // This allows scenarios like CommunityToolkit.Mvvm's [ObservableProperty] to work correctly. - _context.ReportDiagnostic(Diagnostic.Create(Descriptors.BindingPropertyNotFound, GetLocation(_node), p, previousPartType.ToFQDisplayString())); + // Don't emit directly — let the caller decide whether to report this. + // For x:Reference bindings, the caller silently falls back to runtime. + propertyNotFoundDiagnostic = Diagnostic.Create(Descriptors.BindingPropertyNotFound, GetLocation(_node), p, previousPartType.ToFQDisplayString()); return false; } diff --git a/src/Controls/src/SourceGen/Descriptors.cs b/src/Controls/src/SourceGen/Descriptors.cs index fed3bdb7ba1d..a338f9c64d2d 100644 --- a/src/Controls/src/SourceGen/Descriptors.cs +++ b/src/Controls/src/SourceGen/Descriptors.cs @@ -325,6 +325,14 @@ public static class Descriptors defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + public static DiagnosticDescriptor DuplicatePropertyAssignment = new DiagnosticDescriptor( + id: "MAUIX2015", + title: new LocalizableResourceString(nameof(MauiGResources.DuplicatePropertyAssignmentTitle), MauiGResources.ResourceManager, typeof(MauiGResources)), + messageFormat: new LocalizableResourceString(nameof(MauiGResources.DuplicatePropertyAssignmentMessage), MauiGResources.ResourceManager, typeof(MauiGResources)), + category: "XamlInflation", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + // public static BuildExceptionCode TypeResolution = new BuildExceptionCode("XC", 0000, nameof(TypeResolution), ""); // public static BuildExceptionCode PropertyResolution = new BuildExceptionCode("XC", 0001, nameof(PropertyResolution), ""); // public static BuildExceptionCode MissingEventHandler = new BuildExceptionCode("XC", 0002, nameof(MissingEventHandler), ""); diff --git a/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs b/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs index db94769a8e97..3df90e8f0da5 100644 --- a/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs +++ b/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs @@ -130,7 +130,8 @@ PrePost newblock() => return; } -"""); +""" +); } Visit(root, sgcontext); @@ -176,6 +177,7 @@ static void Visit(RootNode rootnode, SourceGenContext visitorContext, bool useDe rootnode.Accept(new CreateValuesVisitor(visitorContext), null); rootnode.Accept(new SetNamescopesAndRegisterNamesVisitor(visitorContext), null); //set namescopes for {x:Reference} and FindByName rootnode.Accept(new SetFieldsForXNamesVisitor(visitorContext), null); + rootnode.Accept(new PropagateDataTypeVisitor(visitorContext), null); // rootnode.Accept(new FillResourceDictionariesVisitor(visitorContext), null); rootnode.Accept(new SetResourcesVisitor(visitorContext), null); rootnode.Accept(new SetPropertiesVisitor(visitorContext, true), null); diff --git a/src/Controls/src/SourceGen/KnownMarkups.cs b/src/Controls/src/SourceGen/KnownMarkups.cs index 9c0602411cff..ac5d242ce854 100644 --- a/src/Controls/src/SourceGen/KnownMarkups.cs +++ b/src/Controls/src/SourceGen/KnownMarkups.cs @@ -343,25 +343,45 @@ private static bool ProvideValueForBindingExtension(ElementNode markupNode, Inde returnType = context.Compilation.GetTypeByMetadataName("Microsoft.Maui.Controls.BindingBase")!; ITypeSymbol? dataTypeSymbol = null; - // When Source is explicitly set (RelativeSource or x:Reference), x:DataType does not describe - // the actual source — skip compilation and fall back to runtime binding. - bool hasExplicitSource = HasExplicitBindingSource(markupNode); + // When Source is RelativeSource, the type is determined at runtime — skip compilation. + // When Source is x:Reference, resolve the referenced element's type and compile against it. + // Otherwise, use x:DataType from the current scope. + bool hasRelativeSource = HasRelativeSourceBinding(markupNode); context.Variables.TryGetValue(markupNode, out ILocalValue? extVariable); - if ( !hasExplicitSource + if ( !hasRelativeSource && extVariable is not null) { - TryGetXDataType(markupNode, context, out dataTypeSymbol); + ITypeSymbol? xRefSourceType = TryResolveXReferenceSourceType(markupNode, context); + dataTypeSymbol = xRefSourceType; + if (dataTypeSymbol is null && TryGetBindingContextDataType(markupNode, context, out var bcDataType)) + { + dataTypeSymbol = bcDataType.Symbol; + + if (bcDataType.CrossedTemplateBoundary) + { + var location = LocationHelpers.LocationCreate(context.ProjectItem.RelativePath!, (IXmlLineInfo)markupNode, "x:DataType"); + context.ReportDiagnostic(Diagnostic.Create(Descriptors.BindingWithXDataTypeFromOuterScope, location)); + } + } if (dataTypeSymbol is not null) { var compiledBindingMarkup = new CompiledBindingMarkup(markupNode, GetBindingPath(markupNode), extVariable, context); - if (compiledBindingMarkup.TryCompileBinding(dataTypeSymbol, isTemplateBinding, out string? newBindingExpression) && newBindingExpression is not null) + if (compiledBindingMarkup.TryCompileBinding(dataTypeSymbol, isTemplateBinding, out string? newBindingExpression, out Diagnostic? propertyNotFoundDiagnostic) && newBindingExpression is not null) { value = newBindingExpression; return true; } + + // Emit property-not-found diagnostic only for x:DataType-sourced bindings. + // For x:Reference bindings, silently fall back to runtime — these bindings + // were never compiled before, so emitting a new warning would be a regression. + if (propertyNotFoundDiagnostic is not null && xRefSourceType is null) + { + context.ReportDiagnostic(propertyNotFoundDiagnostic); + } } } @@ -468,161 +488,66 @@ static string GetBindingPath(ElementNode node) } } - static bool TryGetXDataType(ElementNode node, SourceGenContext context, out ITypeSymbol? dataTypeSymbol) + static ElementNode? GetParent(ElementNode node) { - dataTypeSymbol = null; - - if (!TryFindXDataTypeNode(node, context, out INode? dataTypeNode, out bool xDataTypeIsInOuterScope) - || dataTypeNode is null) + return node switch { - return false; - } - - var location = LocationHelpers.LocationCreate(context.ProjectItem.RelativePath!, (IXmlLineInfo)node, "x:DataType"); + { Parent: ListNode { Parent: ElementNode parentNode } } => parentNode, + { Parent: ElementNode parentNode } => parentNode, + _ => null, + }; + } - if (xDataTypeIsInOuterScope) - { - context.ReportDiagnostic(Diagnostic.Create(Descriptors.BindingWithXDataTypeFromOuterScope, location)); - // continue compilation - this is a warning - } + /// + /// Looks up the pre-computed BindingContextDataType for a binding markup node. + /// Handles the BindingContext={Binding} skip: when the binding target is the + /// BindingContext property itself, the node's own x:DataType describes the RESULT, + /// not the source — so we look up the grandparent's type instead. + /// + static bool TryGetBindingContextDataType(ElementNode markupNode, SourceGenContext context, out BindingContextDataType result) + { + result = default!; - if (dataTypeNode.RepresentsType(XamlParser.X2009Uri, "NullExtension")) + if (IsBindingContextBinding(markupNode)) { - // TODO report warning - // context.ReportDiagnostic(Diagnostic.Create(Descriptors.XamlParserError, location, "Binding with x:DataType NullExtension")); - // context.LoggingHelper.LogWarningOrError(BuildExceptionCode.BindingWithNullDataType, context.XamlFilePath, node.LineNumber, node.LinePosition, 0, 0, null); - return false; - } + // The binding is BindingContext="{Binding ...}". The parent element's x:DataType + // describes what BindingContext will become after the binding resolves — not the + // source to resolve against. We need the grandparent's data type. + var parent = GetParent(markupNode); + if (parent is null) + return false; - // TypeExtension would already provide the type value, so we can just grab it from the context - if (dataTypeNode.RepresentsType(XamlParser.X2009Uri, "TypeExtension")) - { - // it is possible that the dataTypeNode belongs to the parent context - // this is the case for example in this scenario: - // - // - // - // - SourceGenContext? ctx = context; - while (ctx is not null) + var grandparent = GetParent(parent); + if (grandparent is not null + && TryGetBindingContextDataTypeFromContext(grandparent, context, out result)) { - if (ctx.Types.TryGetValue(dataTypeNode, out dataTypeSymbol)) - { - return true; - } - - ctx = ctx.ParentContext; + return true; } - context.ReportDiagnostic(Diagnostic.Create(Descriptors.XamlParserError, location, "Binding with x:DataType TypeExtension which cannot be resolved")); - return false; - } - - string? dataTypeName = (dataTypeNode as ValueNode)?.Value as string; - if (dataTypeName is null) - { - // TODO - context.ReportDiagnostic(Diagnostic.Create(Descriptors.XamlParserError, location, $"dataTypeNode is not a value node, got {dataTypeNode} instead")); - // throw new BuildException(XDataTypeSyntax, dataTypeNode as IXmlLineInfo, null); - // throw new Exception($"dataTypeNode {dataTypeNode} is not a value node"); // TODO - return false; - } - - XmlType? dataType = null; - try - { - dataType = TypeArgumentsParser.ParseSingle(dataTypeName, node.NamespaceResolver, dataTypeNode as IXmlLineInfo); - } - catch (XamlParseException) - { - var prefix = dataTypeName.Contains(":") ? dataTypeName.Substring(0, dataTypeName.IndexOf(":", StringComparison.Ordinal)) : ""; - // throw new BuildException(XmlnsUndeclared, dataTypeNode as IXmlLineInfo, null, prefix); - throw new Exception($"XmlnsUndeclared {prefix}"); // TODO - } - - if (dataType is null) - { - // TODO - // throw new BuildException(XDataTypeSyntax, dataTypeNode as IXmlLineInfo, null); - // throw new Exception($"cannot parse {dataTypeName}"); // TODO - context.ReportDiagnostic(Diagnostic.Create(Descriptors.XamlParserError, location, "Cannot parse x:DataType value")); - return false; - } - - if (!dataType.TryResolveTypeSymbol(null, context.Compilation, context.XmlnsCache, context.TypeCache, out INamedTypeSymbol? symbol) && symbol is not null) - { - // TODO report the right diagnostic - context.ReportDiagnostic(Diagnostic.Create(Descriptors.XamlParserError, location, "Cannot resolve x:DataType type")); return false; } - dataTypeSymbol = symbol; - return true; + return TryGetBindingContextDataTypeFromContext(markupNode, context, out result); } - static bool TryFindXDataTypeNode(ElementNode elementNode, SourceGenContext context, out INode? dataTypeNode, out bool isInOuterScope) + static bool TryGetBindingContextDataTypeFromContext(ElementNode node, SourceGenContext context, out BindingContextDataType result) { - isInOuterScope = false; - dataTypeNode = null; - - // Special handling for BindingContext={Binding ...} - // The order of checks is: - // - x:DataType on the binding itself - // - SKIP looking for x:DataType on the parent - // - continue looking for x:DataType on the parent's parent... - ElementNode? skipNode = null; - if (IsBindingContextBinding(elementNode)) - { - skipNode = GetParent(elementNode); - } - - ElementNode? node = elementNode; - while (node is not null) + // Walk up the context chain (handles template child contexts) + SourceGenContext? ctx = context; + while (ctx is not null) { - if (node != skipNode && node.Properties.TryGetValue(XmlName.xDataType, out dataTypeNode)) + if (ctx.BindingContextDataTypes.TryGetValue(node, out result) + && result.Kind == BindingContextDataTypeKind.Resolved) { return true; } - - if (DoesNotInheritDataType(node, context)) - { - return false; - } - - // When the binding is inside of a DataTemplate and the x:DataType is in the parent scope, - // it is usually a bug. - if (node.RepresentsType(XamlParser.MauiUri, "DataTemplate")) - { - isInOuterScope = true; - } - - node = GetParent(node); + ctx = ctx.ParentContext; } + result = default!; return false; } - static bool DoesNotInheritDataType(ElementNode node, SourceGenContext context) - { - return GetParent(node) is ElementNode parentNode - && parentNode.XmlType.TryResolveTypeSymbol(null, context.Compilation, context.XmlnsCache, context.TypeCache, out INamedTypeSymbol? parentTypeSymbol) - && parentTypeSymbol is not null - && node.TryGetPropertyName(parentNode, out XmlName propertyName) - && parentTypeSymbol.GetAllProperties(propertyName.LocalName, context).FirstOrDefault() is IPropertySymbol propertySymbol - && propertySymbol.GetAttributes().Any(a => a.AttributeClass?.ToFQDisplayString() == "global::Microsoft.Maui.Controls.Xaml.DoesNotInheritDataTypeAttribute"); - } - - static ElementNode? GetParent(ElementNode node) - { - return node switch - { - { Parent: ListNode { Parent: ElementNode parentNode } } => parentNode, - { Parent: ElementNode parentNode } => parentNode, - _ => null, - }; - } - static bool IsBindingContextBinding(ElementNode node) { // looking for BindingContext="{Binding ...}" @@ -632,29 +557,71 @@ static bool IsBindingContextBinding(ElementNode node) && propertyName.LocalName == "BindingContext"; } - // Checks if the binding has a Source property set to RelativeSource or x:Reference. - // When Source is explicitly set, x:DataType does not describe the actual binding source, + // Checks if the binding has a Source property set to RelativeSource. + // When a binding uses RelativeSource, the source type is determined at runtime, // so we should NOT compile the binding using x:DataType. - static bool HasExplicitBindingSource(ElementNode bindingNode) + static bool HasRelativeSourceBinding(ElementNode bindingNode) { - // Check if Source property exists if (!bindingNode.Properties.TryGetValue(new XmlName("", "Source"), out INode? sourceNode) && !bindingNode.Properties.TryGetValue(new XmlName(null, "Source"), out sourceNode)) { return false; } - // Check if the Source is a RelativeSourceExtension or ReferenceExtension if (sourceNode is ElementNode sourceElementNode) { return sourceElementNode.XmlType.Name is "RelativeSourceExtension" - or "RelativeSource" - or "ReferenceExtension" - or "Reference"; + or "RelativeSource"; } return false; } + + // When Source={x:Reference Name} is set on a binding, resolves the referenced element's type + // by walking namescopes (same logic as ProvideValueForReferenceExtension). + // Returns null if Source is not an x:Reference or the name cannot be resolved. + static ITypeSymbol? TryResolveXReferenceSourceType(ElementNode bindingNode, SourceGenContext context) + { + if (!bindingNode.Properties.TryGetValue(new XmlName("", "Source"), out INode? sourceNode) + && !bindingNode.Properties.TryGetValue(new XmlName(null, "Source"), out sourceNode)) + return null; + + if (sourceNode is not ElementNode refNode) + return null; + + if (refNode.XmlType.Name is not "ReferenceExtension" and not "Reference") + return null; + + // Extract the Name from the x:Reference markup + if (!refNode.Properties.TryGetValue(new XmlName("", "Name"), out INode? refNameNode) + && !refNode.Properties.TryGetValue(new XmlName(null, "Name"), out refNameNode)) + { + refNameNode = refNode.CollectionItems.Count > 0 ? refNode.CollectionItems[0] : null; + } + + if (refNameNode is not ValueNode vn || vn.Value is not string name) + return null; + + // Walk namescopes to find the referenced element's type + ElementNode? node = bindingNode; + var currentContext = context; + while (currentContext is not null && node is not null) + { + while (currentContext is not null && !currentContext.Scopes.ContainsKey(node)) + currentContext = currentContext.ParentContext; + if (currentContext is null) + break; + var namescope = currentContext.Scopes[node]; + if (namescope.namesInScope != null && namescope.namesInScope.ContainsKey(name)) + return namescope.namesInScope[name].Type; + INode n = node; + while (n.Parent is ListNode ln) + n = ln.Parent; + node = n.Parent as ElementNode; + } + + return null; + } } internal static bool ProvideValueForDataTemplateExtension(ElementNode markupNode, IndentedTextWriter writer, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate? getNodeValue, out ITypeSymbol? returnType, out string value) diff --git a/src/Controls/src/SourceGen/MauiGResources.resx b/src/Controls/src/SourceGen/MauiGResources.resx index d1bb6e523e47..da1831797582 100644 --- a/src/Controls/src/SourceGen/MauiGResources.resx +++ b/src/Controls/src/SourceGen/MauiGResources.resx @@ -240,4 +240,11 @@ Event handler '{0}' with correct signature not found in type '{1}'. 0 is handler name, 1 is type name + + Property set multiple times + + + Property '{0}' is being set multiple times. Only the last value will be used. + 0 is a property name (e.g., "Border.Content", "Label.Text") + diff --git a/src/Controls/src/SourceGen/SetPropertyHelpers.cs b/src/Controls/src/SourceGen/SetPropertyHelpers.cs index e7e370a0deff..de739a376d87 100644 --- a/src/Controls/src/SourceGen/SetPropertyHelpers.cs +++ b/src/Controls/src/SourceGen/SetPropertyHelpers.cs @@ -233,6 +233,16 @@ public static void AddLazyResourceToResourceDictionary(IndentedTextWriter writer ParentContext = context }; + // Seed the lambda context with the parent's BindingContextDataType. + // The CrossedTemplateBoundary flag from the main context already carries the + // correct value. When the node declares its own x:DataType, the visitor will + // override this seed. + if (context.BindingContextDataTypes.TryGetValue(node, out var nodeDataType) + && nodeDataType.Kind == BindingContextDataTypeKind.Resolved) + { + lambdaContext.BindingContextDataTypes[node] = nodeDataType; + } + // First pass: Create all values (node and its descendants) using CreateValuesVisitor // This mirrors the normal flow: CreateValuesVisitor walks the entire tree first node.Accept(new CreateValuesVisitor(lambdaContext), null); @@ -240,6 +250,9 @@ public static void AddLazyResourceToResourceDictionary(IndentedTextWriter writer // Second pass: Set namescopes and register names in the namescope node.Accept(new SetNamescopesAndRegisterNamesVisitor(lambdaContext), null); + // Propagate x:DataType through the tree + node.Accept(new PropagateDataTypeVisitor(lambdaContext), null); + // Third pass: Set resources in ResourceDictionary node.Accept(new SetResourcesVisitor(lambdaContext), null); @@ -534,7 +547,7 @@ static void SetValue(IndentedTextWriter writer, ILocalValue parentVar, IFieldSym } } - static bool CanGet(ILocalValue parentVar, string localName, SourceGenContext context, out ITypeSymbol? propertyType, out IPropertySymbol? propertySymbol) + internal static bool CanGet(ILocalValue parentVar, string localName, SourceGenContext context, out ITypeSymbol? propertyType, out IPropertySymbol? propertySymbol) { propertyType = null; if ((propertySymbol = parentVar.Type.GetAllProperties(localName, context).FirstOrDefault()) == null) @@ -546,7 +559,7 @@ static bool CanGet(ILocalValue parentVar, string localName, SourceGenContext con return true; } - static bool CanGetValue(ILocalValue parentVar, IFieldSymbol? bpFieldSymbol, bool attached, SourceGenContext context, out ITypeSymbol? propertyType) + internal static bool CanGetValue(ILocalValue parentVar, IFieldSymbol? bpFieldSymbol, bool attached, SourceGenContext context, out ITypeSymbol? propertyType) { propertyType = null; if (bpFieldSymbol == null) diff --git a/src/Controls/src/SourceGen/SourceGenContext.cs b/src/Controls/src/SourceGen/SourceGenContext.cs index 58ed48d8112d..69cf0b13e65a 100644 --- a/src/Controls/src/SourceGen/SourceGenContext.cs +++ b/src/Controls/src/SourceGen/SourceGenContext.cs @@ -38,10 +38,14 @@ public void ReportDiagnostic(Diagnostic diagnostic) var noWarn = ProjectItem?.NoWarn; if (!string.IsNullOrEmpty(noWarn)) { - var suppressedIds = noWarn!.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); + var suppressedIds = noWarn!.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries); foreach (var id in suppressedIds) { - if (diagnostic.Id.Equals(id.Trim(), StringComparison.OrdinalIgnoreCase)) + var code = id.Trim(); + // Match full ID (e.g., "MAUIX2015") or bare numeric suffix (e.g., "2015") + if (code.Equals(diagnostic.Id, StringComparison.OrdinalIgnoreCase) || + (diagnostic.Id.StartsWith("MAUIX", StringComparison.OrdinalIgnoreCase) && + code == diagnostic.Id.Substring("MAUIX".Length))) { return; // Suppress this diagnostic } @@ -54,6 +58,7 @@ public void ReportDiagnostic(Diagnostic diagnostic) public SourceGenContext? ParentContext { get; set; } public ITypeSymbol? BaseType { get; } = baseType; public IDictionary Types { get; } = new Dictionary(); + public IDictionary BindingContextDataTypes { get; } = new Dictionary(); public IDictionary> KeysInRD { get; } = new Dictionary>(); public IDictionary<(ILocalValue, IFieldSymbol?, IPropertySymbol?), ILocalValue> VariablesProperties { get; } = new Dictionary<(ILocalValue, IFieldSymbol?, IPropertySymbol?), ILocalValue>(); public IList LocalMethods { get; } = new List(); diff --git a/src/Controls/src/SourceGen/Visitors/PropagateDataTypeVisitor.cs b/src/Controls/src/SourceGen/Visitors/PropagateDataTypeVisitor.cs new file mode 100644 index 000000000000..db7752418f38 --- /dev/null +++ b/src/Controls/src/SourceGen/Visitors/PropagateDataTypeVisitor.cs @@ -0,0 +1,155 @@ +using System.Linq; +using System.Xml; +using Microsoft.CodeAnalysis; +using Microsoft.Maui.Controls.Xaml; + +namespace Microsoft.Maui.Controls.SourceGen; + +/// +/// Top-down visitor that resolves x:DataType for every ElementNode in the tree and stores +/// the result in . +/// +/// This pre-computes type information so binding compilation can do an O(1) lookup instead +/// of walking up the tree for every binding. +/// +class PropagateDataTypeVisitor(SourceGenContext context) : IXamlNodeVisitor +{ + public TreeVisitingMode VisitingMode => TreeVisitingMode.TopDown; + public bool StopOnDataTemplate => true; + public bool StopOnResourceDictionary => false; + public bool VisitNodeOnDataTemplate => true; + public bool SkipChildren(INode node, INode parentNode) => false; + public bool IsResourceDictionary(ElementNode node) => node.IsResourceDictionary(context); + + public void Visit(ValueNode node, INode parentNode) { } + public void Visit(MarkupNode node, INode parentNode) { } + public void Visit(ListNode node, INode parentNode) { } + public void Visit(RootNode node, INode parentNode) => Visit((ElementNode)node, parentNode); + + public void Visit(ElementNode node, INode parentNode) + { + // 1. Check if this node declares x:DataType directly + if (node.Properties.TryGetValue(XmlName.xDataType, out INode? dataTypeNode)) + { + var resolved = ResolveDataTypeNode(dataTypeNode, node); + context.BindingContextDataTypes[node] = resolved; + return; + } + + // 2. Try to inherit from parent + var parentElement = GetParentElement(node); + if (parentElement is null) + return; + + if (!context.BindingContextDataTypes.TryGetValue(parentElement, out var parentDataType)) + return; + + // ExplicitNull and Unresolved block inheritance + if (parentDataType.Kind != BindingContextDataTypeKind.Resolved) + return; + + // DoesNotInheritDataType blocks inheritance + if (DoesNotInheritDataType(node)) + return; + + // Check if we're crossing a DataTemplate boundary + bool crossedBoundary = parentDataType.CrossedTemplateBoundary + || node.RepresentsType(XamlParser.MauiUri, "DataTemplate"); + + context.BindingContextDataTypes[node] = parentDataType with + { + IsInherited = true, + CrossedTemplateBoundary = crossedBoundary, + }; + } + + /// + /// Resolves an x:DataType property value node into a . + /// Handles {x:Null}, {x:Type}, and string type names. + /// + private BindingContextDataType ResolveDataTypeNode(INode dataTypeNode, ElementNode ownerNode) + { + // x:DataType="{x:Null}" — explicit opt-out + if (dataTypeNode.RepresentsType(XamlParser.X2009Uri, "NullExtension")) + { + return new BindingContextDataType( + BindingContextDataTypeKind.ExplicitNull, + Symbol: null, + Origin: ownerNode, + IsInherited: false, + CrossedTemplateBoundary: false); + } + + // x:DataType="{x:Type vm:MyViewModel}" — lookup from context.Types + if (dataTypeNode.RepresentsType(XamlParser.X2009Uri, "TypeExtension")) + { + SourceGenContext? ctx = context; + while (ctx is not null) + { + if (ctx.Types.TryGetValue(dataTypeNode, out var symbol)) + { + return new BindingContextDataType( + BindingContextDataTypeKind.Resolved, + Symbol: symbol, + Origin: ownerNode, + IsInherited: false, + CrossedTemplateBoundary: false); + } + ctx = ctx.ParentContext; + } + + return Unresolved(ownerNode); + } + + // x:DataType="vm:MyViewModel" — string type name + if (dataTypeNode is ValueNode { Value: string dataTypeName }) + { + try + { + var dataType = TypeArgumentsParser.ParseSingle( + dataTypeName, ownerNode.NamespaceResolver, dataTypeNode as IXmlLineInfo); + + if (dataType is not null + && dataType.TryResolveTypeSymbol(null, context.Compilation, context.XmlnsCache, context.TypeCache, out INamedTypeSymbol? resolved) + && resolved is not null) + { + return new BindingContextDataType( + BindingContextDataTypeKind.Resolved, + Symbol: resolved, + Origin: ownerNode, + IsInherited: false, + CrossedTemplateBoundary: false); + } + } + catch (XamlParseException) + { + // Fall through to Unresolved + } + } + + return Unresolved(ownerNode); + } + + private static BindingContextDataType Unresolved(ElementNode ownerNode) + => new(BindingContextDataTypeKind.Unresolved, Symbol: null, Origin: ownerNode, IsInherited: false, CrossedTemplateBoundary: false); + + private bool DoesNotInheritDataType(ElementNode node) + { + return GetParentElement(node) is ElementNode parentNode + && parentNode.XmlType.TryResolveTypeSymbol(null, context.Compilation, context.XmlnsCache, context.TypeCache, out INamedTypeSymbol? parentTypeSymbol) + && parentTypeSymbol is not null + && node.TryGetPropertyName(parentNode, out XmlName propertyName) + && parentTypeSymbol.GetAllProperties(propertyName.LocalName, context).FirstOrDefault() is IPropertySymbol propertySymbol + && propertySymbol.GetAttributes().Any(a => a.AttributeClass?.ToFQDisplayString() == "global::Microsoft.Maui.Controls.Xaml.DoesNotInheritDataTypeAttribute"); + } + + private static ElementNode? GetParentElement(ElementNode node) + { + var parent = node.Parent; + if (parent is ListNode listNode) + return listNode.Parent as ElementNode; + if (parent is ElementNode elementNode) + return elementNode; + return null; + } +} diff --git a/src/Controls/src/SourceGen/Visitors/SetPropertiesVisitor.cs b/src/Controls/src/SourceGen/Visitors/SetPropertiesVisitor.cs index 1291cb5f05e4..84b8a392f1d0 100644 --- a/src/Controls/src/SourceGen/Visitors/SetPropertiesVisitor.cs +++ b/src/Controls/src/SourceGen/Visitors/SetPropertiesVisitor.cs @@ -1,3 +1,4 @@ +using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Linq; @@ -35,11 +36,59 @@ class SetPropertiesVisitor(SourceGenContext context, bool stopOnResourceDictiona public bool SkipChildren(INode node, INode parentNode) => node is ElementNode en && en.IsLazyResource(parentNode, Context); public bool IsResourceDictionary(ElementNode node) => node.IsResourceDictionary(Context); + // Track properties that have been set to detect duplicates + readonly Dictionary> setProperties = new Dictionary>(); + + void CheckForDuplicateProperty(ElementNode parentNode, XmlName propertyName, IXmlLineInfo lineInfo) + { + if (!setProperties.TryGetValue(parentNode, out var props)) + { + props = new HashSet(); + setProperties[parentNode] = props; + } + + if (!props.Add(propertyName)) + { + var propertyDisplayName = $"{parentNode.XmlType.Name}.{propertyName.LocalName}"; + var location = LocationCreate(Context.ProjectItem.RelativePath!, lineInfo, propertyDisplayName); + context.ReportDiagnostic(Diagnostic.Create(Descriptors.DuplicatePropertyAssignment, location, propertyDisplayName)); + } + } + + /// + /// Resolves the type of an implicit content property and, when the property is a non-collection + /// non-Object type, calls to emit a diagnostic if it has + /// already been assigned. + /// + /// XmlName used for the GetBindableProperty lookup (namespace may differ from ). + /// XmlName used as the deduplication key (always uses the parent element's namespace). + void CheckImplicitContentForDuplicate( + ILocalValue parentVar, + ElementNode parentNode, + XmlName lookupName, + XmlName trackingName, + IXmlLineInfo lineInfo) + { + bool attached = false; + var localName = lookupName.LocalName; + var bpFieldSymbol = parentVar.Type.GetBindableProperty(lookupName.NamespaceURI, ref localName, out attached, context, lineInfo); + ITypeSymbol? propertyType = null; + + bool hasProperty = (bpFieldSymbol != null && SetPropertyHelpers.CanGetValue(parentVar, bpFieldSymbol, attached, context, out propertyType)) + || SetPropertyHelpers.CanGet(parentVar, localName, context, out propertyType, out _); + + bool isObject = propertyType != null && propertyType.SpecialType == SpecialType.System_Object; + if (hasProperty && propertyType != null && !isObject && !propertyType.CanAdd(context)) + CheckForDuplicateProperty(parentNode, trackingName, lineInfo); + } + public void Visit(ValueNode node, INode parentNode) { //TODO support Label text as element // if it's implicit content property, get the content property name + bool isImplicitContentProperty = false; + ILocalValue? parentVar = null; if (!node.TryGetPropertyName(parentNode, out XmlName propertyName)) { if (!parentNode.IsCollectionItem(node)) @@ -47,9 +96,12 @@ public void Visit(ValueNode node, INode parentNode) string? contentProperty; if (!Context.Variables.ContainsKey((ElementNode)parentNode)) return; - var parentVariable = Context.Variables[(ElementNode)parentNode]; - if ((contentProperty = parentVariable.Type.GetContentPropertyName(context)) != null) + parentVar = Context.Variables[(ElementNode)parentNode]; + if ((contentProperty = parentVar.Type.GetContentPropertyName(context)) != null) + { propertyName = new XmlName(((ElementNode)parentNode).NamespaceURI, contentProperty); + isImplicitContentProperty = true; + } else return; } @@ -63,13 +115,12 @@ public void Visit(ValueNode node, INode parentNode) if (propertyName.Equals(XmlName.mcIgnorable)) return; - // Check that parent has a variable before accessing - var parentElement = (ElementNode)parentNode; - if (!Context.Variables.TryGetValue(parentElement, out var parentVar)) - { - var parentKey = parentElement.Properties.TryGetValue(XmlName.xKey, out var keyNode) && keyNode is ValueNode vn ? vn.Value?.ToString() : "(none)"; - throw new System.InvalidOperationException($"SetPropertiesVisitor.Visit(ValueNode): Parent '{parentElement.XmlType.Name}' x:Key='{parentKey}' not in Variables for propertyName='{propertyName}'"); - } + parentVar ??= Context.Variables.TryGetValue((ElementNode)parentNode, out var pv) ? pv : null; + // Parent element not yet in Variables — can occur for markup extensions or nodes + // processed before their parent is fully initialized. Silently skip; the assignment + // will be handled through another visitor pass. + if (parentVar == null) + return; // Try to set runtime name (for x:Name with RuntimeNameProperty attribute) if (TrySetRuntimeName(propertyName, parentVar, node)) @@ -79,6 +130,10 @@ public void Visit(ValueNode node, INode parentNode) if (propertyName == XmlName.xName) return; + // Check for duplicate property assignment for implicit content properties + if (isImplicitContentProperty) + CheckImplicitContentForDuplicate(parentVar, (ElementNode)parentNode, propertyName, propertyName, node); + SetPropertyHelpers.SetPropertyValue(Writer, parentVar, propertyName, node, Context); } @@ -182,9 +237,22 @@ public void Visit(ElementNode node, INode parentNode) ParentContext = context, }; + // Seed the template context with the parent's BindingContextDataType for the template + // root node. This preserves the CrossedTemplateBoundary flag so that bindings inside + // the template that inherit from outside get the correct warning. + if (context.BindingContextDataTypes.TryGetValue(parentNode, out var parentDataType) + && parentDataType.Kind == BindingContextDataTypeKind.Resolved) + { + templateContext.BindingContextDataTypes[parentNode] = parentDataType with + { + CrossedTemplateBoundary = true, + }; + } + //inflate the template node.Accept(new CreateValuesVisitor(templateContext), null); node.Accept(new SetNamescopesAndRegisterNamesVisitor(templateContext), null); + node.Accept(new PropagateDataTypeVisitor(templateContext), null); // node.Accept(new SetFieldVisitor(templateContext), null); node.Accept(new SetResourcesVisitor(templateContext), null); node.Accept(new SetPropertiesVisitor(templateContext, stopOnResourceDictionary: true), null); @@ -207,6 +275,7 @@ public void Visit(ElementNode node, INode parentNode) var key1 = ((ElementNode)parentNode).Properties.TryGetValue(XmlName.xKey, out var kn1) && kn1 is ValueNode vn1 ? vn1.Value?.ToString() : "(none)"; throw new System.InvalidOperationException($"SetPropertiesVisitor.Visit(ElementNode) propertyName != Empty: Parent '{((ElementNode)parentNode).XmlType.Name}' x:Key='{key1}' not in Variables"); } + SetPropertyHelpers.SetPropertyValue(Writer, pVar1, propertyName, node, Context); } else if (parentNode.IsCollectionItem(node) && parentNode is ElementNode) @@ -225,8 +294,14 @@ public void Visit(ElementNode node, INode parentNode) var name = new XmlName(node.NamespaceURI, contentProperty); if (skips.Contains(name)) return; - if (parentNode is ElementNode node1 && node1.SkipProperties.Contains(propertyName)) + if (parentNode is ElementNode node1 && node1.SkipProperties.Contains(name)) return; + + // Only check for duplicate property assignment if the property is not a collection + // Use parent namespace for duplicate tracking to match how explicit property assignments are keyed + var contentPropertyName = new XmlName(((ElementNode)parentNode).NamespaceURI, contentProperty); + CheckImplicitContentForDuplicate(parentVar, (ElementNode)parentNode, name, contentPropertyName, node); + SetPropertyHelpers.SetPropertyValue(Writer, parentVar, name, node, Context); } else if (parentVar.Type.CanAdd(context)) diff --git a/src/Controls/src/SourceGen/xlf/MauiGResources.Designer.cs b/src/Controls/src/SourceGen/xlf/MauiGResources.Designer.cs index a6ceac0485d0..2d51af07e9c0 100644 --- a/src/Controls/src/SourceGen/xlf/MauiGResources.Designer.cs +++ b/src/Controls/src/SourceGen/xlf/MauiGResources.Designer.cs @@ -267,9 +267,10 @@ internal static string DuplicateTypeError // Namescope diagnostics internal static string NamescopeError => ResourceManager.GetString("NamescopeError", resourceCulture); internal static string NamescopeDuplicate => ResourceManager.GetString("NamescopeDuplicate", resourceCulture); - // Event handler diagnostics internal static string MissingEventHandlerTitle => ResourceManager.GetString("MissingEventHandlerTitle", resourceCulture); internal static string MissingEventHandler => ResourceManager.GetString("MissingEventHandler", resourceCulture); + internal static string DuplicatePropertyAssignmentTitle => ResourceManager.GetString("DuplicatePropertyAssignmentTitle", resourceCulture); + internal static string DuplicatePropertyAssignmentMessage => ResourceManager.GetString("DuplicatePropertyAssignmentMessage", resourceCulture); } } diff --git a/src/Controls/tests/SourceGen.UnitTests/BindingDiagnosticsTests.cs b/src/Controls/tests/SourceGen.UnitTests/BindingDiagnosticsTests.cs index 61471531093e..cd520fe8e676 100644 --- a/src/Controls/tests/SourceGen.UnitTests/BindingDiagnosticsTests.cs +++ b/src/Controls/tests/SourceGen.UnitTests/BindingDiagnosticsTests.cs @@ -298,11 +298,62 @@ public class ItemModel .AddSyntaxTrees(Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(csharp)); var result = RunGenerator(compilation, new AdditionalXamlFile("Test.xaml", xaml), assertNoCompilationErrors: false); - // x:Reference bindings skip compilation entirely — no MAUIG2045 should be emitted - // for properties on the DataTemplate's x:DataType (ItemModel). + // x:Reference resolves to ContentPage; BindingContext is 'object', so SelectItemCommand + // can't be resolved statically. MAUIG2045 is suppressed for x:Reference bindings. Assert.DoesNotContain(result.Diagnostics, d => d.Id == "MAUIG2045" && d.GetMessage().Contains("ItemModel", StringComparison.Ordinal)); } + [Fact] + public void BindingWithXReferenceToNonRootElement_ResolvesCorrectType() + { + var xaml = +""" + + + + + +"""; + + var csharp = +""" +namespace Test; + +public partial class TestPage : Microsoft.Maui.Controls.ContentPage { } + +public class ViewModel +{ + public System.Collections.Generic.List Items { get; set; } +} + +public class ItemModel +{ + public string Name { get; set; } +} +"""; + + var compilation = CreateMauiCompilation() + .AddSyntaxTrees(Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(csharp)); + var result = RunGenerator(compilation, new AdditionalXamlFile("Test.xaml", xaml), assertNoCompilationErrors: false); + + // Path=Text resolves against Label (the x:Reference target), not ItemModel (x:DataType). + // Label.Text exists, so there should be no MAUIG2045 at all. + Assert.DoesNotContain(result.Diagnostics, d => d.Id == "MAUIG2045"); + } + [Fact] public void BindingIndexerTypeUnsupported_ReportsCorrectDiagnostic() { diff --git a/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/MultipleChildrenWarningTests.cs b/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/MultipleChildrenWarningTests.cs new file mode 100644 index 000000000000..f7553c4e585c --- /dev/null +++ b/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/MultipleChildrenWarningTests.cs @@ -0,0 +1,193 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.Maui.Controls.SourceGen.UnitTests; + +public class MultipleChildrenWarningTests : SourceGenXamlInitializeComponentTestBase +{ + [Fact] + public void BorderWithMultipleChildren_EmitsWarning() + { + var xaml = +""" + + + + + +""" +; + + var code = +""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace Test; + +[XamlProcessing(XamlInflator.SourceGen)] +partial class TestPage : ContentPage +{ + public TestPage() + { + InitializeComponent(); + } +} +""" +; + + var (result, generated) = RunGenerator(xaml, code); + + // Verify that a warning diagnostic was emitted + var warnings = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Warning).ToArray(); + Assert.True(warnings.Length > 0, "Expected at least one warning diagnostic"); + + var multipleChildrenWarning = warnings.FirstOrDefault(d => d.Id == "MAUIX2015"); + Assert.NotNull(multipleChildrenWarning); + Assert.Contains("Border.Content", multipleChildrenWarning.GetMessage(), StringComparison.Ordinal); + Assert.Contains("multiple times", multipleChildrenWarning.GetMessage(), StringComparison.Ordinal); + } + + [Fact] + public void ContentPageWithMultipleChildren_EmitsWarning() + { + var xaml = +""" + + + +""" +; + + var code = +""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace Test; + +[XamlProcessing(XamlInflator.SourceGen)] +partial class TestPage : ContentPage +{ + public TestPage() + { + InitializeComponent(); + } +} +""" +; + + var (result, generated) = RunGenerator(xaml, code); + + // Verify that a warning diagnostic was emitted + var warnings = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Warning).ToArray(); + Assert.True(warnings.Length > 0, "Expected at least one warning diagnostic"); + + var multipleChildrenWarning = warnings.FirstOrDefault(d => d.Id == "MAUIX2015"); + Assert.NotNull(multipleChildrenWarning); + Assert.Contains("ContentPage.Content", multipleChildrenWarning.GetMessage(), StringComparison.Ordinal); + } + + [Fact] + public void BorderWithSingleChild_NoWarning() + { + var xaml = +""" + + + + + +""" +; + + var code = +""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace Test; + +[XamlProcessing(XamlInflator.SourceGen)] +partial class TestPage : ContentPage +{ + public TestPage() + { + InitializeComponent(); + } +} +""" +; + + var (result, generated) = RunGenerator(xaml, code); + + // Verify that NO warning diagnostic was emitted for single child + var multipleChildrenWarning = result.Diagnostics.FirstOrDefault(d => d.Id == "MAUIX2015"); + Assert.Null(multipleChildrenWarning); + } + + [Fact] + public void StackLayoutWithMultipleChildren_NoWarning() + { + var xaml = +""" + + + + + +""" +; + + var code = +""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace Test; + +[XamlProcessing(XamlInflator.SourceGen)] +partial class TestPage : ContentPage +{ + public TestPage() + { + InitializeComponent(); + } +} +""" +; + + var (result, generated) = RunGenerator(xaml, code); + + // VerticalStackLayout is a collection type, so multiple children are valid + var multipleChildrenWarning = result.Diagnostics.FirstOrDefault(d => d.Id == "MAUIX2015"); + Assert.Null(multipleChildrenWarning); + } +} diff --git a/src/Controls/tests/SourceGen.UnitTests/XReferenceBindingTests.cs b/src/Controls/tests/SourceGen.UnitTests/XReferenceBindingTests.cs new file mode 100644 index 000000000000..e7571d4ca466 --- /dev/null +++ b/src/Controls/tests/SourceGen.UnitTests/XReferenceBindingTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.Maui.Controls.SourceGen; +using Xunit; + +using static Microsoft.Maui.Controls.Xaml.UnitTests.SourceGen.SourceGeneratorDriver; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests.SourceGen; + +public class XReferenceBindingTests : SourceGenTestsBase +{ + private record AdditionalXamlFile(string Path, string Content, string? RelativePath = null, string? TargetPath = null, string? ManifestResourceName = null, string? TargetFramework = null, string? NoWarn = null) + : AdditionalFile(Text: SourceGeneratorDriver.ToAdditionalText(Path, Content), Kind: "Xaml", RelativePath: RelativePath ?? Path, TargetPath: TargetPath, ManifestResourceName: ManifestResourceName, TargetFramework: TargetFramework, NoWarn: NoWarn); + + [Fact] + public void XReferenceDirectProperty_DoesNotFallBackSilently() + { + // Binding to a direct property on a referenced element (e.g., Slider.Value) + // should attempt compilation. If the property exists on the element type, + // no MAUIG2045 diagnostic should be emitted. + var xaml = +""" + + + + + + +"""; + + var csharp = +""" +namespace Test; + +public partial class TestPage : Microsoft.Maui.Controls.ContentPage { } +"""; + + var compilation = CreateMauiCompilation() + .AddSyntaxTrees(Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(csharp)); + var result = RunGenerator(compilation, new AdditionalXamlFile("Test.xaml", xaml), assertNoCompilationErrors: false); + + // Value exists on Slider — should not report property not found + Assert.DoesNotContain(result.Diagnostics, d => d.Id == "MAUIG2045"); + } + + [Fact] + public void XReferenceInDataTemplate_DoesNotReportFalsePositive() + { + // x:Reference bindings inside DataTemplates should NOT produce MAUIG2045 + // for the DataTemplate's x:DataType + var xaml = +""" + + + + + +