Skip to content

Commit 428b08c

Browse files
committed
feat(xaml): support named params in MarkupExtension
1 parent 4fc5c92 commit 428b08c

File tree

6 files changed

+200
-65
lines changed

6 files changed

+200
-65
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System;
2+
using System.Collections;
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using System.Text;
6+
using System.Text.RegularExpressions;
7+
using System.Threading.Tasks;
8+
using NUnit.Framework;
9+
10+
namespace Uno.Xaml.Tests.Test.System.Xaml
11+
{
12+
[TestFixture]
13+
public class ParsedMarkupExtensionInfoTests
14+
{
15+
public static IEnumerable GetSliceParametersTestCases()
16+
{
17+
yield return new TestCaseData("{Binding Path}").Returns(new[] { "Path" });
18+
yield return new TestCaseData("{Binding Path=Path}").Returns(new[] { "Path=Path" });
19+
yield return new TestCaseData("{Binding Path, Converter={StaticResource asd}}").Returns(new[]
20+
{
21+
"Path",
22+
"Converter={StaticResource asd}"
23+
});
24+
yield return new TestCaseData("{Binding Test={some:NestedExtension With,Multiple,Paremeters}, Prop2='Asd'}").Returns(new[]
25+
{
26+
"Test={some:NestedExtension With,Multiple,Paremeters}",
27+
"Prop2='Asd'"
28+
});
29+
yield return new TestCaseData("{Binding Test='text,with,commas,in', Prop1={some:NestedExtension With,Multiple,Paremeters}, Prop2='Asd'}").Returns(new[]
30+
{
31+
"Test='text,with,commas,in'",
32+
"Prop1={some:NestedExtension With,Multiple,Paremeters}",
33+
"Prop2='Asd'"
34+
});
35+
yield return new TestCaseData("{Binding Test1='{}{escaped open bracket', Prop1={some:NestedExtension}, Test2='}close bracket'}").Returns(new[]
36+
{
37+
"Test1='{}{escaped open bracket'",
38+
"Prop1={some:NestedExtension}",
39+
"Test2='}close bracket'"
40+
});
41+
yield return new TestCaseData("{Binding Test1='{', Prop1={some:NestedExtension}, Test2='}close bracket'}").Returns(new[]
42+
{
43+
"Test1='{'",
44+
"Prop1={some:NestedExtension}",
45+
"Test2='}close bracket'"
46+
});
47+
yield return new TestCaseData("{Binding Test1=value without single-quot and with space is legal, Prop2=asd}").Returns(new[]
48+
{
49+
"Test1=value without single-quot and with space is legal",
50+
"Prop2=asd"
51+
});
52+
yield return new TestCaseData("{Binding Test1='{}{however to use the {} escape or comma, you need the single-quots}'}").Returns(new[]
53+
{
54+
"Test1='{}{however to use the {} escape or comma, you need the single-quots}'"
55+
});
56+
}
57+
58+
[Test]
59+
[TestCaseSource(nameof(GetSliceParametersTestCases))]
60+
public string[] SliceParametersTest(string raw)
61+
{
62+
// extract only the vargs portion without the starting `{Binding` and the ending `}`
63+
var vargs = Regex.Match(raw, "^{[^ ]+( (?<vargs>.+))?}$").Groups["vargs"].Value;
64+
65+
return ParsedMarkupExtensionInfo.SliceParameters(vargs, raw).ToArray();
66+
}
67+
}
68+
}

src/SourceGenerators/System.Xaml/Assembly/AssemblyInfo.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,5 @@
6868
#if !MOBILE && !NETSTANDARD2_0 && !NET5_0
6969
[assembly: TypeForwardedTo (typeof (System.Windows.Markup.ValueSerializerAttribute))]
7070
#endif
71+
72+
[assembly: InternalsVisibleTo("Uno.Xaml.Tests")]

src/SourceGenerators/System.Xaml/System.Xaml/ParsedMarkupExtensionInfo.cs

