Răsfoiți Sursa

Null conditional operator in bindings (#18270)

* Update ncrunch config.

* Fix test naming.

* Add BindingExpressionGrammar tests.

We only had tests for the error states, and that was named incorrectly.

* Parse null-conditionals in bindings.

* Initial impl of null conditionals in binding path.

Fixes #17029.

* Ensure that nothing is logged.

* Make "?." work when binding to methods.

* Don't add a new public API.

And add a comment reminding us to make this class internal for 12.0.

* Use existing method.
Steven Kirk 7 luni în urmă
părinte
comite
fb5121a42f

+ 5 - 0
.ncrunch/XEmbedSample.net8.0.v3.ncrunchproject

@@ -0,0 +1,5 @@
+<ProjectConfiguration>
+  <Settings>
+    <IgnoreThisComponentCompletely>True</IgnoreThisComponentCompletely>
+  </Settings>
+</ProjectConfiguration>

+ 5 - 0
.ncrunch/XEmbedSample.netstandard2.0.v3.ncrunchproject

@@ -0,0 +1,5 @@
+<ProjectConfiguration>
+  <Settings>
+    <IgnoreThisComponentCompletely>True</IgnoreThisComponentCompletely>
+  </Settings>
+</ProjectConfiguration>

+ 10 - 2
src/Avalonia.Base/Data/Core/ExpressionNodes/PropertyAccessorNode.cs

@@ -14,12 +14,14 @@ internal sealed class PropertyAccessorNode : ExpressionNode, IPropertyAccessorNo
 {
     private readonly Action<object?> _onValueChanged;
     private readonly IPropertyAccessorPlugin _plugin;
+    private readonly bool _acceptsNull;
     private IPropertyAccessor? _accessor;
     private bool _enableDataValidation;
 
-    public PropertyAccessorNode(string propertyName, IPropertyAccessorPlugin plugin)
+    public PropertyAccessorNode(string propertyName, IPropertyAccessorPlugin plugin, bool acceptsNull)
     {
         _plugin = plugin;
+        _acceptsNull = acceptsNull;
         _onValueChanged = OnValueChanged;
         PropertyName = propertyName;
     }
@@ -50,8 +52,14 @@ internal sealed class PropertyAccessorNode : ExpressionNode, IPropertyAccessorNo
     [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
     protected override void OnSourceChanged(object? source, Exception? dataValidationError)
     {
-        if (!ValidateNonNullSource(source))
+        if (source is null)
+        {
+            if (_acceptsNull)
+                SetValue(null);
+            else
+                ValidateNonNullSource(source);
             return;
+        }
 
         var reference = new WeakReference<object?>(source);
 

+ 10 - 2
src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/DynamicPluginPropertyAccessorNode.cs

@@ -14,12 +14,14 @@ namespace Avalonia.Data.Core.ExpressionNodes.Reflection;
 [RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)]
 internal sealed class DynamicPluginPropertyAccessorNode : ExpressionNode, IPropertyAccessorNode, ISettableNode
 {
+    private readonly bool _acceptsNull;
     private readonly Action<object?> _onValueChanged;
     private IPropertyAccessor? _accessor;
     private bool _enableDataValidation;
 
-    public DynamicPluginPropertyAccessorNode(string propertyName)
+    public DynamicPluginPropertyAccessorNode(string propertyName, bool acceptsNull)
     {
+        _acceptsNull = acceptsNull;
         _onValueChanged = OnValueChanged;
         PropertyName = propertyName;
     }
@@ -44,8 +46,14 @@ internal sealed class DynamicPluginPropertyAccessorNode : ExpressionNode, IPrope
 
     protected override void OnSourceChanged(object? source, Exception? dataValidationError)
     {
-        if (!ValidateNonNullSource(source))
+        if (source is null)
+        {
+            if (_acceptsNull)
+                SetValue(null);
+            else
+                ValidateNonNullSource(source);
             return;
+        }
 
         var reference = new WeakReference<object?>(source);
 

+ 2 - 2
src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs

@@ -69,7 +69,7 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
         switch (node.Member.MemberType)
         {
             case MemberTypes.Property:
-                return Add(node.Expression, node, new DynamicPluginPropertyAccessorNode(node.Member.Name));
+                return Add(node.Expression, node, new DynamicPluginPropertyAccessorNode(node.Member.Name, acceptsNull: false));
             default:
                 throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
         }
@@ -99,7 +99,7 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
         }
         else if (method == CreateDelegateMethod)
         {
-            var accessor = new DynamicPluginPropertyAccessorNode(GetValue<MethodInfo>(node.Object!).Name);
+            var accessor = new DynamicPluginPropertyAccessorNode(GetValue<MethodInfo>(node.Object!).Name, acceptsNull: false);
             return Add(node.Arguments[1], node, accessor);
         }
 

+ 15 - 0
src/Avalonia.Base/Utilities/CharacterReader.cs

@@ -2,6 +2,7 @@ using System;
 
 namespace Avalonia.Utilities
 {
+    // TODO12: This should not be public
 #if !BUILDTASK
     public
 #endif
@@ -46,6 +47,20 @@ namespace Avalonia.Utilities
             }
         }
 
+        internal bool TakeIf(string s)
+        {
+            var p = TryPeek(s.Length);
+
+            if (p.SequenceEqual(s.AsSpan()))
+            {
+                _s = _s.Slice(s.Length);
+                Position += s.Length;
+                return true;
+            }
+
+            return false;
+        }
+
         public bool TakeIf(Func<char, bool> condition)
         {
             if (condition(Peek))

+ 42 - 8
src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlBindingPathHelper.cs

@@ -207,11 +207,11 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
                             }
                             else if (GetAllDefinedProperties(targetType).FirstOrDefault(p => p.Name == propName.PropertyName) is IXamlProperty clrProperty)
                             {
-                                nodes.Add(new XamlIlClrPropertyPathElementNode(clrProperty));
+                                nodes.Add(new XamlIlClrPropertyPathElementNode(clrProperty, propName.AcceptsNull));
                             }
                             else if (GetAllDefinedMethods(targetType).FirstOrDefault(m => m.Name == propName.PropertyName) is IXamlMethod method)
                             {
-                                nodes.Add(new XamlIlClrMethodPathElementNode(method, context.Configuration.WellKnownTypes.Delegate));
+                                nodes.Add(new XamlIlClrMethodPathElementNode(method, context.Configuration.WellKnownTypes.Delegate, propName.AcceptsNull));
                             }
                             else
                             {
@@ -683,10 +683,12 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
         class XamlIlClrPropertyPathElementNode : IXamlIlBindingPathElementNode
         {
             private readonly IXamlProperty _property;
+            private readonly bool _acceptsNull;
 
-            public XamlIlClrPropertyPathElementNode(IXamlProperty property)
+            public XamlIlClrPropertyPathElementNode(IXamlProperty property, bool acceptsNull)
             {
                 _property = property;
+                _acceptsNull = acceptsNull;
             }
 
             public void Emit(XamlIlEmitContext context, IXamlILEmitter codeGen)
@@ -697,9 +699,23 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
                 context.Configuration.GetExtra<XamlIlPropertyInfoAccessorFactoryEmitter>()
                     .EmitLoadInpcPropertyAccessorFactory(context, codeGen);
 
+                // By default use the 2-argument overload of CompiledBindingPathBuilder.Property,
+                // unless a "?." null conditional operator appears in the path, in which case use
+                // the 3-parameter version with the `acceptsNull` parameter. This ensures we don't
+                // get a missing method exception if we run against an old version of Avalonia.
+                var methodArgumentCount = 2;
+
+                if (_acceptsNull)
+                {
+                    methodArgumentCount = 3;
+                    codeGen.Ldc_I4(1);
+                }
+
                 codeGen
                     .EmitCall(context.GetAvaloniaTypes()
-                        .CompiledBindingPathBuilder.GetMethod(m => m.Name == "Property"));
+                        .CompiledBindingPathBuilder.GetMethod(m => 
+                            m.Name == "Property" &&
+                            m.Parameters.Count == methodArgumentCount));
             }
 
             public IXamlType Type => _property.PropertyType;
@@ -707,11 +723,13 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
 
         class XamlIlClrMethodPathElementNode : IXamlIlBindingPathElementNode
         {
+            private readonly bool _acceptsNull;
 
-            public XamlIlClrMethodPathElementNode(IXamlMethod method, IXamlType systemDelegateType)
+            public XamlIlClrMethodPathElementNode(IXamlMethod method, IXamlType systemDelegateType, bool acceptsNull)
             {
                 Method = method;
                 Type = systemDelegateType;
+                _acceptsNull = acceptsNull;
             }
             public IXamlMethod Method { get; }
 
@@ -754,9 +772,25 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
 
                 codeGen
                     .Ldtoken(Method)
-                    .Ldtoken(specificDelegateType)
-                    .EmitCall(context.GetAvaloniaTypes()
-                        .CompiledBindingPathBuilder.GetMethod(m => m.Name == "Method"));
+                    .Ldtoken(specificDelegateType);
+
+                // By default use the 2-argument overload of CompiledBindingPathBuilder.Method,
+                // unless a "?." null conditional operator appears in the path, in which case use
+                // the 3-parameter version with the `acceptsNull` parameter. This ensures we don't
+                // get a missing method exception if we run against an old version of Avalonia.
+                var methodArgumentCount = 2;
+
+                if (_acceptsNull)
+                {
+                    methodArgumentCount = 3;
+                    codeGen.Ldc_I4(1);
+                }
+
+                codeGen.EmitCall(context.GetAvaloniaTypes()
+                    .CompiledBindingPathBuilder.GetMethod(m => 
+                        m.Name == "Method" &&
+                        m.Parameters.Count == methodArgumentCount));
+
 
                 newDelegateTypeBuilder?.CreateType();
             }

+ 40 - 6
src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindings/CompiledBindingPath.cs

@@ -35,7 +35,7 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings
                         node = null;
                         break;
                     case PropertyElement prop:
-                        node = new PropertyAccessorNode(prop.Property.Name, new PropertyInfoAccessorPlugin(prop.Property, prop.AccessorFactory));
+                        node = new PropertyAccessorNode(prop.Property.Name, new PropertyInfoAccessorPlugin(prop.Property, prop.AccessorFactory), prop.AcceptsNull);
                         break;
                     case MethodAsCommandElement methodAsCommand:
                         node = new MethodCommandNode(
@@ -45,7 +45,10 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings
                             methodAsCommand.DependsOnProperties);
                         break;
                     case MethodAsDelegateElement methodAsDelegate:
-                        node = new PropertyAccessorNode(methodAsDelegate.Method.Name, new MethodAccessorPlugin(methodAsDelegate.Method, methodAsDelegate.DelegateType));
+                        node = new PropertyAccessorNode(
+                            methodAsDelegate.Method.Name,
+                            new MethodAccessorPlugin(methodAsDelegate.Method, methodAsDelegate.DelegateType),
+                            methodAsDelegate.AcceptsNull);
                         break;
                     case ArrayElementPathElement arr:
                         node = new ArrayIndexerNode(arr.Indices);
@@ -132,15 +135,33 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings
             }
             else
             {
-                _elements.Add(new PropertyElement(info, accessorFactory, _elements.Count == 0));
+                return Property(info, accessorFactory, acceptsNull: false);
             }
 
             return this;
         }
 
+        public CompiledBindingPathBuilder Property(
+            IPropertyInfo info,
+            Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> accessorFactory,
+            bool acceptsNull)
+        {
+            _elements.Add(new PropertyElement(info, accessorFactory, _elements.Count == 0, acceptsNull));
+            return this;
+        }
+
         public CompiledBindingPathBuilder Method(RuntimeMethodHandle handle, RuntimeTypeHandle delegateType)
         {
-            _elements.Add(new MethodAsDelegateElement(handle, delegateType));
+            Method(handle, delegateType, acceptsNull: false);
+            return this;
+        }
+
+        public CompiledBindingPathBuilder Method(
+            RuntimeMethodHandle handle,
+            RuntimeTypeHandle delegateType,
+            bool acceptsNull)
+        {
+            _elements.Add(new MethodAsDelegateElement(handle, delegateType, acceptsNull));
             return this;
         }
 
@@ -228,34 +249,47 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings
     {
         private readonly bool _isFirstElement;
 
-        public PropertyElement(IPropertyInfo property, Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> accessorFactory, bool isFirstElement)
+        public PropertyElement(
+            IPropertyInfo property,
+            Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> accessorFactory,
+            bool isFirstElement,
+            bool acceptsNull)
         {
             Property = property;
             AccessorFactory = accessorFactory;
             _isFirstElement = isFirstElement;
+            AcceptsNull = acceptsNull;
         }
 
         public IPropertyInfo Property { get; }
 
         public Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> AccessorFactory { get; }
 
+        public bool AcceptsNull { get; }
+
         public override string ToString()
             => _isFirstElement ? Property.Name : $".{Property.Name}";
     }
 
     internal class MethodAsDelegateElement : ICompiledBindingPathElement
     {
-        public MethodAsDelegateElement(RuntimeMethodHandle method, RuntimeTypeHandle delegateType)
+        public MethodAsDelegateElement(
+            RuntimeMethodHandle method,
+            RuntimeTypeHandle delegateType,
+            bool acceptsNull)
         {
             Method = MethodBase.GetMethodFromHandle(method) as MethodInfo
                 ?? throw new ArgumentException("Invalid method handle", nameof(method));
             DelegateType = Type.GetTypeFromHandle(delegateType)
                 ?? throw new ArgumentException("Unexpected null returned from Type.GetTypeFromHandle in MethodAsDelegateElement");
+            AcceptsNull = acceptsNull;
         }
 
         public MethodInfo Method { get; }
 
         public Type DelegateType { get; }
+        
+        public bool AcceptsNull { get; }
     }
 
     internal class MethodAsCommandElement : ICompiledBindingPathElement

+ 49 - 13
src/Markup/Avalonia.Markup/Markup/Parsers/BindingExpressionGrammar.cs

@@ -52,11 +52,19 @@ namespace Avalonia.Markup.Parsers
                         break;
 
                     case State.BeforeMember:
-                        state = ParseBeforeMember(ref r, nodes);
+                        state = ParseBeforeMember(ref r, nodes, false);
+                        break;
+
+                    case State.BeforeMemberNullable:
+                        state = ParseBeforeMember(ref r, nodes, true);
                         break;
 
                     case State.AttachedProperty:
-                        state = ParseAttachedProperty(ref r, nodes);
+                        state = ParseAttachedProperty(ref r, nodes, false);
+                        break;
+
+                    case State.AttachedPropertyNullable:
+                        state = ParseAttachedProperty(ref r, nodes, true);
                         break;
 
                     case State.Indexer:
@@ -84,7 +92,7 @@ namespace Avalonia.Markup.Parsers
                 throw new ExpressionParseException(r.Position, "Expected end of expression.");
             }
 
