Skip to content

Commit 96bcb90

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

File tree

4 files changed

+286
-29
lines changed

4 files changed

+286
-29
lines changed
Lines changed: 42 additions & 0 deletions
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+
}
Lines changed: 15 additions & 0 deletions
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+
}

src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs

Lines changed: 143 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
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;
79

810
#if IS_WINUI
911
using Microsoft.UI.Xaml.Markup;
@@ -15,6 +17,15 @@ namespace Uno.Toolkit.RuntimeTests.Helpers
1517
{
1618
internal static class XamlHelper
1719
{
20+
public static readonly IReadOnlyDictionary<string, string> KnownXmlnses = new Dictionary<string, string>
21+
{
22+
[string.Empty] = "http://schemas.microsoft.com/winfx/2006/xaml/presentation",
23+
["x"] = "http://schemas.microsoft.com/winfx/2006/xaml",
24+
["toolkit"] = "using:Uno.UI.Toolkit", // uno utilities
25+
["utu"] = "using:Uno.Toolkit.UI", // this library
26+
["muxc"] = "using:Microsoft.UI.Xaml.Controls",
27+
};
28+
1829
/// <summary>
1930
/// Matches right before the &gt; or \&gt; tail of any tag.
2031
/// </summary>
@@ -28,56 +39,159 @@ internal static class XamlHelper
2839
/// </summary>
2940
private static readonly Regex NonXmlnsTagRegex = new Regex(@"<\w+[ />]");
3041

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-
};
42+
/// <summary>
43+
/// Matches any open/open-hanging/self-close/close tag.
44+
/// </summary>
45+
/// <remarks>open-hanging refers to xml tag that opens, but span on multiple lines.</remarks>
46+
private static readonly Regex XmlTagRegex = new Regex("<[^>]+(>|$)");
3947

4048
/// <summary>
41-
/// XamlReader.Load the xaml and type-check result.
49+
/// Auto complete any unclosed tag.
4250
/// </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
51+
/// <param name="xaml"></param>
52+
/// <returns></returns>
53+
internal static string XamlAutoFill(string xaml)
4654
{
47-
var xmlnses = new Dictionary<string, string>();
55+
var buffer = new StringBuilder();
4856

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

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

68176
/// <summary>
69177
/// XamlReader.Load the xaml and type-check result.
70178
/// </summary>
71179
/// <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
180+
/// <param name="xmlnses">Optional; xmlns that may be needed. <see cref="KnownXmlnses"/> will be used if null.</param>
181+
/// <param name="complementaryXmlnses">Completary xmlnses that adds to <paramref name="xmlnses"/></param>
182+
public static T LoadXaml<T>(string xaml, IDictionary<string, string>? xmlnses = null, IDictionary<string, string>? complementaryXmlnses = null)
183+
where T : class
74184
{
75-
var injection = " " + string.Join(" ", xmlnses
76-
.Select(x => $"xmlns{(string.IsNullOrEmpty(x.Key) ? "" : $":{x.Key}")}=\"{x.Value}\"")
77-
);
185+
xaml = InjectXmlns(xaml, xmlnses, complementaryXmlnses);
78186

79-
xaml = EndOfTagRegex.Replace(xaml, injection.TrimEnd(), 1);
187+
return LoadXaml<T>(xaml, xmlnses);
188+
}
80189

190+
/// <summary>
191+
/// XamlReader.Load the xaml and type-check result.
192+
/// </summary>
193+
private static T LoadXaml<T>(string xaml) where T : class
194+
{
81195
var result = XamlReader.Load(xaml);
82196
Assert.IsNotNull(result, "XamlReader.Load returned null");
83197
Assert.IsInstanceOfType(result, typeof(T), "XamlReader.Load did not return the expected type");
Lines changed: 86 additions & 0 deletions
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)