Lines changed: 58 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
// distribute, sublicense, and/or sell copies of the Software, and to
99
// permit persons to whom the Software is furnished to do so, subject to
1010
// the following conditions:
11-
//
11+
//
1212
// The above copyright notice and this permission notice shall be
1313
// included in all copies or substantial portions of the Software.
14-
//
14+
//
1515
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
1616
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
1717
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
@@ -32,28 +32,6 @@ namespace Uno.Xaml
3232
{
3333
internal class ParsedMarkupExtensionInfo
3434
{
35-
/// <summary>
36-
/// This regex returns the members of a binding expression which are separated
37-
/// by commas but keeps the commas inside the member value.
38-
/// e.g. [Property], ConverterParameter='A', TargetNullValue='B', FallbackValue='C,D,E,F' returns
39-
/// - [Property]
40-
/// - ConverterParameter='A'
41-
/// - TargetNullValue='B'
42-
/// - FallbackValue='C,D,E,F'
43-
/// </summary>
44-
private static Regex BindingMembersRegex = new Regex("[^'\",]+'[^^']+'|[^'\",]+\"[^\"]+\"|[^,]+");
45-
private static Regex BalancedMarkupBlockRegex = new Regex(@"
46-
# modified from: https://stackoverflow.com/a/7899205
47-
{ # First '{'
48-
(?:
49-
[^{}]| # Match all non-braces
50-
(?<open>{)| # Match '{', and capture into 'open'
51-
(?<-open>}) # Match '}', and delete the 'open' capture
52-
)+?
53-
(?(open)(?!)) # Fails if 'open' stack isn't empty!
54-
} # Last '}'
55-
", RegexOptions.IgnorePatternWhitespace);
56-
5735
Dictionary<XamlMember, object> args = new Dictionary<XamlMember, object>();
5836
public Dictionary<XamlMember, object> Arguments
5937
{
@@ -74,55 +52,40 @@ public static ParsedMarkupExtensionInfo Parse(string raw, IXamlNamespaceResolver
7452
throw Error("Invalid markup extension attribute. It should begin with '{{', but was {0}", raw);
7553
}
7654

77-
var ret = new ParsedMarkupExtensionInfo();
78-
int idx = raw.LastIndexOf('}');
55+
if (raw.Length >= 2 && raw[1] == '}')
56+
{
57+
throw Error("Markup extension can not begin with an '{}' escape: '{0}'", raw);
58+
}
7959

80-
if (idx < 0)
60+
var ret = new ParsedMarkupExtensionInfo();
61+
if (raw[raw.Length - 1] != '}')
8162
{
63+
// Any character after the final closing bracket is not accepted. Therefore, the last character should be '}'.
64+
// Ideally, we should still ran the entire markup through the parser to get a more meaningful error.
8265
throw Error("Expected '}}' in the markup extension attribute: '{0}'", raw);
8366
}
84-
85-
raw = raw.Substring(1, idx - 1);
86-
idx = raw.IndexOf(' ');
87-
string name = idx < 0 ? raw : raw.Substring(0, idx);
8867

89-
XamlTypeName xtn;
90-
if (!XamlTypeName.TryParse(name, nsResolver, out xtn))
68+
var nameSeparatorIndex = raw.IndexOf(' ');
69+
var name = nameSeparatorIndex != -1 ? raw.Substring(1, nameSeparatorIndex - 1) : raw.Substring(1, raw.Length - 2);
70+
if (!XamlTypeName.TryParse(name, nsResolver, out var xtn))
9171
{
9272
throw Error("Failed to parse type name '{0}'", name);
9373
}
9474

9575
var xt = sctx.GetXamlType(xtn) ?? new XamlType(xtn.Namespace, xtn.Name, null, sctx);
9676
ret.Type = xt;
9777

98-
if (idx < 0)
78+
if (nameSeparatorIndex < 0)
9979
return ret;
10080

101-
var valueWithoutBinding = raw.Substring(idx + 1, raw.Length - idx - 1);
102-
103-
//var vpairs = BindingMembersRegex.Matches(valueWithoutBinding)
104-
// .Cast<Match>()
105-
// .Select(m => m.Value.Trim())
106-
// .ToList();
107-
//if (vpairs.Count == 0)
108-
//{
109-
// vpairs.Add(valueWithoutBinding);
110-
//}
111-
112-
var innerMarkups = BalancedMarkupBlockRegex.Matches(valueWithoutBinding)
113-
.OfType<Match>(); // needed for net461, netstandard2.0
114-
var indexes = IndexOfAll(valueWithoutBinding, ',')
115-
// ignore those separators used within inner markups
116-
.Where(x => !innerMarkups.Any(y => y.Index <= x && x <= y.Index + y.Length - 1));
117-
var vpairs = SplitByIndex(valueWithoutBinding, indexes)
118-
.Select(x => x.Trim())
119-
.ToList();
81+
var valueWithoutBinding = raw.Substring(nameSeparatorIndex + 1, raw.Length - 1 - (nameSeparatorIndex + 1));
82+
var vpairs = SliceParameters(valueWithoutBinding, raw);
12083

12184
List<string> posPrms = null;
12285
XamlMember lastMember = null;
12386
foreach (var vpair in vpairs)
12487
{
125-
idx = vpair.IndexOf('=');
88+
var idx = vpair.IndexOf('=');
12689

12790
// FIXME: unescape string (e.g. comma)
12891
if (idx < 0)
@@ -223,21 +186,51 @@ static Exception Error(string format, params object[] args)
223186
return new XamlParseException(String.Format(format, args));
224187
}
225188

226-
static IEnumerable<int> IndexOfAll(string x, char value) => x
227-
.Select(Tuple.Create<char, int>)
228-
.Where(x => x.Item1 == value)
229-
.Select(x => x.Item2);
230-
231-
static IEnumerable<string> SplitByIndex(string x, IEnumerable<int> indexes)
189+
internal static IEnumerable<string> SliceParameters(string vargs, string raw)
232190
{
233-
var previousIndex = 0;
234-
foreach (var index in indexes.OrderBy(i => i))
191+
vargs = vargs.Trim();
192+
193+
// We need to split the parameters by the commas, but with two catches:
194+
// 1. Nested markup extension can also contains multiple parameters, but they are a single parameter to the current context
195+
// 2. Comma can appear within a single-quoted string.
196+
// 3. a little bit of #1 and a little bit #2...
197+
// While we can use regex to match #1 and #2, #3 cannot be solved with regex.
198+
199+
// It seems that single-quot(`'`) can't be escaped when used in the parameters.
200+
// So we don't have to worry about escaping it.
201+
202+
var isInQuot = false;
203+
var bracketDepth = 0;
204+
var lastSliceIndex = -1;
205+
206+
for (int i = 0; i < vargs.Length; i++)
207+
{
208+
var c = vargs[i];
209+
if (false) { }
210+
else if (c == '\'') isInQuot = !isInQuot;
211+
else if (isInQuot) { }
212+
else if (c == '{') bracketDepth++;
213+
else if (c == '}')
214+
{
215+
bracketDepth--;
216+
if (bracketDepth > 0)
217+
{
218+
throw Error("Unexpected '}}' in markup extension: '{0}'", raw);
219+
}
220+
}
221+
else if (c == ',' && bracketDepth == 0)
222+
{
223+
yield return vargs.Substring(lastSliceIndex + 1, i - lastSliceIndex - 1).Trim();
224+
lastSliceIndex = i;
225+
}
226+
}
227+
228+
if (bracketDepth > 0)
235229
{
236-
yield return x.Substring(previousIndex, index - previousIndex);
237-
previousIndex = index + 1;
230+
throw Error("Expected '}}' in markup extension:", raw);
238231
}
239232

240-
yield return x.Substring(previousIndex);
233+
yield return vargs.Substring(lastSliceIndex + 1).Trim();
241234
}
242235
}
243236
}

src/SourceGenerators/Uno.UI.SourceGenerators/XamlGenerator/XamlFileGenerator.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4868,6 +4868,11 @@ private string BuildBindingOption(XamlMemberDefinition m, INamedTypeSymbol? prop
48684868
return BuildLiteralValue(namedTypeSymbol, value.ToString());
48694869
}
48704870

4871+
if (IsCustomMarkupExtensionType(bindingType.Type))
4872+
{
4873+
return GetCustomMarkupExtensionValue(m);
4874+
}
4875+
48714876
// If type specified in the binding was not found, log and return an error message
48724877
if (!string.IsNullOrEmpty(bindingType.Type.Name))
48734878
{
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<UserControl x:Class="XamlGenerationTests.MarkupExtensionTests"
2+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4+
xmlns:ext="using:XamlGenerationTests.MarkupExtensions">
5+
6+
<UserControl.Resources>
7+
<ext:NotImplementedConverter x:Key="SomeNotImplementedConverter" />
8+
</UserControl.Resources>
9+
10+
<StackPanel>
11+
<TextBlock Text="{Binding Converter={StaticResource SomeNotImplementedConverter}}" />
12+
<!-- TODO: add support for positional parameters -->
13+
<!-- <TextBlock Text="{Binding Converter={StaticResource SomeNotImplementedConverter}, ConverterParameter={ext:MarkupWithArgsExtension 1, 2}}" /> -->
14+
<TextBlock Text="{Binding Converter={StaticResource SomeNotImplementedConverter}, ConverterParameter={ext:MarkupWithArgsExtension Prop1=1, Prop2=2}}" />
15+
</StackPanel>
16+
</UserControl>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
using Windows.UI.Xaml.Controls;
7+
using Windows.UI.Xaml;
8+
using Windows.UI.Xaml.Markup;
9+
using Windows.UI.Xaml.Data;
10+
// using System.Windows.Markup; // <-- will cause MarkupWithArgsExtension to fail
11+
// ^ as it would instead inherits from System.Windows.Markup rather than Windows.UI.Xaml.Markup
12+
13+
namespace XamlGenerationTests
14+
{
15+
public partial class MarkupExtensionTests : UserControl
16+
{
17+
public MarkupExtensionTests()
18+
{
19+
InitializeComponent();
20+
}
21+
}
22+
}
23+
24+
namespace XamlGenerationTests.MarkupExtensions
25+
{
26+
public class NotImplementedConverter : IValueConverter
27+
{
28+
public object Convert(object value, Type targetType, object parameter, string language) => throw new NotImplementedException();
29+
public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException();
30+
}
31+
32+
public class MarkupWithArgsExtension : MarkupExtension
33+
{
34+
public MarkupWithArgsExtension()
35+
{
36+
}
37+
public MarkupWithArgsExtension(object prop1, object prop2)
38+
{
39+
this.Prop1 = prop1;
40+
this.Prop2 = prop2;
41+
}
42+
43+
[System.Windows.Markup.ConstructorArgument("prop1")]
44+
public object Prop1 { get; set; }
45+
46+
[System.Windows.Markup.ConstructorArgument("prop2")]
47+
public object Prop2 { get; set; }
48+
49+
protected override object ProvideValue() => this;
50+
}
51+
}

0 commit comments

Comments
 (0)