diff --git a/src/Avalonia.Base/Utilities/CharacterReader.cs b/src/Avalonia.Base/Utilities/CharacterReader.cs index 9be2d230d4..67a77f1c5c 100644 --- a/src/Avalonia.Base/Utilities/CharacterReader.cs +++ b/src/Avalonia.Base/Utilities/CharacterReader.cs @@ -19,7 +19,7 @@ namespace Avalonia.Utilities } public bool End => _s.IsEmpty; - public char Peek => _s[0]; + public char PeekOneOrThrow => _s[0]; public int Position { get; private set; } public char Take() { @@ -38,7 +38,7 @@ namespace Avalonia.Utilities public bool TakeIf(char c) { - if (Peek == c) + if (PeekOneOrThrow == c) { Take(); return true; @@ -51,7 +51,7 @@ namespace Avalonia.Utilities public bool TakeIf(Func condition) { - if (condition(Peek)) + if (condition(PeekOneOrThrow)) { Take(); return true; @@ -82,5 +82,23 @@ namespace Avalonia.Utilities Position += len; return span; } + + public ReadOnlySpan TryPeek(int count) + { + return _s.Slice(0, count); + } + + public ReadOnlySpan PeekWhitespace() + { + var trimmed = _s.TrimStart(); + return _s.Slice(0, _s.Length - trimmed.Length); + } + + public void Skip(int count) + { + if (_s.Length < count) + throw new IndexOutOfRangeException(); + _s = _s.Slice(count); + } } } diff --git a/src/Avalonia.Base/Utilities/IdentifierParser.cs b/src/Avalonia.Base/Utilities/IdentifierParser.cs index a57a2b7ba5..ab14ea2c58 100644 --- a/src/Avalonia.Base/Utilities/IdentifierParser.cs +++ b/src/Avalonia.Base/Utilities/IdentifierParser.cs @@ -13,7 +13,7 @@ namespace Avalonia.Utilities { public static ReadOnlySpan ParseIdentifier(this ref CharacterReader r) { - if (IsValidIdentifierStart(r.Peek)) + if (IsValidIdentifierStart(r.PeekOneOrThrow)) { return r.TakeWhile(IsValidIdentifierChar); } diff --git a/src/Avalonia.Base/Utilities/KeywordParser.cs b/src/Avalonia.Base/Utilities/KeywordParser.cs new file mode 100644 index 0000000000..5c7d415820 --- /dev/null +++ b/src/Avalonia.Base/Utilities/KeywordParser.cs @@ -0,0 +1,34 @@ +using System; + +namespace Avalonia.Utilities +{ +#if !BUILDTASK + public +#endif + static class KeywordParser + { + public static bool CheckKeyword(this ref CharacterReader r, string keyword) + { + return (CheckKeywordInternal(ref r, keyword) >= 0); + } + + static int CheckKeywordInternal(this ref CharacterReader r, string keyword) + { + var ws = r.PeekWhitespace(); + + var chars = r.TryPeek(ws.Length + keyword.Length); + if (chars.Slice(ws.Length).Equals(keyword.AsSpan(), StringComparison.Ordinal)) + return chars.Length; + return -1; + } + + public static bool TakeIfKeyword(this ref CharacterReader r, string keyword) + { + var l = CheckKeywordInternal(ref r, keyword); + if (l < 0) + return false; + r.Skip(l); + return true; + } + } +} diff --git a/src/Markup/Avalonia.Markup.Xaml/Parsers/PropertyParser.cs b/src/Markup/Avalonia.Markup.Xaml/Parsers/PropertyParser.cs index ea94012096..ebd6347a86 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Parsers/PropertyParser.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Parsers/PropertyParser.cs @@ -41,7 +41,7 @@ namespace Avalonia.Markup.Xaml.Parsers throw new ExpressionParseException(r.Position, $"Expected ')'."); } - throw new ExpressionParseException(r.Position, $"Unexpected '{r.Peek}'."); + throw new ExpressionParseException(r.Position, $"Unexpected '{r.PeekOneOrThrow}'."); } } else if (!r.End && r.TakeIf(':')) diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/ArgumentListParser.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/ArgumentListParser.cs index cb9c81caa1..fd6f7b20bd 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/ArgumentListParser.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/ArgumentListParser.cs @@ -11,7 +11,7 @@ namespace Avalonia.Markup.Parsers { public static IList ParseArguments(this ref CharacterReader r, char open, char close, char delimiter = ',') { - if (r.Peek == open) + if (r.PeekOneOrThrow == open) { var result = new List(); diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/ExpressionParser.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/ExpressionParser.cs index a1350a8393..2a737d36e9 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/ExpressionParser.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/ExpressionParser.cs @@ -304,7 +304,7 @@ namespace Avalonia.Markup.Parsers private static bool PeekOpenBracket(ref CharacterReader r) { - return !r.End && r.Peek == '['; + return !r.End && r.PeekOneOrThrow == '['; } private static bool ParseStreamOperator(ref CharacterReader r) diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/PropertyPathGrammar.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/PropertyPathGrammar.cs new file mode 100644 index 0000000000..89490db2aa --- /dev/null +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/PropertyPathGrammar.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using Avalonia.Data.Core; +using Avalonia.Utilities; + +namespace Avalonia.Markup.Parsers +{ +#if !BUILDTASK + public +#endif + class PropertyPathGrammar + { + private enum State + { + Start, + Next, + AfterProperty, + End + } + + public static IEnumerable Parse(string s) + { + var r = new CharacterReader(s.AsSpan()); + return Parse(ref r); + } + + private static IEnumerable Parse(ref CharacterReader r) + { + var state = State.Start; + var parsed = new List(); + while (state != State.End) + { + ISyntax syntax = null; + if (state == State.Start) + (state, syntax) = ParseStart(ref r); + else if (state == State.Next) + (state, syntax) = ParseNext(ref r); + else if (state == State.AfterProperty) + (state, syntax) = ParseAfterProperty(ref r); + + + if (syntax != null) + { + parsed.Add(syntax); + } + } + + if (state != State.End && r.End) + { + throw new ExpressionParseException(r.Position, "Unexpected end of property path"); + } + + return parsed; + } + + private static (State, ISyntax) ParseNext(ref CharacterReader r) + { + r.SkipWhitespace(); + if (r.End) + return (State.End, null); + + return ParseStart(ref r); + } + + private static (State, ISyntax) ParseStart(ref CharacterReader r) + { + r.SkipWhitespace(); + + if (r.TakeIf('(')) + return ParseTypeQualifiedProperty(ref r); + + return ParseProperty(ref r); + } + + private static (State, ISyntax) ParseTypeQualifiedProperty(ref CharacterReader r) + { + r.SkipWhitespace(); + const string error = + "Unable to parse qualified property name, expected `(ns:TypeName.PropertyName)` or `(TypeName.PropertyName)` after `(`"; + + var typeName = ParseXamlIdentifier(ref r); + + + if (!r.TakeIf('.')) + throw new ExpressionParseException(r.Position, error); + + var propertyName = r.ParseIdentifier(); + if (propertyName.IsEmpty) + throw new ExpressionParseException(r.Position, error); + + r.SkipWhitespace(); + if (!r.TakeIf(')')) + throw new ExpressionParseException(r.Position, + "Expected ')' after qualified property name " + + typeName.ns + ':' + typeName.name + + "." + propertyName.ToString()); + + return (State.AfterProperty, + new TypeQualifiedPropertySyntax + { + Name = propertyName.ToString(), + TypeName = typeName.name, + TypeNamespace = typeName.ns + }); + } + + static (string ns, string name) ParseXamlIdentifier(ref CharacterReader r) + { + var ident = r.ParseIdentifier(); + if (ident.IsEmpty) + throw new ExpressionParseException(r.Position, "Expected identifier"); + if (r.TakeIf(':')) + { + var part2 = r.ParseIdentifier(); + if (part2.IsEmpty) + throw new ExpressionParseException(r.Position, + "Expected the rest of the identifier after " + ident.ToString() + ":"); + return (ident.ToString(), part2.ToString()); + } + + return (null, ident.ToString()); + } + + private static (State, ISyntax) ParseProperty(ref CharacterReader r) + { + r.SkipWhitespace(); + var prop = r.ParseIdentifier(); + if (prop.IsEmpty) + throw new ExpressionParseException(r.Position, "Unable to parse property name"); + return (State.AfterProperty, new PropertySyntax {Name = prop.ToString()}); + } + + + private static (State, ISyntax) ParseAfterProperty(ref CharacterReader r) + { + r.SkipWhitespace(); + if (r.End) + return (State.End, null); + if (r.TakeIf('.')) + return (State.Next, ChildTraversalSyntax.Instance); + if (r.TakeIfKeyword(":=")) + return ParseEnsureType(ref r); + + if (r.TakeIfKeyword(":>") || r.TakeIfKeyword("as ")) + return ParseCastType(ref r); + + throw new ExpressionParseException(r.Position, "Unexpected character " + r.PeekOneOrThrow + " after property name"); + } + + private static (State, ISyntax) ParseEnsureType(ref CharacterReader r) + { + r.SkipWhitespace(); + var type = ParseXamlIdentifier(ref r); + return (State.AfterProperty, new EnsureTypeSyntax {TypeName = type.name, TypeNamespace = type.ns}); + } + + private static (State, ISyntax) ParseCastType(ref CharacterReader r) + { + r.SkipWhitespace(); + var type = ParseXamlIdentifier(ref r); + return (State.AfterProperty, new CastTypeSyntax {TypeName = type.name, TypeNamespace = type.ns}); + } + + public interface ISyntax + { + + } + + public class PropertySyntax : ISyntax + { + public string Name { get; set; } + + public override bool Equals(object obj) + => obj is PropertySyntax other + && other.Name == Name; + } + + public class TypeQualifiedPropertySyntax : ISyntax + { + public string Name { get; set; } + public string TypeName { get; set; } + public string TypeNamespace { get; set; } + + public override bool Equals(object obj) + => obj is TypeQualifiedPropertySyntax other + && other.Name == Name + && other.TypeName == TypeName + && other.TypeNamespace == TypeNamespace; + } + + public class ChildTraversalSyntax : ISyntax + { + public static ChildTraversalSyntax Instance { get; } = new ChildTraversalSyntax(); + public override bool Equals(object obj) => obj is ChildTraversalSyntax; + } + + public class EnsureTypeSyntax : ISyntax + { + public string TypeName { get; set; } + public string TypeNamespace { get; set; } + public override bool Equals(object obj) + => obj is EnsureTypeSyntax other + && other.TypeName == TypeName + && other.TypeNamespace == TypeNamespace; + } + + public class CastTypeSyntax : ISyntax + { + public string TypeName { get; set; } + public string TypeNamespace { get; set; } + public override bool Equals(object obj) + => obj is CastTypeSyntax other + && other.TypeName == TypeName + && other.TypeNamespace == TypeNamespace; + } + } +} diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs index c57b8c4cf6..2de66d5501 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs @@ -123,7 +123,7 @@ namespace Avalonia.Markup.Parsers { return (State.Class, null); } - else if (r.TakeIf(char.IsWhiteSpace) || r.Peek == '>') + else if (r.TakeIf(char.IsWhiteSpace) || r.PeekOneOrThrow == '>') { return (State.Traversal, null); } @@ -139,7 +139,7 @@ namespace Avalonia.Markup.Parsers { return (State.Start, new CommaSyntax()); } - else if (end.HasValue && !r.End && r.Peek == end.Value) + else if (end.HasValue && !r.End && r.PeekOneOrThrow == end.Value) { return (State.End, null); } @@ -262,7 +262,7 @@ namespace Avalonia.Markup.Parsers if (!r.TakeIf('=')) { - throw new ExpressionParseException(r.Position, $"Expected '=', got '{r.Peek}'"); + throw new ExpressionParseException(r.Position, $"Expected '=', got '{r.PeekOneOrThrow}'"); } var value = r.TakeUntil(']'); @@ -281,7 +281,7 @@ namespace Avalonia.Markup.Parsers if (namespaceOrTypeName.IsEmpty) { - throw new ExpressionParseException(r.Position, $"Expected an identifier, got '{r.Peek}"); + throw new ExpressionParseException(r.Position, $"Expected an identifier, got '{r.PeekOneOrThrow}"); } if (!r.End && r.TakeIf('|')) @@ -311,7 +311,7 @@ namespace Avalonia.Markup.Parsers } else if (!r.TakeIf(')')) { - throw new ExpressionParseException(r.Position, $"Expected '{c}', got '{r.Peek}'."); + throw new ExpressionParseException(r.Position, $"Expected '{c}', got '{r.PeekOneOrThrow}'."); } } diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/PropertyPathGrammarTests.cs b/tests/Avalonia.Base.UnitTests/Data/Core/PropertyPathGrammarTests.cs new file mode 100644 index 0000000000..99144b7381 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Data/Core/PropertyPathGrammarTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Markup.Parsers; +using Xunit; + +namespace Avalonia.Base.UnitTests.Data.Core +{ + public class PropertyPathGrammarTests + { + static void Check(string s, params PropertyPathGrammar.ISyntax[] expected) + { + var parsed = PropertyPathGrammar.Parse(s).ToList(); + Assert.Equal(expected.Length, parsed.Count); + for (var c = 0; c < parsed.Count; c++) + Assert.Equal(expected[c], parsed[c]); + } + + [Fact] + public void PropertyPath_Should_Support_Simple_Properties() + { + Check("SomeProperty", new PropertyPathGrammar.PropertySyntax {Name = "SomeProperty"}); + } + + [Fact] + public void PropertyPath_Should_Ignore_Trailing_Whitespace() + { + Check(" SomeProperty ", new PropertyPathGrammar.PropertySyntax {Name = "SomeProperty"}); + } + + [Fact] + public void PropertyPath_Should_Support_Qualified_Properties() + { + Check(" ( somens:SomeType.SomeProperty ) ", + new PropertyPathGrammar.TypeQualifiedPropertySyntax() + { + Name = "SomeProperty", TypeName = "SomeType", TypeNamespace = "somens" + }); + } + + [Fact] + public void PropertyPath_Should_Support_Property_Paths() + { + Check(" ( somens:SomeType.SomeProperty ).Child . SubChild ", + new PropertyPathGrammar.TypeQualifiedPropertySyntax() + { + Name = "SomeProperty", TypeName = "SomeType", TypeNamespace = "somens" + }, + PropertyPathGrammar.ChildTraversalSyntax.Instance, + new PropertyPathGrammar.PropertySyntax {Name = "Child"}, + PropertyPathGrammar.ChildTraversalSyntax.Instance, + new PropertyPathGrammar.PropertySyntax {Name = "SubChild"} + ); + } + + [Fact] + public void PropertyPath_Should_Support_Casts() + { + Check(" ( somens:SomeType.SomeProperty ) :> SomeType.Child as somens:SomeType . SubChild ", + new PropertyPathGrammar.TypeQualifiedPropertySyntax() + { + Name = "SomeProperty", TypeName = "SomeType", TypeNamespace = "somens" + }, + new PropertyPathGrammar.CastTypeSyntax + { + TypeName = "SomeType" + }, + PropertyPathGrammar.ChildTraversalSyntax.Instance, + new PropertyPathGrammar.PropertySyntax {Name = "Child"}, + new PropertyPathGrammar.CastTypeSyntax + { + TypeName = "SomeType", + TypeNamespace = "somens" + }, + PropertyPathGrammar.ChildTraversalSyntax.Instance, + new PropertyPathGrammar.PropertySyntax {Name = "SubChild"} + ); + } + + [Fact] + public void PropertyPath_Should_Support_Ensure_Type() + { + Check(" ( somens:SomeType.SomeProperty ) := SomeType.Child := somens:SomeType . SubChild ", + new PropertyPathGrammar.TypeQualifiedPropertySyntax() + { + Name = "SomeProperty", TypeName = "SomeType", TypeNamespace = "somens" + }, + new PropertyPathGrammar.EnsureTypeSyntax + { + TypeName = "SomeType" + }, + PropertyPathGrammar.ChildTraversalSyntax.Instance, + new PropertyPathGrammar.PropertySyntax {Name = "Child"}, + new PropertyPathGrammar.EnsureTypeSyntax + { + TypeName = "SomeType", + TypeNamespace = "somens" + }, + PropertyPathGrammar.ChildTraversalSyntax.Instance, + new PropertyPathGrammar.PropertySyntax {Name = "SubChild"} + ); + } + } +}