diff --git a/src/Markup/Avalonia.Markup.Xaml/Avalonia.Markup.Xaml.csproj b/src/Markup/Avalonia.Markup.Xaml/Avalonia.Markup.Xaml.csproj
index cdc22f4102..8c843a4b49 100644
--- a/src/Markup/Avalonia.Markup.Xaml/Avalonia.Markup.Xaml.csproj
+++ b/src/Markup/Avalonia.Markup.Xaml/Avalonia.Markup.Xaml.csproj
@@ -33,6 +33,7 @@
+
diff --git a/src/Markup/Avalonia.Markup.Xaml/Parsers/PropertyParser.cs b/src/Markup/Avalonia.Markup.Xaml/Parsers/PropertyParser.cs
new file mode 100644
index 0000000000..ce82ffe0a1
--- /dev/null
+++ b/src/Markup/Avalonia.Markup.Xaml/Parsers/PropertyParser.cs
@@ -0,0 +1,84 @@
+using System;
+using Avalonia.Data.Core;
+using Avalonia.Markup.Parsers;
+
+namespace Avalonia.Markup.Xaml.Parsers
+{
+ internal class PropertyParser
+ {
+ public (string ns, string owner, string name) Parse(Reader r)
+ {
+ if (r.End)
+ {
+ throw new ExpressionParseException(0, "Expected property name.");
+ }
+
+ var openParens = r.TakeIf('(');
+ bool closeParens = false;
+ string ns = null;
+ string owner = null;
+ string name = null;
+
+ do
+ {
+ var token = IdentifierParser.Parse(r);
+
+ if (token == null)
+ {
+ if (r.End)
+ {
+ break;
+ }
+ else
+ {
+ if (openParens && !r.End && (closeParens = r.TakeIf(')')))
+ {
+ break;
+ }
+ else if (openParens)
+ {
+ throw new ExpressionParseException(r.Position, $"Expected ')'.");
+ }
+
+ throw new ExpressionParseException(r.Position, $"Unexpected '{r.Peek}'.");
+ }
+ }
+ else if (!r.End && r.TakeIf(':'))
+ {
+ ns = ns == null ?
+ token :
+ throw new ExpressionParseException(r.Position, "Unexpected ':'.");
+ }
+ else if (!r.End && r.TakeIf('.'))
+ {
+ owner = owner == null ?
+ token :
+ throw new ExpressionParseException(r.Position, "Unexpected '.'.");
+ }
+ else
+ {
+ name = token;
+ }
+ } while (!r.End);
+
+ if (name == null)
+ {
+ throw new ExpressionParseException(0, "Expected property name.");
+ }
+ else if (openParens && owner == null)
+ {
+ throw new ExpressionParseException(1, "Expected property owner.");
+ }
+ else if (openParens && !closeParens)
+ {
+ throw new ExpressionParseException(r.Position, "Expected ')'.");
+ }
+ else if (!r.End)
+ {
+ throw new ExpressionParseException(r.Position, "Expected end of expression.");
+ }
+
+ return (ns, owner, name);
+ }
+ }
+}
diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/IdentifierParser.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/IdentifierParser.cs
index f86f2db321..9431dab45e 100644
--- a/src/Markup/Avalonia.Markup/Markup/Parsers/IdentifierParser.cs
+++ b/src/Markup/Avalonia.Markup/Markup/Parsers/IdentifierParser.cs
@@ -6,7 +6,7 @@ using System.Text;
namespace Avalonia.Markup.Parsers
{
- internal static class IdentifierParser
+ public static class IdentifierParser
{
public static string Parse(Reader r)
{
diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/Reader.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/Reader.cs
index 9355bc9aa3..4a3d6aa277 100644
--- a/src/Markup/Avalonia.Markup/Markup/Parsers/Reader.cs
+++ b/src/Markup/Avalonia.Markup/Markup/Parsers/Reader.cs
@@ -5,7 +5,7 @@ using System;
namespace Avalonia.Markup.Parsers
{
- internal class Reader
+ public class Reader
{
private readonly string _s;
private int _i;
diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Parsers/PropertyParserTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Parsers/PropertyParserTests.cs
new file mode 100644
index 0000000000..a05485f55b
--- /dev/null
+++ b/tests/Avalonia.Markup.Xaml.UnitTests/Parsers/PropertyParserTests.cs
@@ -0,0 +1,225 @@
+using System;
+using Avalonia.Data.Core;
+using Avalonia.Markup.Parsers;
+using Avalonia.Markup.Xaml.Parsers;
+using Xunit;
+
+namespace Avalonia.Markup.Xaml.UnitTests.Parsers
+{
+ public class PropertyParserTests
+ {
+ [Fact]
+ public void Parses_Name()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("Foo");
+ var (ns, owner, name) = target.Parse(reader);
+
+ Assert.Null(ns);
+ Assert.Null(owner);
+ Assert.Equal("Foo", name);
+ }
+
+ [Fact]
+ public void Parses_Owner_And_Name()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("Foo.Bar");
+ var (ns, owner, name) = target.Parse(reader);
+
+ Assert.Null(ns);
+ Assert.Equal("Foo", owner);
+ Assert.Equal("Bar", name);
+ }
+
+ [Fact]
+ public void Parses_Namespace_Owner_And_Name()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("foo:Bar.Baz");
+ var (ns, owner, name) = target.Parse(reader);
+
+ Assert.Equal("foo", ns);
+ Assert.Equal("Bar", owner);
+ Assert.Equal("Baz", name);
+ }
+
+ [Fact]
+ public void Parses_Owner_And_Name_With_Parentheses()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("(Foo.Bar)");
+ var (ns, owner, name) = target.Parse(reader);
+
+ Assert.Null(ns);
+ Assert.Equal("Foo", owner);
+ Assert.Equal("Bar", name);
+ }
+
+ [Fact]
+ public void Parses_Namespace_Owner_And_Name_With_Parentheses()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("(foo:Bar.Baz)");
+ var (ns, owner, name) = target.Parse(reader);
+
+ Assert.Equal("foo", ns);
+ Assert.Equal("Bar", owner);
+ Assert.Equal("Baz", name);
+ }
+
+ [Fact]
+ public void Fails_With_Empty_String()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(0, ex.Column);
+ Assert.Equal("Expected property name.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Only_Whitespace()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader(" ");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(0, ex.Column);
+ Assert.Equal("Unexpected ' '.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Leading_Whitespace()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader(" Foo");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(0, ex.Column);
+ Assert.Equal("Unexpected ' '.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Trailing_Whitespace()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("Foo ");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(3, ex.Column);
+ Assert.Equal("Unexpected ' '.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Invalid_Property_Name()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("123");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(0, ex.Column);
+ Assert.Equal("Unexpected '1'.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Trailing_Junk()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("Foo%");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(3, ex.Column);
+ Assert.Equal("Unexpected '%'.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Invalid_Property_Name_After_Owner()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("Foo.123");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(4, ex.Column);
+ Assert.Equal("Unexpected '1'.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Whitespace_Between_Owner_And_Name()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("Foo. Bar");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(4, ex.Column);
+ Assert.Equal("Unexpected ' '.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Too_Many_Segments()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("Foo.Bar.Baz");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(8, ex.Column);
+ Assert.Equal("Unexpected '.'.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Too_Many_Namespaces()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("foo:bar:Baz");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(8, ex.Column);
+ Assert.Equal("Unexpected ':'.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Parens_But_No_Owner()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("(Foo)");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(1, ex.Column);
+ Assert.Equal("Expected property owner.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Parens_And_Namespace_But_No_Owner()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("(foo:Bar)");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(1, ex.Column);
+ Assert.Equal("Expected property owner.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Missing_Close_Parens()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("(Foo.Bar");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(8, ex.Column);
+ Assert.Equal("Expected ')'.", ex.Message);
+ }
+
+ [Fact]
+ public void Fails_With_Unexpected_Close_Parens()
+ {
+ var target = new PropertyParser();
+ var reader = new Reader("Foo.Bar)");
+
+ var ex = Assert.Throws(() => target.Parse(reader));
+ Assert.Equal(7, ex.Column);
+ Assert.Equal("Unexpected ')'.", ex.Message);
+ }
+ }
+}