-            if (state == State.BeforeMember)
+            if (state is State.BeforeMember or State.BeforeMemberNullable)
             {
                 throw new ExpressionParseException(r.Position, "Unexpected end of expression.");
             }
@@ -142,9 +150,9 @@ namespace Avalonia.Markup.Parsers
 
         private static State ParseAfterMember(ref CharacterReader r, IList<INode> nodes)
         {
-            if (ParseMemberAccessor(ref r))
+            if (ParseMemberAccessor(ref r, out var acceptsNull))
             {
-                return State.BeforeMember;
+                return acceptsNull ? State.BeforeMemberNullable : State.BeforeMember;
             }
             else if (ParseStreamOperator(ref r))
             {
@@ -163,7 +171,7 @@ namespace Avalonia.Markup.Parsers
             return State.End;
         }
 
-        private static State ParseBeforeMember(ref CharacterReader r, IList<INode> nodes)
+        private static State ParseBeforeMember(ref CharacterReader r, IList<INode> nodes, bool acceptsNull)
         {
             if (ParseOpenBrace(ref r))
             {
@@ -172,7 +180,7 @@ namespace Avalonia.Markup.Parsers
                     return State.TypeCast;
                 }
 
-                return State.AttachedProperty;
+                return acceptsNull ? State.AttachedPropertyNullable : State.AttachedProperty;
             }
             else
             {
@@ -180,7 +188,11 @@ namespace Avalonia.Markup.Parsers
 
                 if (!identifier.IsEmpty)
                 {
-                    nodes.Add(new PropertyNameNode { PropertyName = identifier.ToString() });
+                    nodes.Add(new PropertyNameNode 
+                    { 
+                        AcceptsNull = acceptsNull,
+                        PropertyName = identifier.ToString() 
+                    });
                     return State.AfterMember;
                 }
 
@@ -192,7 +204,7 @@ namespace Avalonia.Markup.Parsers
 #if NET7SDK
             scoped
 #endif
-            ref CharacterReader r, List<INode> nodes)
+            ref CharacterReader r, List<INode> nodes, bool acceptsNull)
         {
             var (ns, owner) = ParseTypeName(ref r);
 
@@ -221,6 +233,7 @@ namespace Avalonia.Markup.Parsers
 
             nodes.Add(new AttachedPropertyNameNode
             {
+                AcceptsNull = acceptsNull,
                 Namespace = ns,
                 TypeName = owner,
                 PropertyName = name.ToString()
@@ -256,9 +269,9 @@ namespace Avalonia.Markup.Parsers
                     throw new ExpressionParseException(r.Position, "Expected ')'.");
                 }
 
-                result = ParseBeforeMember(ref r, nodes);
+                result = ParseBeforeMember(ref r, nodes, false);
                 if (result == State.AttachedProperty)
-                    result = ParseAttachedProperty(ref r, nodes);
+                    result = ParseAttachedProperty(ref r, nodes, false);
 
                 if (r.Peek == '[')
                 {
@@ -372,9 +385,28 @@ namespace Avalonia.Markup.Parsers
             return !r.End && r.TakeIf('!');
         }
 
-        private static bool ParseMemberAccessor(ref CharacterReader r)
+        private static bool ParseMemberAccessor(ref CharacterReader r, out bool acceptsNull)
         {
-            return !r.End && r.TakeIf('.');
+            if (r.End)
+            {
+                acceptsNull = false;
+                return false;
+            }
+
+            if (r.TakeIf('.'))
+            {
+                acceptsNull = false;
+                return true;
+            }
+
+            if (r.TakeIf("?."))
+            {
+                acceptsNull = true;
+                return true;
+            }
+
+            acceptsNull = false;
+            return false;
         }
 
         private static bool ParseOpenBrace(ref CharacterReader r)
@@ -424,7 +456,9 @@ namespace Avalonia.Markup.Parsers
             ElementName,
             AfterMember,
             BeforeMember,
+            BeforeMemberNullable,
             AttachedProperty,
+            AttachedPropertyNullable,
             Indexer,
             TypeCast,
             End,
@@ -456,11 +490,13 @@ namespace Avalonia.Markup.Parsers
 
         public class PropertyNameNode : INode
         {
+            public bool AcceptsNull { get; set; }
             public string PropertyName { get; set; } = string.Empty;
         }
 
         public class AttachedPropertyNameNode : INode
         {
+            public bool AcceptsNull { get; set; }
             public string Namespace { get; set; } = string.Empty;
             public string TypeName { get; set; } = string.Empty;
             public string PropertyName { get; set; } = string.Empty;

+ 1 - 1
src/Markup/Avalonia.Markup/Markup/Parsers/ExpressionNodeFactory.cs

@@ -52,7 +52,7 @@ namespace Avalonia.Markup.Parsers
                         ++negated;
                         break;
                     case BindingExpressionGrammar.PropertyNameNode propName:
-                        node = new DynamicPluginPropertyAccessorNode(propName.PropertyName);
+                        node = new DynamicPluginPropertyAccessorNode(propName.PropertyName, propName.AcceptsNull);
                         break;
                     case BindingExpressionGrammar.SelfNode:
                         node = null;

+ 27 - 0
tests/Avalonia.Base.UnitTests/Data/Core/ErrorCollectingTextBox.cs

@@ -0,0 +1,27 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Data;
+
+#nullable enable
+
+namespace Avalonia.Base.UnitTests.Data.Core;
+
+/// <summary>
+/// A <see cref="TextBox"/> which stores the latest binding error state.
+/// </summary>
+public class ErrorCollectingTextBox : TextBox
+{
+    public Exception? Error { get; private set; }
+    public BindingValueType ErrorState { get; private set; }
+
+    protected override void UpdateDataValidation(AvaloniaProperty property, BindingValueType state, Exception? error)
+    {
+        if (property == TextProperty)
+        {
+            Error = error;
+            ErrorState = state;
+        }
+
+        base.UpdateDataValidation(property, state, error);
+    }
+}

+ 184 - 0
tests/Avalonia.Base.UnitTests/Data/Core/NullConditionalBindingTests.cs

@@ -0,0 +1,184 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Logging;
+using Avalonia.Markup.Xaml;
+using Avalonia.UnitTests;
+using Xunit;
+
+#nullable enable
+
+namespace Avalonia.Base.UnitTests.Data.Core;
+
+/// <summary>
+/// Tests for null-conditional operator in binding paths.
+/// </summary>
+/// <remarks>
+/// Ideally these would be part of the <see cref="BindingExpressionTests"/> suite but that uses
+/// C# expression trees as an abstraction to represent both reflection and compiled binding paths.
+/// This is a problem because expression trees don't support the C# null-conditional operator
+/// and I have no desire to refactor all of those tests right now.
+/// </remarks>
+public class NullConditionalBindingTests
+{
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public void Should_Report_Error_Without_Null_Conditional_Operator(bool compileBindings)
+    {
+        // Testing the baseline: should report a null error without null conditionals.
+        using var app = Start();
+        using var log = TestLogger.Create();
+
+        var xaml = $$$"""
+            <Window xmlns='https://github.com/avaloniaui'
+                    xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                    xmlns:local='using:Avalonia.Base.UnitTests.Data.Core'
+                    x:DataType='local:NullConditionalBindingTests+First'
+                    x:CompileBindings='{{{compileBindings}}}'>
+                <local:ErrorCollectingTextBox Text='{Binding Second.Third.Final}'/>
+            </Window>
+            """;
+        var data = new First(new Second(null));
+        var window = CreateTarget(xaml, data);
+        var textBox = Assert.IsType<ErrorCollectingTextBox>(window.Content);
+        var error = Assert.IsType<BindingChainException>(textBox.Error);
+        var message = Assert.Single(log.Messages);
+
+        Assert.Null(textBox.Text);
+        Assert.Equal("Second.Third.Final", error.Expression);
+        Assert.Equal("Third", error.ExpressionErrorPoint);
+        Assert.Equal(BindingValueType.BindingError, textBox.ErrorState);
+        Assert.Equal("An error occurred binding {Property} to {Expression} at {ExpressionErrorPoint}: {Message}", message);
+    }
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public void Should_Not_Report_Error_With_Null_Conditional_Operator(bool compileBindings)
+    {
+        using var app = Start();
+        using var log = TestLogger.Create();
+        var xaml = $$$"""
+            <Window xmlns='https://github.com/avaloniaui'
+                    xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                    xmlns:local='using:Avalonia.Base.UnitTests.Data.Core'
+                    x:DataType='local:NullConditionalBindingTests+First'
+                    x:CompileBindings='{{{compileBindings}}}'>
+                <local:ErrorCollectingTextBox Text='{Binding Second.Third?.Final}'/>
+            </Window>
+            """;
+        var data = new First(new Second(null));
+        var window = CreateTarget(xaml, data);
+        var textBox = Assert.IsType<ErrorCollectingTextBox>(window.Content);
+
+        Assert.Null(textBox.Text);
+        Assert.Null(textBox.Error);
+        Assert.Equal(BindingValueType.Value, textBox.ErrorState);
+        Assert.Empty(log.Messages);
+    }
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public void Should_Not_Report_Error_With_Null_Conditional_Operator_Before_Method(bool compileBindings)
+    {
+        using var app = Start();
+        using var log = TestLogger.Create();
+        var xaml = $$$"""
+            <Window xmlns='https://github.com/avaloniaui'
+                    xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                    xmlns:local='using:Avalonia.Base.UnitTests.Data.Core'
+                    x:DataType='local:NullConditionalBindingTests+First'
+                    x:CompileBindings='{{{compileBindings}}}'>
+                <local:ErrorCollectingTextBox Text='{Binding Second.Third?.Greeting}'/>
+            </Window>
+            """;
+        var data = new First(new Second(null));
+        var window = CreateTarget(xaml, data);
+        var textBox = Assert.IsType<ErrorCollectingTextBox>(window.Content);
+
+        Assert.Null(textBox.Text);
+        Assert.Null(textBox.Error);
+        Assert.Equal(BindingValueType.Value, textBox.ErrorState);
+        Assert.Empty(log.Messages);
+    }
+
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public void Should_Use_TargetNullValue_With_Null_Conditional_Operator(bool compileBindings)
+    {
+        using var app = Start();
+        using var log = TestLogger.Create();
+        var xaml = $$$"""
+            <Window xmlns='https://github.com/avaloniaui'
+                    xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                    xmlns:local='using:Avalonia.Base.UnitTests.Data.Core'
+                    x:DataType='local:NullConditionalBindingTests+First'
+                    x:CompileBindings='{{{compileBindings}}}'>
+                <local:ErrorCollectingTextBox Text='{Binding Second.Third?.Final, TargetNullValue=ItsNull}'/>
+            </Window>
+            """;
+        var data = new First(new Second(null));
+        var window = CreateTarget(xaml, data);
+        var textBox = Assert.IsType<ErrorCollectingTextBox>(window.Content);
+
+        Assert.Equal("ItsNull", textBox.Text);
+        Assert.Null(textBox.Error);
+        Assert.Equal(BindingValueType.Value, textBox.ErrorState);
+        Assert.Empty(log.Messages);
+    }
+
+    private Window CreateTarget(string xaml, object? data)
+    {
+        var result = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
+        result.DataContext = data;
+        result.Show();
+        return result;
+    }
+
+    private static IDisposable Start()
+    {
+        return UnitTestApplication.Start(TestServices.StyledWindow);
+    }
+
+    public record First(Second? Second);
+    public record Second(Third? Third);
+    public record Third(string Final)
+    {
+        public string Greeting() => "Hello!";
+    }
+
+    private class TestLogger : ILogSink, IDisposable
+    {
+        private TestLogger() { }
+
+        public IList<string> Messages { get; } = [];
+
+        public static TestLogger Create()
+        {
+            var result = new TestLogger();
+            Logger.Sink = result;
+            return result;
+        }
+
+        public void Dispose() => Logger.Sink = null;
+
+        public bool IsEnabled(LogEventLevel level, string area)
+        {
+            return level >= LogEventLevel.Warning && area == LogArea.Binding;
+        }
+
+        public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
+        {
+            Messages.Add(messageTemplate);
+        }
+
+        public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] propertyValues)
+        {
+            Messages.Add(messageTemplate);
+        }
+    }
+}

+ 260 - 0
tests/Avalonia.Markup.UnitTests/Parsers/BindingExpressionGrammarTests.cs

@@ -0,0 +1,260 @@
+using System.Collections.Generic;
+using Avalonia.Markup.Parsers;
+using Avalonia.Utilities;
+using Xunit;
+
+namespace Avalonia.Markup.UnitTests.Parsers
+{
+    public partial class BindingExpressionGrammarTests
+    {
+        [Fact]
+        public void Should_Parse_Single_Property()
+        {
+            var result = Parse("Foo");
+            var node = Assert.Single(result);
+
+            AssertIsProperty(node, "Foo");
+        }
+
+        [Fact]
+        public void Should_Parse_Underscored_Property()
+        {
+            var result = Parse("_Foo");
+            var node = Assert.Single(result);
+
+            AssertIsProperty(node, "_Foo");
+        }
+
+        [Fact]
+        public void Should_Parse_Property_With_Digits()
+        {
+            var result = Parse("F0o");
+            var node = Assert.Single(result);
+
+            AssertIsProperty(node, "F0o");
+        }
+
+        [Fact]
+        public void Should_Parse_Dot()
+        {
+            var result = Parse(".");
+            var node = Assert.Single(result);
+
+            Assert.IsType<BindingExpressionGrammar.EmptyExpressionNode>(node);
+        }
+
+        [Fact]
+        public void Should_Parse_Single_Attached_Property()
+        {
+            var result = Parse("(Foo.Bar)");
+            var node = Assert.Single(result);
+
+            AssertIsAttachedProperty(node, "Foo", "Bar");
+        }
+
+        [Fact]
+        public void Should_Parse_Property_Chain()
+        {
+            var result = Parse("Foo.Bar.Baz");
+
+            Assert.Equal(3, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsProperty(result[1], "Bar");
+            AssertIsProperty(result[2], "Baz");
+        }
+
+        [Fact]
+        public void Should_Parse_Property_Chain_With_Attached_Property_1()
+        {
+            var result = Parse("(Foo.Bar).Baz");
+
+            Assert.Equal(2, result.Count);
+            AssertIsAttachedProperty(result[0], "Foo", "Bar");
+            AssertIsProperty(result[1], "Baz");
+        }
+
+        [Fact]
+        public void Should_Parse_Property_Chain_With_Attached_Property_2()
+        {
+            var result = Parse("Foo.(Bar.Baz)");
+
+            Assert.Equal(2, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsAttachedProperty(result[1], "Bar", "Baz");
+        }
+
+        [Fact]
+        public void Should_Parse_Property_Chain_With_Attached_Property_3()
+        {
+            var result = Parse("Foo.(Bar.Baz).Last");
+
+            Assert.Equal(3, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsAttachedProperty(result[1], "Bar", "Baz");
+            AssertIsProperty(result[2], "Last");
+        }
+
+        [Fact]
+        public void Should_Parse_Null_Conditional_In_Property_Chain_1()
+        {
+            var result = Parse("Foo?.Bar.Baz");
+
+            Assert.Equal(3, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsProperty(result[1], "Bar", acceptsNull: true);
+            AssertIsProperty(result[2], "Baz");
+        }
+
+        [Fact]
+        public void Should_Parse_Null_Conditional_In_Property_Chain_2()
+        {
+            var result = Parse("Foo.Bar?.Baz");
+
+            Assert.Equal(3, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsProperty(result[1], "Bar");
+            AssertIsProperty(result[2], "Baz", acceptsNull: true);
+        }
+
+        [Fact]
+        public void Should_Parse_Null_Conditional_In_Property_Chain_3()
+        {
+            var result = Parse("Foo?.(Bar.Baz)");
+
+            Assert.Equal(2, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsAttachedProperty(result[1], "Bar", "Baz", acceptsNull: true);
+        }
+
+        [Fact]
+        public void Should_Parse_Negated_Property_Chain()
+        {
+            var result = Parse("!Foo.Bar.Baz");
+
+            Assert.Equal(4, result.Count);
+            Assert.IsType<BindingExpressionGrammar.NotNode>(result[0]);
+            AssertIsProperty(result[1], "Foo");
+            AssertIsProperty(result[2], "Bar");
+            AssertIsProperty(result[3], "Baz");
+        }
+
+        [Fact]
+        public void Should_Parse_Double_Negated_Property_Chain()
+        {
+            var result = Parse("!!Foo.Bar.Baz");
+
+            Assert.Equal(5, result.Count);
+            Assert.IsType<BindingExpressionGrammar.NotNode>(result[0]);
+            Assert.IsType<BindingExpressionGrammar.NotNode>(result[1]);
+            AssertIsProperty(result[2], "Foo");
+            AssertIsProperty(result[3], "Bar");
+            AssertIsProperty(result[4], "Baz");
+        }
+
+        [Fact]
+        public void Should_Parse_Indexed_Property()
+        {
+            var result = Parse("Foo[15]");
+
+            Assert.Equal(2, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsIndexer(result[1], "15");
+        }
+
+        [Fact]
+        public void Should_Parse_Indexed_Property_StringIndex()
+        {
+            var result = Parse("Foo[Key]");
+
+            Assert.Equal(2, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsIndexer(result[1], "Key");
+        }
+
+        [Fact]
+        public void Should_Parse_Multiple_Indexed_Property()
+        {
+            var result = Parse("Foo[15,6]");
+
+            Assert.Equal(2, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsIndexer(result[1], "15", "6");
+        }
+
+        [Fact]
+        public void Should_Parse_Multiple_Indexed_Property_With_Space()
+        {
+            var result = Parse("Foo[5, 16]");
+
+            Assert.Equal(2, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsIndexer(result[1], "5", "16");
+        }
+
+        [Fact]
+        public void Should_Parse_Consecutive_Indexers()
+        {
+            var result = Parse("Foo[15][16]");
+
+            Assert.Equal(3, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsIndexer(result[1], "15");
+            AssertIsIndexer(result[2], "16");
+        }
+
+        [Fact]
+        public void Should_Parse_Indexed_Property_In_Chain()
+        {
+            var result = Parse("Foo.Bar[5, 6].Baz");
+
+            Assert.Equal(4, result.Count);
+            AssertIsProperty(result[0], "Foo");
+            AssertIsProperty(result[1], "Bar");
+            AssertIsIndexer(result[2], "5", "6");
+            AssertIsProperty(result[3], "Baz");
+        }
+
+        [Fact]
+        public void Should_Parse_Stream_Node()
+        {
+            var result = Parse("Foo^");
+
+            Assert.Equal(2, result.Count);
+            Assert.IsType<BindingExpressionGrammar.StreamNode>(result[1]);
+        }
+
+        private static void AssertIsProperty(
+            BindingExpressionGrammar.INode node,
+            string name,
+            bool acceptsNull = false)
+        {
+            var p = Assert.IsType<BindingExpressionGrammar.PropertyNameNode>(node);
+            Assert.Equal(name, p.PropertyName);
+            Assert.Equal(acceptsNull, p.AcceptsNull);
+        }
+
+        private static void AssertIsAttachedProperty(
+            BindingExpressionGrammar.INode node,
+            string typeName,
+            string name,
+            bool acceptsNull = false)
+        {
+            var p = Assert.IsType<BindingExpressionGrammar.AttachedPropertyNameNode>(node);
+            Assert.Equal(typeName, p.TypeName);
+            Assert.Equal(name, p.PropertyName);
+            Assert.Equal(acceptsNull, p.AcceptsNull);
+        }
+
+        private static void AssertIsIndexer(BindingExpressionGrammar.INode node, params string[] args)
+        {
+            var e = Assert.IsType<BindingExpressionGrammar.IndexerNode>(node);
+            Assert.Equal(e.Arguments, args);
+        }
+
+        private static List<BindingExpressionGrammar.INode> Parse(string s)
+        {
+            var r = new CharacterReader(s);
+            return BindingExpressionGrammar.Parse(ref r).Nodes;
+        }
+    }
+}

+ 29 - 10
tests/Avalonia.Markup.UnitTests/Parsers/BindingExpressionGrammarTests_Errors.cs

@@ -1,12 +1,9 @@
-using System.Collections.Generic;
 using Avalonia.Data.Core;
-using Avalonia.Markup.Parsers;
-using Avalonia.Utilities;
 using Xunit;
 
 namespace Avalonia.Markup.UnitTests.Parsers
 {
-    public class ExpressionObserverBuilderTests_Errors
+    public partial class BindingExpressionGrammarTests
     {
         [Fact]
         public void Identifier_Cannot_Start_With_Digit()
@@ -22,12 +19,40 @@ namespace Avalonia.Markup.UnitTests.Parsers
                 () => Parse("Foo.%Bar"));
         }
 
+        [Fact]
+        public void Identifier_Cannot_Start_With_QuestionMark()
+        {
+            Assert.Throws<ExpressionParseException>(
+                () => Parse("?Foo"));
+        }
+
+        [Fact]
+        public void Identifier_Cannot_Start_With_NullConditional()
+        {
+            Assert.Throws<ExpressionParseException>(
+                () => Parse("?.Foo"));
+        }
+
         [Fact]
         public void Expression_Cannot_End_With_Period()
         {
             Assert.Throws<ExpressionParseException>(
                 () => Parse("Foo.Bar."));
         }
+        
+        [Fact]
+        public void Expression_Cannot_End_With_QuestionMark()
+        {
+            Assert.Throws<ExpressionParseException>(
+                () => Parse("Foo.Bar?"));
+        }
+
+        [Fact]
+        public void Expression_Cannot_End_With_Null_Conditional()
+        {
+            Assert.Throws<ExpressionParseException>(
+                () => Parse("Foo.Bar?."));
+        }
 
         [Fact]
         public void Expression_Cannot_Start_With_Period_Then_Token()
@@ -77,11 +102,5 @@ namespace Avalonia.Markup.UnitTests.Parsers
             Assert.Throws<ExpressionParseException>(
                 () => Parse("Foo.Bar[3,4]A"));
         }
-
-        private static List<BindingExpressionGrammar.INode> Parse(string s)
-        {
-            var r = new CharacterReader(s);
-            return BindingExpressionGrammar.Parse(ref r).Nodes;
-        }
     }
 }

+ 1 - 1
tests/Avalonia.Markup.UnitTests/Parsers/ExpressionPathParsing.cs → tests/Avalonia.Markup.UnitTests/Parsers/ExpressionNodeFactoryTests.cs

@@ -9,7 +9,7 @@ using Xunit;
 
 namespace Avalonia.Markup.UnitTests.Parsers
 {
-    public class ExpressionObserverBuilderTests
+    public class ExpressionNodeFactoryTests
     {
         [Fact]
         public void Should_Build_Single_Property()