diff --git a/src/SourceGenerators/System.Xaml.Tests/Test/System.Xaml/ParsedMarkupExtensionInfoTests.cs b/src/SourceGenerators/System.Xaml.Tests/Test/System.Xaml/ParsedMarkupExtensionInfoTests.cs new file mode 100644 index 000000000000..dc90b7904c71 --- /dev/null +++ b/src/SourceGenerators/System.Xaml.Tests/Test/System.Xaml/ParsedMarkupExtensionInfoTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Uno.Xaml.Tests.Test.System.Xaml +{ + [TestFixture] + public class ParsedMarkupExtensionInfoTests + { + public static IEnumerable GetSliceParametersTestCases() + { + yield return new TestCaseData("{Binding Path}").Returns(new[] { "Path" }); + yield return new TestCaseData("{Binding Path=Path}").Returns(new[] { "Path=Path" }); + yield return new TestCaseData("{Binding Path, Converter={StaticResource asd}}").Returns(new[] + { + "Path", + "Converter={StaticResource asd}" + }); + yield return new TestCaseData("{Binding Test={some:NestedExtension With,Multiple,Paremeters}, Prop2='Asd'}").Returns(new[] + { + "Test={some:NestedExtension With,Multiple,Paremeters}", + "Prop2='Asd'" + }); + yield return new TestCaseData("{Binding Test='text,with,commas,in', Prop1={some:NestedExtension With,Multiple,Paremeters}, Prop2='Asd'}").Returns(new[] + { + "Test='text,with,commas,in'", + "Prop1={some:NestedExtension With,Multiple,Paremeters}", + "Prop2='Asd'" + }); + yield return new TestCaseData("{Binding Test1='{}{escaped open bracket', Prop1={some:NestedExtension}, Test2='}close bracket'}").Returns(new[] + { + "Test1='{}{escaped open bracket'", + "Prop1={some:NestedExtension}", + "Test2='}close bracket'" + }); + yield return new TestCaseData("{Binding Test1='{', Prop1={some:NestedExtension}, Test2='}close bracket'}").Returns(new[] + { + "Test1='{'", + "Prop1={some:NestedExtension}", + "Test2='}close bracket'" + }); + yield return new TestCaseData("{Binding Test1=value without single-quot and with space is legal, Prop2=asd}").Returns(new[] + { + "Test1=value without single-quot and with space is legal", + "Prop2=asd" + }); + yield return new TestCaseData("{Binding Test1='{}{however to use the {} escape or comma, you need the single-quots}'}").Returns(new[] + { + "Test1='{}{however to use the {} escape or comma, you need the single-quots}'" + }); + } + + [Test] + [TestCaseSource(nameof(GetSliceParametersTestCases))] + public string[] SliceParametersTest(string raw) + { + // extract only the vargs portion without the starting `{Binding` and the ending `}` + var vargs = Regex.Match(raw, "^{[^ ]+( (?.+))?}$").Groups["vargs"].Value; + + return ParsedMarkupExtensionInfo.SliceParameters(vargs, raw).ToArray(); + } + } +} diff --git a/src/SourceGenerators/System.Xaml/Assembly/AssemblyInfo.cs b/src/SourceGenerators/System.Xaml/Assembly/AssemblyInfo.cs index d91cb323166d..c6f2457f9867 100644 --- a/src/SourceGenerators/System.Xaml/Assembly/AssemblyInfo.cs +++ b/src/SourceGenerators/System.Xaml/Assembly/AssemblyInfo.cs @@ -68,3 +68,5 @@ #if !MOBILE && !NETSTANDARD2_0 && !NET5_0 [assembly: TypeForwardedTo (typeof (System.Windows.Markup.ValueSerializerAttribute))] #endif + +[assembly: InternalsVisibleTo("Uno.Xaml.Tests")] diff --git a/src/SourceGenerators/System.Xaml/System.Xaml/ParsedMarkupExtensionInfo.cs b/src/SourceGenerators/System.Xaml/System.Xaml/ParsedMarkupExtensionInfo.cs index 6a1c81d6d4ce..9f7d4eff1ff3 100644 --- a/src/SourceGenerators/System.Xaml/System.Xaml/ParsedMarkupExtensionInfo.cs +++ b/src/SourceGenerators/System.Xaml/System.Xaml/ParsedMarkupExtensionInfo.cs @@ -8,10 +8,10 @@ // distribute, sublicense, and/or sell copies of the Software, and to // permit persons to whom the Software is furnished to do so, subject to // the following conditions: -// +// // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. -// +// // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND @@ -32,28 +32,6 @@ namespace Uno.Xaml { internal class ParsedMarkupExtensionInfo { - /// - /// This regex returns the members of a binding expression which are separated - /// by commas but keeps the commas inside the member value. - /// e.g. [Property], ConverterParameter='A', TargetNullValue='B', FallbackValue='C,D,E,F' returns - /// - [Property] - /// - ConverterParameter='A' - /// - TargetNullValue='B' - /// - FallbackValue='C,D,E,F' - /// - private static Regex BindingMembersRegex = new Regex("[^'\",]+'[^^']+'|[^'\",]+\"[^\"]+\"|[^,]+"); - private static Regex BalancedMarkupBlockRegex = new Regex(@" - # modified from: https://stackoverflow.com/a/7899205 - { # First '{' - (?: - [^{}]| # Match all non-braces - (?{)| # Match '{', and capture into 'open' - (?<-open>}) # Match '}', and delete the 'open' capture - )+? - (?(open)(?!)) # Fails if 'open' stack isn't empty! - } # Last '}' - ", RegexOptions.IgnorePatternWhitespace); - Dictionary args = new Dictionary(); public Dictionary Arguments { @@ -74,20 +52,22 @@ public static ParsedMarkupExtensionInfo Parse(string raw, IXamlNamespaceResolver throw Error("Invalid markup extension attribute. It should begin with '{{', but was {0}", raw); } - var ret = new ParsedMarkupExtensionInfo(); - int idx = raw.LastIndexOf('}'); + if (raw.Length >= 2 && raw[1] == '}') + { + throw Error("Markup extension can not begin with an '{}' escape: '{0}'", raw); + } - if (idx < 0) + var ret = new ParsedMarkupExtensionInfo(); + if (raw[raw.Length - 1] != '}') { + // Any character after the final closing bracket is not accepted. Therefore, the last character should be '}'. + // Ideally, we should still ran the entire markup through the parser to get a more meaningful error. throw Error("Expected '}}' in the markup extension attribute: '{0}'", raw); } - - raw = raw.Substring(1, idx - 1); - idx = raw.IndexOf(' '); - string name = idx < 0 ? raw : raw.Substring(0, idx); - XamlTypeName xtn; - if (!XamlTypeName.TryParse(name, nsResolver, out xtn)) + var nameSeparatorIndex = raw.IndexOf(' '); + var name = nameSeparatorIndex != -1 ? raw.Substring(1, nameSeparatorIndex - 1) : raw.Substring(1, raw.Length - 2); + if (!XamlTypeName.TryParse(name, nsResolver, out var xtn)) { throw Error("Failed to parse type name '{0}'", name); } @@ -95,34 +75,17 @@ public static ParsedMarkupExtensionInfo Parse(string raw, IXamlNamespaceResolver var xt = sctx.GetXamlType(xtn) ?? new XamlType(xtn.Namespace, xtn.Name, null, sctx); ret.Type = xt; - if (idx < 0) + if (nameSeparatorIndex < 0) return ret; - var valueWithoutBinding = raw.Substring(idx + 1, raw.Length - idx - 1); - - //var vpairs = BindingMembersRegex.Matches(valueWithoutBinding) - // .Cast() - // .Select(m => m.Value.Trim()) - // .ToList(); - //if (vpairs.Count == 0) - //{ - // vpairs.Add(valueWithoutBinding); - //} - - var innerMarkups = BalancedMarkupBlockRegex.Matches(valueWithoutBinding) - .OfType(); // needed for net461, netstandard2.0 - var indexes = IndexOfAll(valueWithoutBinding, ',') - // ignore those separators used within inner markups - .Where(x => !innerMarkups.Any(y => y.Index <= x && x <= y.Index + y.Length - 1)); - var vpairs = SplitByIndex(valueWithoutBinding, indexes) - .Select(x => x.Trim()) - .ToList(); + var valueWithoutBinding = raw.Substring(nameSeparatorIndex + 1, raw.Length - 1 - (nameSeparatorIndex + 1)); + var vpairs = SliceParameters(valueWithoutBinding, raw); List posPrms = null; XamlMember lastMember = null; foreach (var vpair in vpairs) { - idx = vpair.IndexOf('='); + var idx = vpair.IndexOf('='); // FIXME: unescape string (e.g. comma) if (idx < 0) @@ -223,21 +186,51 @@ static Exception Error(string format, params object[] args) return new XamlParseException(String.Format(format, args)); } - static IEnumerable IndexOfAll(string x, char value) => x - .Select(Tuple.Create) - .Where(x => x.Item1 == value) - .Select(x => x.Item2); - - static IEnumerable SplitByIndex(string x, IEnumerable indexes) + internal static IEnumerable SliceParameters(string vargs, string raw) { - var previousIndex = 0; - foreach (var index in indexes.OrderBy(i => i)) + vargs = vargs.Trim(); + + // We need to split the parameters by the commas, but with two catches: + // 1. Nested markup extension can also contains multiple parameters, but they are a single parameter to the current context + // 2. Comma can appear within a single-quoted string. + // 3. a little bit of #1 and a little bit #2... + // While we can use regex to match #1 and #2, #3 cannot be solved with regex. + + // It seems that single-quot(`'`) can't be escaped when used in the parameters. + // So we don't have to worry about escaping it. + + var isInQuot = false; + var bracketDepth = 0; + var lastSliceIndex = -1; + + for (int i = 0; i < vargs.Length; i++) + { + var c = vargs[i]; + if (false) { } + else if (c == '\'') isInQuot = !isInQuot; + else if (isInQuot) { } + else if (c == '{') bracketDepth++; + else if (c == '}') + { + bracketDepth--; + if (bracketDepth > 0) + { + throw Error("Unexpected '}}' in markup extension: '{0}'", raw); + } + } + else if (c == ',' && bracketDepth == 0) + { + yield return vargs.Substring(lastSliceIndex + 1, i - lastSliceIndex - 1).Trim(); + lastSliceIndex = i; + } + } + + if (bracketDepth > 0) { - yield return x.Substring(previousIndex, index - previousIndex); - previousIndex = index + 1; + throw Error("Expected '}}' in markup extension:", raw); } - yield return x.Substring(previousIndex); + yield return vargs.Substring(lastSliceIndex + 1).Trim(); } } } diff --git a/src/SourceGenerators/Uno.UI.SourceGenerators/XamlGenerator/XamlFileGenerator.cs b/src/SourceGenerators/Uno.UI.SourceGenerators/XamlGenerator/XamlFileGenerator.cs index 5fa0eb08315c..79c8b132a7fb 100644 --- a/src/SourceGenerators/Uno.UI.SourceGenerators/XamlGenerator/XamlFileGenerator.cs +++ b/src/SourceGenerators/Uno.UI.SourceGenerators/XamlGenerator/XamlFileGenerator.cs @@ -4868,6 +4868,11 @@ private string BuildBindingOption(XamlMemberDefinition m, INamedTypeSymbol? prop return BuildLiteralValue(namedTypeSymbol, value.ToString()); } + if (IsCustomMarkupExtensionType(bindingType.Type)) + { + return GetCustomMarkupExtensionValue(m); + } + // If type specified in the binding was not found, log and return an error message if (!string.IsNullOrEmpty(bindingType.Type.Name)) { diff --git a/src/SourceGenerators/XamlGenerationTests/MarkupExtensionTests.xaml b/src/SourceGenerators/XamlGenerationTests/MarkupExtensionTests.xaml new file mode 100644 index 000000000000..403b2d3e7101 --- /dev/null +++ b/src/SourceGenerators/XamlGenerationTests/MarkupExtensionTests.xaml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/src/SourceGenerators/XamlGenerationTests/MarkupExtensionTests.xaml.cs b/src/SourceGenerators/XamlGenerationTests/MarkupExtensionTests.xaml.cs new file mode 100644 index 000000000000..fbf3f82846c7 --- /dev/null +++ b/src/SourceGenerators/XamlGenerationTests/MarkupExtensionTests.xaml.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Markup; +using Windows.UI.Xaml.Data; +// using System.Windows.Markup; // <-- will cause MarkupWithArgsExtension to fail +// ^ as it would instead inherits from System.Windows.Markup rather than Windows.UI.Xaml.Markup + +namespace XamlGenerationTests +{ + public partial class MarkupExtensionTests : UserControl + { + public MarkupExtensionTests() + { + InitializeComponent(); + } + } +} + +namespace XamlGenerationTests.MarkupExtensions +{ + public class NotImplementedConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) => throw new NotImplementedException(); + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException(); + } + + public class MarkupWithArgsExtension : MarkupExtension + { + public MarkupWithArgsExtension() + { + } + public MarkupWithArgsExtension(object prop1, object prop2) + { + this.Prop1 = prop1; + this.Prop2 = prop2; + } + + [System.Windows.Markup.ConstructorArgument("prop1")] + public object Prop1 { get; set; } + + [System.Windows.Markup.ConstructorArgument("prop2")] + public object Prop2 { get; set; } + + protected override object ProvideValue() => this; + } +}