diff --git a/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs b/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs index 1a8e2416e32c..bdb2b1932a0b 100644 --- a/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs +++ b/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs @@ -106,7 +106,7 @@ PrePost newblock() => var methodName = genSwitch ? "InitializeComponentSourceGen" : "InitializeComponent"; codeWriter.WriteLine($"private partial void {methodName}()"); root!.XmlType.TryResolveTypeSymbol(null, compilation, xmlnsCache, typeCache, out var baseType); - var sgcontext = new SourceGenContext(codeWriter, compilation, sourceProductionContext, xmlnsCache, typeCache, rootType!, baseType, xamlItem.ProjectItem); + var sgcontext = new SourceGenContext(codeWriter, compilation, sourceProductionContext, xmlnsCache, typeCache, rootType!, baseType, xamlItem.ProjectItem, sourceProductionContext.ReportDiagnostic); // Compute stable node IDs before Visit() mutates the tree (markup expansion etc.) // Use cached effective IDs (from state) if available, to stay consistent with UC patches. diff --git a/src/Controls/src/SourceGen/SetPropertyHelpers.cs b/src/Controls/src/SourceGen/SetPropertyHelpers.cs index 7c0cc148c6e4..c484140e87ce 100644 --- a/src/Controls/src/SourceGen/SetPropertyHelpers.cs +++ b/src/Controls/src/SourceGen/SetPropertyHelpers.cs @@ -229,7 +229,8 @@ public static void AddLazyResourceToResourceDictionary(IndentedTextWriter writer context.TypeCache, context.RootType, null, - context.ProjectItem) + context.ProjectItem, + context.ReportDiagnostic) { ParentContext = context }; diff --git a/src/Controls/src/SourceGen/SourceGenContext.cs b/src/Controls/src/SourceGen/SourceGenContext.cs index f79109e0cf45..636ebd9347d0 100644 --- a/src/Controls/src/SourceGen/SourceGenContext.cs +++ b/src/Controls/src/SourceGen/SourceGenContext.cs @@ -9,9 +9,12 @@ namespace Microsoft.Maui.Controls.SourceGen; -class SourceGenContext(IndentedTextWriter writer, Compilation compilation, SourceProductionContext sourceProductionContext, AssemblyAttributes assemblyCaches, IDictionary typeCache, ITypeSymbol rootType, ITypeSymbol? baseType, ProjectItem projectItem) +class SourceGenContext(IndentedTextWriter writer, Compilation compilation, SourceProductionContext sourceProductionContext, AssemblyAttributes assemblyCaches, IDictionary typeCache, ITypeSymbol rootType, ITypeSymbol? baseType, ProjectItem projectItem, Action diagnosticReporter) { - internal static SourceGenContext CreateNewForTests() => new SourceGenContext( + static readonly Action s_noOpDiagnosticReporter = static _ => { }; + List? _bufferedDiagnostics; + + internal static SourceGenContext CreateNewForTests(Action? diagnosticReporter = null) => new SourceGenContext( null!, null!, default, @@ -19,7 +22,8 @@ class SourceGenContext(IndentedTextWriter writer, Compilation compilation, Sourc new Dictionary(), null!, null, - null!); + null!, + diagnosticReporter ?? s_noOpDiagnosticReporter); public SourceProductionContext SourceProductionContext => sourceProductionContext; public IndentedTextWriter Writer => writer; @@ -34,6 +38,12 @@ class SourceGenContext(IndentedTextWriter writer, Compilation compilation, Sourc public IDictionary Variables { get; } = new Dictionary(); public void ReportDiagnostic(Diagnostic diagnostic) { + if (ParentContext is not null) + { + ParentContext.ReportDiagnostic(diagnostic); + return; + } + // Check if this diagnostic should be suppressed based on NoWarn var noWarn = ProjectItem?.NoWarn; if (!string.IsNullOrEmpty(noWarn)) @@ -51,8 +61,42 @@ public void ReportDiagnostic(Diagnostic diagnostic) } } } - sourceProductionContext.ReportDiagnostic(diagnostic); + + if (_bufferedDiagnostics is not null) + { + _bufferedDiagnostics.Add(diagnostic); + return; + } + + ReportDiagnosticCore(diagnostic); } + + internal int BufferedDiagnosticCount => _bufferedDiagnostics?.Count ?? 0; + + internal void BeginDiagnosticBuffering() + { + if (_bufferedDiagnostics is not null) + throw new InvalidOperationException("Diagnostic buffering is already active."); + + _bufferedDiagnostics = []; + } + + internal void FlushBufferedDiagnostics() + { + if (_bufferedDiagnostics is null) + throw new InvalidOperationException("Diagnostic buffering is not active."); + + var diagnostics = _bufferedDiagnostics; + _bufferedDiagnostics = null; + foreach (var diagnostic in diagnostics) + ReportDiagnosticCore(diagnostic); + } + + internal void DiscardBufferedDiagnostics() => _bufferedDiagnostics = null; + + void ReportDiagnosticCore(Diagnostic diagnostic) + => diagnosticReporter(diagnostic); + public IDictionary ServiceProviders { get; } = new Dictionary(); public IDictionary namesInScope)> Scopes = new Dictionary)>(); public SourceGenContext? ParentContext { get; set; } diff --git a/src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs b/src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs index 5cea9bdcd739..96d4b28b1147 100644 --- a/src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs +++ b/src/Controls/src/SourceGen/UpdateComponentCodeWriter.cs @@ -582,9 +582,218 @@ static void EmitNewElementProperties( TryEmitMarkupNodeChange(codeWriter, syntheticDiff, typeSymbol, varName, isRoot: false, compilation, xmlnsCache, typeCache, rootType, sourceProductionContext, projectItem); } + else if (kvp.Value is ElementNode or ListNode) + { + if (!TryEmitNewElementComplexProperty( + codeWriter, + element, + kvp.Value, + varName, + typeSymbol, + compilation, + xmlnsCache, + typeCache, + rootType, + sourceProductionContext, + projectItem)) + { + codeWriter.WriteLine($"// Complex property '{kvp.Key.LocalName}' ({kvp.Value.GetType().Name}) — skipped (not yet supported)"); + } + } + } + } + + /// + /// Emits an element-valued property while constructing a new element. This deliberately uses + /// the InitializeComponent visitor pipeline; mutations on existing elements continue through + /// and retain their narrower supported surface. + /// + static bool TryEmitNewElementComplexProperty( + IndentedTextWriter codeWriter, + ElementNode element, + INode propertyNode, + string varName, + INamedTypeSymbol typeSymbol, + Compilation compilation, + AssemblyAttributes xmlnsCache, + IDictionary typeCache, + INamedTypeSymbol rootType, + SourceProductionContext sourceProductionContext, + ProjectItem? projectItem) + { + // This speculative path does not run SetResourcesVisitor, so accepting resources here + // would emit an incomplete subtree. + if (ContainsInlineResources(propertyNode)) + return false; + + var preflightDiagnosticContext = CreateConversionContext( + compilation, + sourceProductionContext, + xmlnsCache, + typeCache, + rootType, + projectItem); + preflightDiagnosticContext.BeginDiagnosticBuffering(); + bool containsStaticResource; + try + { + containsStaticResource = ContainsStaticResourceReference(propertyNode, markup => ExpandMarkupForUC( + markup, + compilation, + xmlnsCache, + typeCache, + rootType, + sourceProductionContext, + projectItem, + preflightDiagnosticContext.ReportDiagnostic)); + } + finally + { + preflightDiagnosticContext.DiscardBufferedDiagnostics(); + } + + if (containsStaticResource) + return false; + + using var captureStringWriter = new StringWriter(CultureInfo.InvariantCulture); + using var captureWriter = new IndentedTextWriter(captureStringWriter, "\t") { NewLine = NewLine }; + var context = CreateConversionContext( + compilation, + sourceProductionContext, + xmlnsCache, + typeCache, + rootType, + projectItem, + captureWriter); + context.Variables[element] = new DirectValue(typeSymbol, varName); + context.BeginDiagnosticBuffering(); + + try + { + propertyNode.Accept(new CreateValuesVisitor(context), element); + propertyNode.Accept(new SetPropertiesVisitor(context, stopOnResourceDictionary: true), element); + } + catch (InvalidOperationException) + { + context.DiscardBufferedDiagnostics(); + return false; + } + + captureWriter.Flush(); + var capturedCode = captureStringWriter.ToString(); + if (string.IsNullOrWhiteSpace(capturedCode)) + { + context.DiscardBufferedDiagnostics(); + return false; + } + + codeWriter.WriteLine("{"); + codeWriter.Indent++; + foreach (var line in capturedCode.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) + { + if (!string.IsNullOrEmpty(line)) + codeWriter.WriteLine(line); + } + + foreach (var localMethod in context.LocalMethods) + { + codeWriter.WriteLine(); + foreach (var line in localMethod.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) + { + if (string.IsNullOrWhiteSpace(line)) + codeWriter.InnerWriter.WriteLine(); + else + codeWriter.WriteLine(line); + } } + + codeWriter.Indent--; + codeWriter.WriteLine("}"); + context.FlushBufferedDiagnostics(); + return true; + } + + static bool ContainsInlineResources(INode node) + { + if (node is ElementNode element) + { + if (element.XmlType.Name == "ResourceDictionary") + return true; + + foreach (var property in element.Properties) + { + if (property.Key.LocalName == "Resources" + || property.Key.LocalName.EndsWith(".Resources", StringComparison.Ordinal) + || ContainsInlineResources(property.Value)) + { + return true; + } + } + + foreach (var child in element.CollectionItems) + { + if (ContainsInlineResources(child)) + return true; + } + + return false; + } + + if (node is ListNode list) + { + foreach (var child in list.CollectionItems) + { + if (ContainsInlineResources(child)) + return true; + } + } + + return false; } + static bool ContainsStaticResourceReference(INode node, Func expandMarkup) + { + if (node is MarkupNode markup) + { + var expanded = expandMarkup(markup); + return expanded is not null && ContainsStaticResourceReference(expanded, expandMarkup); + } + + if (node is ElementNode element) + { + if (IsStaticResourceExtension(element.XmlType.Name)) + return true; + + foreach (var property in element.Properties.Values) + { + if (ContainsStaticResourceReference(property, expandMarkup)) + return true; + } + + foreach (var child in element.CollectionItems) + { + if (ContainsStaticResourceReference(child, expandMarkup)) + return true; + } + + return false; + } + + if (node is ListNode list) + { + foreach (var child in list.CollectionItems) + { + if (ContainsStaticResourceReference(child, expandMarkup)) + return true; + } + } + + return false; + } + + static bool IsStaticResourceExtension(string typeName) => + typeName is "StaticResource" or "StaticResourceExtension"; + /// /// Recursively creates children for a newly created element, handling both /// Layout containers (Children.Add) and Content containers (Content property). @@ -1280,12 +1489,20 @@ static bool TryEmitMarkupNodeChange( IDictionary typeCache, INamedTypeSymbol rootType, SourceProductionContext sourceProductionContext, - ProjectItem? projectItem) + ProjectItem? projectItem, + Action? diagnosticReporter = null) { var markupString = markupNode.MarkupString; // Build a minimal SourceGenContext for ExpandMarkupsVisitor's parser - var ctx = CreateConversionContext(compilation, sourceProductionContext, xmlnsCache, typeCache, rootType, projectItem); + var ctx = CreateConversionContext( + compilation, + sourceProductionContext, + xmlnsCache, + typeCache, + rootType, + projectItem, + diagnosticReporter: diagnosticReporter); // Classification: expression or markup extension? bool TryResolveMarkup(string name) @@ -1775,12 +1992,14 @@ static SourceGenContext CreateConversionContext( IDictionary typeCache, INamedTypeSymbol rootType, ProjectItem? projectItem, - IndentedTextWriter? writer = null) + IndentedTextWriter? writer = null, + Action? diagnosticReporter = null) { var pi = projectItem ?? new ProjectItem(EmptyAdditionalText.Instance, EmptyConfigOptions.Instance); + diagnosticReporter ??= sourceProductionContext.ReportDiagnostic; return new SourceGenContext( writer ?? new IndentedTextWriter(new StringWriter()), compilation, sourceProductionContext, - xmlnsCache, typeCache, rootType, rootType.BaseType, pi); + xmlnsCache, typeCache, rootType, rootType.BaseType, pi, diagnosticReporter); } } diff --git a/src/Controls/src/SourceGen/Visitors/SetPropertiesVisitor.cs b/src/Controls/src/SourceGen/Visitors/SetPropertiesVisitor.cs index 4e4ddbd74f28..46da5f237a73 100644 --- a/src/Controls/src/SourceGen/Visitors/SetPropertiesVisitor.cs +++ b/src/Controls/src/SourceGen/Visitors/SetPropertiesVisitor.cs @@ -278,7 +278,7 @@ public void Visit(ElementNode node, INode parentNode) Writer.WriteLine($"object {methodName}()"); using (PrePost.NewBlock(Writer, begin: "{", end: "}")) { - var templateContext = new SourceGenContext(Writer, context.Compilation, context.SourceProductionContext, context.XmlnsCache, context.TypeCache, context.RootType!, null, context.ProjectItem) + var templateContext = new SourceGenContext(Writer, context.Compilation, context.SourceProductionContext, context.XmlnsCache, context.TypeCache, context.RootType!, null, context.ProjectItem, context.ReportDiagnostic) { ParentContext = context, }; @@ -298,7 +298,7 @@ public void Visit(ElementNode node, INode parentNode) Writer.WriteLine($"{variable.ValueAccessor}.LoadTemplate = () =>"); using (PrePost.NewBlock(Writer, begin: "{", end: "};")) { - var templateContext = new SourceGenContext(Writer, context.Compilation, context.SourceProductionContext, context.XmlnsCache, context.TypeCache, context.RootType!, null, context.ProjectItem) + var templateContext = new SourceGenContext(Writer, context.Compilation, context.SourceProductionContext, context.XmlnsCache, context.TypeCache, context.RootType!, null, context.ProjectItem, context.ReportDiagnostic) { ParentContext = context, }; diff --git a/src/Controls/tests/SourceGen.UnitTests/HotReload/AiAssisted/BindingAndMarkupHotReloadTests.Bindings.cs b/src/Controls/tests/SourceGen.UnitTests/HotReload/AiAssisted/BindingAndMarkupHotReloadTests.Bindings.cs new file mode 100644 index 000000000000..f28ae7dfd2a7 --- /dev/null +++ b/src/Controls/tests/SourceGen.UnitTests/HotReload/AiAssisted/BindingAndMarkupHotReloadTests.Bindings.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections; +using System.ComponentModel; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.Maui.Controls.SourceGen.UnitTests.HotReload; +using Xunit; + +namespace Microsoft.Maui.Controls.SourceGen.UnitTests.HotReload.AiAssisted; + +public partial class BindingAndMarkupHotReloadTests +{ + // Wave2 · Binding & Markup · P0-01 · BM-01 + // Provenance: MAUI §3.4 | portfolio P0-01 + // Faithfulness: reaches writer L1548 for DynamicResource and Binding markup nodes; fails-for-bug: markup swap does not replace the prior value source. + // Issue: https://github.com/dotnet/maui/issues/36732 + [MetadataUpdateFact] + public void DynamicResourceToBinding_SwapAndReverse_UpdatesVisibleValue() + { + const string xamlV1 = """ + + + + Resource-V1 + + + + + + + """; + const string xamlV2 = """ + + + + Resource-V1 + + + + + + + """; + const string xamlV3 = """ + + + + Resource-V3 + + + + + + + """; + + using var harness = CreateHarness(); + var generation = harness.Generate(xamlV1, xamlV2, xamlV3); + + harness.RunLive(generation, live => + { + var page = live.GetInstance(); + var label = Assert.IsType