Skip to content

Commit 13719b4

Browse files
committed
chore: add support for partial xaml parsing
1 parent 91c5efc commit 13719b4

File tree

4 files changed

+288
-29
lines changed

4 files changed

+288
-29
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
7+
namespace Uno.Toolkit.RuntimeTests.Extensions;
8+
9+
internal static class DictionaryExtensions
10+
{
11+
/// <summary>
12+
/// Combine two dictionaries into a new one.
13+
/// </summary>
14+
/// <typeparam name="TKey"></typeparam>
15+
/// <typeparam name="TValue"></typeparam>
16+
/// <param name="dict"></param>
17+
/// <param name="other"></param>
18+
/// <param name="preferOther"></param>
19+
/// <param name="comparer"></param>
20+
/// <returns></returns>
21+
public static IDictionary<TKey,TValue> Combine<TKey, TValue>(
22+
this IReadOnlyDictionary<TKey,TValue> dict,
23+
IReadOnlyDictionary<TKey,TValue>? other,
24+
bool preferOther = true,
25+
IEqualityComparer<TKey>? comparer = null
26+
) where TKey : notnull
27+
{
28+
var result = new Dictionary<TKey, TValue>(dict, comparer);
29+
if (other is { })
30+
{
31+
foreach (var kvp in other)
32+
{
33+
if (preferOther || !result.ContainsKey(kvp.Key))
34+
{
35+
result[kvp.Key] = kvp.Value;
36+
}
37+
}
38+
}
39+
40+
return result;
41+
}
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace Uno.Toolkit.RuntimeTests.Extensions;
5+
6+
internal static class StackExtensions
7+
{
8+
public static IEnumerable<T> PopWhile<T>(this Stack<T> stack, Func<T, bool> predicate)
9+
{
10+
while (stack.TryPeek(out var item) && predicate(item))
11+
{
12+
yield return stack.Pop();
13+
}
14+
}
15+
}

Diff for: src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs

+145-29
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
using System;
2+
using System.Collections;
23
using System.Collections.Generic;
34
using System.Linq;
45
using System.Text;
56
using System.Text.RegularExpressions;
67
using Microsoft.VisualStudio.TestTools.UnitTesting;
8+
using Uno.Toolkit.RuntimeTests.Extensions;
9+
using static Uno.UI.FeatureConfiguration;
10+
711

812
#if IS_WINUI
913
using Microsoft.UI.Xaml.Markup;
@@ -15,6 +19,15 @@ namespace Uno.Toolkit.RuntimeTests.Helpers
1519
{
1620
internal static class XamlHelper
1721
{
22+
public static readonly IReadOnlyDictionary<string, string> KnownXmlnses = new Dictionary<string, string>
23+
{
24+
[string.Empty] = "http://schemas.microsoft.com/winfx/2006/xaml/presentation",
25+
["x"] = "http://schemas.microsoft.com/winfx/2006/xaml",
26+
["toolkit"] = "using:Uno.UI.Toolkit", // uno utilities
27+
["utu"] = "using:Uno.Toolkit.UI", // this library
28+
["muxc"] = "using:Microsoft.UI.Xaml.Controls",
29+
};
30+
1831
/// <summary>
1932
/// Matches right before the &gt; or \&gt; tail of any tag.
2033
/// </summary>
@@ -28,56 +41,159 @@ internal static class XamlHelper
2841
/// </summary>
2942
private static readonly Regex NonXmlnsTagRegex = new Regex(@"<\w+[ />]");
3043

31-
private static readonly IReadOnlyDictionary<string, string> KnownXmlnses = new Dictionary<string, string>
32-
{
33-
[string.Empty] = "http://schemas.microsoft.com/winfx/2006/xaml/presentation",
34-
["x"] = "http://schemas.microsoft.com/winfx/2006/xaml",
35-
["toolkit"] = "using:Uno.UI.Toolkit", // uno utilities
36-
["utu"] = "using:Uno.Toolkit.UI", // this library
37-
["muxc"] = "using:Microsoft.UI.Xaml.Controls",
38-
};
44+
/// <summary>
45+
/// Matches any open/open-hanging/self-close/close tag.
46+
/// </summary>
47+
/// <remarks>open-hanging refers to xml tag that opens, but span on multiple lines.</remarks>
48+
private static readonly Regex XmlTagRegex = new Regex("<[^>]+(>|$)");
3949

4050
/// <summary>
41-
/// XamlReader.Load the xaml and type-check result.
51+
/// Auto complete any unclosed tag.
4252
/// </summary>
43-
/// <param name="xaml">Xaml with single or double quotes</param>
44-
/// <param name="autoInjectXmlns">Toggle automatic detection of xmlns required and inject to the xaml</param>
45-
public static T LoadXaml<T>(string xaml, bool autoInjectXmlns = true) where T : class
53+
/// <param name="xaml"></param>
54+
/// <returns></returns>
55+
internal static string XamlAutoFill(string xaml)
4656
{
47-
var xmlnses = new Dictionary<string, string>();
57+
var buffer = new StringBuilder();
4858

49-
if (autoInjectXmlns)
59+
// we assume the input is either space or tab indented, not mixed.
60+
// it doesnt really matter here if we count the depth in 1 or 2 or 4,
61+
// since they will be compared against themselves, which hopefully follow the same "style".
62+
var stack = new Stack<(string Indent, string Name)>();
63+
void PopFrame((string Indent, string Name) frame)
64+
{
65+
buffer.AppendLine($"{frame.Indent}</{frame.Name}>");
66+
}
67+
void PopStack(Stack<(string Indent, string Name)> stack)
5068
{
51-
foreach (var xmlns in KnownXmlnses)
69+
while (stack.TryPop(out var item))
5270
{
53-
var match = xmlns.Key == string.Empty
54-
? NonXmlnsTagRegex.IsMatch(xaml)
55-
// naively match the xmlns-prefix regardless if it is quoted,
56-
// since false positive doesn't matter.
57-
: xaml.Contains($"{xmlns.Key}:");
58-
if (match)
71+
PopFrame(item);
72+
}
73+
}
74+
75+
var lines = string.Concat(xaml.Split('\r')).Split('\n');
76+
foreach (var line in lines)
77+
{
78+
if (line.TrimStart() is { Length: > 0 } content)
79+
{
80+
var depth = line.Length - content.Length;
81+
var indent = line[0..depth];
82+
83+
// we should parse all tags on this line: Open OpenHanging SelfClose Close
84+
// then close all 'open/open-hanging' tags in the stack with higher depth
85+
// while pairing `Close` in the left-most part of current line with whats in stack that match name and depth, and eliminate them
86+
87+
var overflows = new Stack<(string Indent, string Name)>(stack.PopWhile(x => x.Indent.Length >= depth).Reverse());
88+
var tags = XmlTagRegex.Matches(content).Select(x => x.Value).ToArray();
89+
foreach (var tag in tags)
5990
{
60-
xmlnses.Add(xmlns.Key, xmlns.Value);
91+
if (tag.StartsWith("<!"))
92+
{
93+
PopStack(overflows);
94+
}
95+
else if (tag.EndsWith("/>"))
96+
{
97+
PopStack(overflows);
98+
}
99+
else if (tag.StartsWith("</"))
100+
{
101+
var name = tag.Split(' ', '>')[0][2..];
102+
while (overflows.TryPop(out var overflow))
103+
{
104+
if (overflow.Name == name) break;
105+
106+
PopFrame(overflow);
107+
}
108+
}
109+
else
110+
{
111+
PopStack(overflows);
112+
113+
var name = tag.Split(' ', '/', '>')[0][1..];
114+
stack.Push((indent, name));
115+
}
61116
}
62117
}
118+
buffer.AppendLine(line);
63119
}
64120

65-
return LoadXaml<T>(xaml, xmlnses);
121+
PopStack(stack);
122+
return buffer.ToString();
123+
}
124+
125+
/// <summary>
126+
/// Inject any required xmlns.
127+
/// </summary>
128+
/// <param name="xaml"></param>
129+
/// <param name="xmlnses">Optional; used to override <see cref="KnownXmlnses"/>.</param>
130+
/// <param name="complementaryXmlnses">Completary xmlnses that adds to <paramref name="xmlnses"/></param>
131+
/// <returns></returns>
132+
internal static string InjectXmlns(string xaml, IDictionary<string, string>? xmlnses = null, IDictionary<string, string>? complementaryXmlnses = null)
133+
{
134+
var xmlnsLookup = (xmlnses?.AsReadOnly() ?? KnownXmlnses).Combine(complementaryXmlnses?.AsReadOnly());
135+
var injectables = new Dictionary<string, string>();
136+
137+
foreach (var xmlns in xmlnsLookup)
138+
{
139+
var match = xmlns.Key == string.Empty
140+
? NonXmlnsTagRegex.IsMatch(xaml)
141+
// naively match the xmlns-prefix regardless if it is quoted,
142+
// since false positive doesn't matter.
143+
: xaml.Contains($"{xmlns.Key}:");
144+
if (match)
145+
{
146+
injectables.Add(xmlns.Key, xmlns.Value);
147+
}
148+
}
149+
150+
if (injectables.Any())
151+
{
152+
var injection = " " + string.Join(" ", injectables
153+
.Select(x => $"xmlns{(string.IsNullOrEmpty(x.Key) ? "" : $":{x.Key}")}=\"{x.Value}\"")
154+
);
155+
156+
xaml = EndOfTagRegex.Replace(xaml, injection.TrimEnd(), 1);
157+
}
158+
159+
return xaml;
160+
}
161+
162+
/// <summary>
163+
/// Load partial xaml with omittable closing tags.
164+
/// </summary>
165+
/// <param name="xaml">Xaml with single or double quotes</param>
166+
/// <param name="xmlnses">Optional; xmlns that may be needed. <see cref="KnownXmlnses"/> will be used if null.</param>
167+
/// <param name="complementaryXmlnses">Completary xmlnses that adds to <paramref name="xmlnses"/></param>
168+
/// <returns></returns>
169+
public static T LoadPartialXaml<T>(string xaml, IDictionary<string, string>? xmlnses = null, IDictionary<string, string>? complementaryXmlnses = null)
170+
where T : class
171+
{
172+
xaml = XamlAutoFill(xaml);
173+
xaml = InjectXmlns(xaml, xmlnses, complementaryXmlnses);
174+
175+
return LoadXaml<T>(xaml);
66176
}
67177

68178
/// <summary>
69179
/// XamlReader.Load the xaml and type-check result.
70180
/// </summary>
71181
/// <param name="xaml">Xaml with single or double quotes</param>
72-
/// <param name="xmlnses">Xmlns to inject; use string.Empty for the default xmlns' key</param>
73-
public static T LoadXaml<T>(string xaml, Dictionary<string, string> xmlnses) where T : class
182+
/// <param name="xmlnses">Optional; xmlns that may be needed. <see cref="KnownXmlnses"/> will be used if null.</param>
183+
/// <param name="complementaryXmlnses">Completary xmlnses that adds to <paramref name="xmlnses"/></param>
184+
public static T LoadXaml<T>(string xaml, IDictionary<string, string>? xmlnses = null, IDictionary<string, string>? complementaryXmlnses = null)
185+
where T : class
74186
{
75-
var injection = " " + string.Join(" ", xmlnses
76-
.Select(x => $"xmlns{(string.IsNullOrEmpty(x.Key) ? "" : $":{x.Key}")}=\"{x.Value}\"")
77-
);
187+
xaml = InjectXmlns(xaml, xmlnses, complementaryXmlnses);
78188

79-
xaml = EndOfTagRegex.Replace(xaml, injection.TrimEnd(), 1);
189+
return LoadXaml<T>(xaml, xmlnses);
190+
}
80191

192+
/// <summary>
193+
/// XamlReader.Load the xaml and type-check result.
194+
/// </summary>
195+
private static T LoadXaml<T>(string xaml) where T : class
196+
{
81197
var result = XamlReader.Load(xaml);
82198
Assert.IsNotNull(result, "XamlReader.Load returned null");
83199
Assert.IsInstanceOfType(result, typeof(T), "XamlReader.Load did not return the expected type");
+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
using Microsoft.VisualStudio.TestTools.UnitTesting;
7+
using Uno.Toolkit.RuntimeTests.Helpers;
8+
9+
namespace Uno.Toolkit.RuntimeTests.Tests;
10+
11+
[TestClass]
12+
internal class XamlHelperTests
13+
{
14+
[TestMethod]
15+
public void Complex_Test()
16+
{
17+
var result = XamlHelper.XamlAutoFill("""
18+
<DataTemplate>
19+
<StackPanel>
20+
<Grid>
21+
<!-- test -->
22+
<Button />
23+
<TextBlock>
24+
<TextBlock>
25+
<Grid Background="SkyBlue"
26+
Tag="this unclosed node spans on multiple lines">
27+
<Button>
28+
<TextBlock>
29+
<Button Tag="this one has closing">
30+
<TextBlock>
31+
</Button>
32+
<Grid Tag="single-line multi-nesting"><Grid><Grid Tag="multi-line"
33+
Background="Pink">
34+
<Button>
35+
<Grid Tag="self-closing tag, should not have have closing tag appended"/>
36+
<Button />
37+
<Grid><Border><Grid>
38+
<Button Content="ThisShouldStillWork" />
39+
</Grid></Border></Grid>
40+
<GridA><Border><GridB>
41+
""").TrimEnd();
42+
var expectation = """
43+
<DataTemplate>
44+
<StackPanel>
45+
<Grid>
46+
<!-- test -->
47+
<Button />
48+
<TextBlock>
49+
</TextBlock>
50+
<TextBlock>
51+
</TextBlock>
52+
</Grid>
53+
<Grid Background="SkyBlue"
54+
Tag="this unclosed node spans on multiple lines">
55+
<Button>
56+
<TextBlock>
57+
</TextBlock>
58+
</Button>
59+
<Button Tag="this one has closing">
60+
<TextBlock>
61+
</TextBlock>
62+
</Button>
63+
</Grid>
64+
<Grid Tag="single-line multi-nesting"><Grid><Grid Tag="multi-line"
65+
Background="Pink">
66+
<Button>
67+
</Button>
68+
</Grid>
69+
</Grid>
70+
</Grid>
71+
<Grid Tag="self-closing tag, should not have have closing tag appended"/>
72+
<Button />
73+
</StackPanel>
74+
<Grid><Border><Grid>
75+
<Button Content="ThisShouldStillWork" />
76+
</Grid></Border></Grid>
77+
<GridA><Border><GridB>
78+
</GridB>
79+
</Border>
80+
</GridA>
81+
</DataTemplate>
82+
""".TrimEnd();
83+
84+
Assert.AreEqual(expectation, result);
85+
}
86+
}

0 commit comments

Comments
 (0)