Improve XEXPR diagnostics for interpolated strings and lambda method groups#35093
Improve XEXPR diagnostics for interpolated strings and lambda method groups#35093StephaneDelcroix wants to merge 1 commit intomainfrom
Conversation
…groups
- Report MAUIX2009 when interpolated string holes reference nonexistent
members (e.g., {$'Hello, {Name1}!'} where Name1 doesn't exist),
instead of falling through to a C# compilation error.
- Add MAUIX2017 diagnostic when lambda event handler body is a method
group reference instead of an invocation (e.g., {(s, e) => this.OnClicked}
missing parentheses), with a suggestion to add them.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35093Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35093" |
There was a problem hiding this comment.
Pull request overview
This PR improves .NET MAUI XAML C# expression (XEXPR) source-generator diagnostics so that certain invalid expressions fail with MAUIX* diagnostics instead of falling through to cryptic C# compiler errors.
Changes:
- Added Roslyn-based extraction of identifiers from interpolated string holes to report MAUIX2009 at source generation time.
- Added a new MAUIX2017 diagnostic for event-handler lambdas whose body is a method group reference (missing invocation).
- Added unit tests and registered the new diagnostic in analyzer release notes.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Controls/tests/SourceGen.UnitTests/CSharpExpressionDiagnosticsTests.cs | Adds coverage for interpolated-string missing member and lambda method-group diagnostics. |
| src/Controls/src/SourceGen/SetPropertyHelpers.cs | Emits MAUIX2009 for missing members inside interpolated strings; emits MAUIX2017 for method-group lambda bodies in ConnectEvent. |
| src/Controls/src/SourceGen/Descriptors.cs | Introduces MAUIX2017 descriptor definition. |
| src/Controls/src/SourceGen/CSharpExpressionHelpers.cs | Adds helper methods to parse interpolated strings and detect method-group lambda bodies. |
| src/Controls/src/SourceGen/AnalyzerReleases.Unshipped.md | Registers MAUIX2017 in the unshipped analyzer release list. |
| // Validate identifiers inside interpolated string holes | ||
| if (expression.Code.StartsWith("$\"", StringComparison.Ordinal) || expression.Code.StartsWith("$@\"", StringComparison.Ordinal)) | ||
| { | ||
| var interpolatedIds = CSharpExpressionHelpers.ExtractInterpolatedStringIdentifiers(expression.Code); | ||
| foreach (var id in interpolatedIds) | ||
| { | ||
| var idResolution = MemberResolver.Resolve(id, context.RootType, dataTypeSymbol, context.Compilation); | ||
| if (idResolution.Location == MemberLocation.Neither) | ||
| { | ||
| var idLocation = LocationCreate(context.ProjectItem.RelativePath!, (IXmlLineInfo)valueNode, expression.Code); | ||
| context.ReportDiagnostic(Diagnostic.Create(Descriptors.MemberNotFound, idLocation, id, context.RootType?.Name ?? "this", dataTypeSymbol.Name)); | ||
| return true; // Handled (with error) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The interpolated-string identifier validation will incorrectly report MAUIX2009 for identifiers that are not members on the page/ViewModel but are still valid C# in interpolation holes (e.g., DateTime.Now, System.String, Math.PI). In those cases MemberResolver.Resolve(id, ...) returns Neither and this code turns otherwise-valid expressions into generator errors. Consider skipping identifiers that resolve to a type/namespace (via the Compilation) or performing semantic analysis on each interpolation hole so only actual member references against this/x:DataType are validated.
| if (expression.Code.StartsWith("$\"", StringComparison.Ordinal) || expression.Code.StartsWith("$@\"", StringComparison.Ordinal)) | ||
| { | ||
| var interpolatedIds = CSharpExpressionHelpers.ExtractInterpolatedStringIdentifiers(expression.Code); |
There was a problem hiding this comment.
This interpolated-string check only triggers for expression.Code starting with $" or $@". Valid interpolated strings can also start with @$" (and may have leading whitespace), so those cases will skip identifier validation and still fall through to C# compilation errors. Consider using TrimStart() plus handling @$", or (more robust) detecting interpolated-string syntax via Roslyn rather than string prefix checks.
| if (expression.Code.StartsWith("$\"", StringComparison.Ordinal) || expression.Code.StartsWith("$@\"", StringComparison.Ordinal)) | |
| { | |
| var interpolatedIds = CSharpExpressionHelpers.ExtractInterpolatedStringIdentifiers(expression.Code); | |
| var trimmedExpressionCode = expression.Code.TrimStart(); | |
| if (trimmedExpressionCode.StartsWith("$\"", StringComparison.Ordinal) || | |
| trimmedExpressionCode.StartsWith("$@\"", StringComparison.Ordinal) || | |
| trimmedExpressionCode.StartsWith("@$\"", StringComparison.Ordinal)) | |
| { | |
| var interpolatedIds = CSharpExpressionHelpers.ExtractInterpolatedStringIdentifiers(trimmedExpressionCode); |
| var parameters = string.Join(", ", lambda.ParameterList.Parameters.Select(p => p.Identifier.Text)); | ||
| return (memberAccess.ToString(), parameters); | ||
| } | ||
|
|
||
| // Also check for bare identifier (e.g., (s, e) => OnClicked) | ||
| if (body is IdentifierNameSyntax identifier) | ||
| { | ||
| var parameters = string.Join(", ", lambda.ParameterList.Parameters.Select(p => p.Identifier.Text)); | ||
| return (identifier.Identifier.Text, parameters); | ||
| } | ||
|
|
||
| return null; | ||
| } |
There was a problem hiding this comment.
DetectLambdaMethodGroupReference builds the suggested invocation argument list from the lambda parameter identifiers. If the lambda uses discard parameters (e.g. (_, _) => this.OnClicked) the diagnostic will suggest this.OnClicked(_, _), which does not compile because discards can’t be referenced. Consider omitting parameter names when any parameter is _, or generating safe placeholders (e.g. arg0, arg1) for the message.
| var parameters = string.Join(", ", lambda.ParameterList.Parameters.Select(p => p.Identifier.Text)); | |
| return (memberAccess.ToString(), parameters); | |
| } | |
| // Also check for bare identifier (e.g., (s, e) => OnClicked) | |
| if (body is IdentifierNameSyntax identifier) | |
| { | |
| var parameters = string.Join(", ", lambda.ParameterList.Parameters.Select(p => p.Identifier.Text)); | |
| return (identifier.Identifier.Text, parameters); | |
| } | |
| return null; | |
| } | |
| var parameters = GetLambdaInvocationArgumentList(lambda.ParameterList.Parameters); | |
| return (memberAccess.ToString(), parameters); | |
| } | |
| // Also check for bare identifier (e.g., (s, e) => OnClicked) | |
| if (body is IdentifierNameSyntax identifier) | |
| { | |
| var parameters = GetLambdaInvocationArgumentList(lambda.ParameterList.Parameters); | |
| return (identifier.Identifier.Text, parameters); | |
| } | |
| return null; | |
| } | |
| static string GetLambdaInvocationArgumentList(SeparatedSyntaxList<ParameterSyntax> parameters) | |
| { | |
| var parameterNames = parameters.Select(p => p.Identifier.Text).ToArray(); | |
| if (parameterNames.Any(name => name == "_")) | |
| return string.Join(", ", parameterNames.Select((_, index) => $"arg{index}")); | |
| return string.Join(", ", parameterNames); | |
| } |
| Assert.Contains(result.Diagnostics, d => d.Id == "MAUIX2009"); | ||
| } | ||
|
|
||
| [Fact] |
There was a problem hiding this comment.
The new interpolated-string validation adds a new failure mode (false positives on static types/namespaces like DateTime.Now / System.String). Consider adding a unit test that asserts no MAUIX2009 is reported for an interpolated string that uses a static type in an interpolation hole, to prevent regressions once the validation logic is adjusted.
| [Fact] | |
| [Fact] | |
| public void InterpolatedStaticTypeReference_DoesNotReportMAUIX2009() | |
| { | |
| var xaml = | |
| """ | |
| <?xml version="1.0" encoding="utf-8" ?> | |
| <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" | |
| xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" | |
| xmlns:sys="clr-namespace:System;assembly=netstandard" | |
| x:Class="TestApp.InterpolatedStaticTypePage"> | |
| <Label Text="{Binding $`Now: {sys:DateTime.Now}`}" /> | |
| </ContentPage> | |
| """; | |
| var codeBehind = | |
| """ | |
| using Microsoft.Maui.Controls; | |
| using Microsoft.Maui.Controls.Xaml; | |
| namespace TestApp; | |
| [XamlProcessing(XamlInflator.SourceGen)] | |
| public partial class InterpolatedStaticTypePage : ContentPage | |
| { | |
| public InterpolatedStaticTypePage() => InitializeComponent(); | |
| } | |
| """; | |
| var (result, _) = RunGenerator(xaml, codeBehind, assertNoCompilationErrors: false); | |
| Assert.DoesNotContain(result.Diagnostics, d => d.Id == "MAUIX2009"); | |
| } | |
| [Fact] |
|
|
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description
Two XAML C# expression (XEXPR) scenarios currently produce C# compilation errors instead of source generator diagnostics, making errors harder to understand and fix. This PR catches both at source generation time with clear messages.
Issue 1: Interpolated string with nonexistent member
When
Name1doesn't exist on the page or ViewModel, the source gen previously emitted$"Hello, {__source.Name1}!"verbatim, resulting in a cryptic C# compilation error. Now MAUIX2009 is reported: "'Name1' not found on 'MyPage' or 'MyViewModel'."Root cause: The existing MAUIX2009 check only ran for "simple identifiers" (
IsSimpleIdentifierreturns false for interpolated strings due to$,{,}chars). The fix extracts identifiers from interpolation holes using Roslyn and validates each againstMemberResolver.Issue 2: Lambda with method group instead of invocation
Missing
()caused the source gen to emitbutton.Clicked += (s, e) => this.OnCounterClicked;— invalid C# (method group can't be expression body of void-returning lambda). Now MAUIX2017 is reported: "Lambda body 'this.OnCounterClicked' is a method group reference, not a method invocation. Did you mean 'this.OnCounterClicked(s, e)'?"Root cause:
ConnectEvent()emitted lambda expressions with zero validation. The fix parses the lambda body with Roslyn to detectMemberAccessExpressionwithout invocation.Changes
CSharpExpressionHelpers.csExtractInterpolatedStringIdentifiers()andDetectLambdaMethodGroupReference()helpersSetPropertyHelpers.csTryHandleExpressionBinding(); method group check inConnectEvent()Descriptors.csMAUIX2017diagnostic descriptorAnalyzerReleases.Unshipped.mdCSharpExpressionDiagnosticsTests.cs