Skip to content

Commit

Permalink
chore: add support for partial xaml parsing
Browse files Browse the repository at this point in the history
  • Loading branch information
Xiaoy312 committed Dec 3, 2024
1 parent 91c5efc commit 96bcb90
Show file tree
Hide file tree
Showing 4 changed files with 286 additions and 29 deletions.
42 changes: 42 additions & 0 deletions src/Uno.Toolkit.RuntimeTests/Extensions/DictionaryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Uno.Toolkit.RuntimeTests.Extensions;

internal static class DictionaryExtensions
{
/// <summary>
/// Combine two dictionaries into a new one.
/// </summary>
/// <typeparam name="TKey"></typeparam>
/// <typeparam name="TValue"></typeparam>
/// <param name="dict"></param>
/// <param name="other"></param>
/// <param name="preferOther"></param>
/// <param name="comparer"></param>
/// <returns></returns>
public static IDictionary<TKey,TValue> Combine<TKey, TValue>(
this IReadOnlyDictionary<TKey,TValue> dict,
IReadOnlyDictionary<TKey,TValue>? other,
bool preferOther = true,
IEqualityComparer<TKey>? comparer = null
) where TKey : notnull
{
var result = new Dictionary<TKey, TValue>(dict, comparer);
if (other is { })
{
foreach (var kvp in other)
{
if (preferOther || !result.ContainsKey(kvp.Key))
{
result[kvp.Key] = kvp.Value;
}
}
}

return result;
}
}
15 changes: 15 additions & 0 deletions src/Uno.Toolkit.RuntimeTests/Extensions/StackExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;

namespace Uno.Toolkit.RuntimeTests.Extensions;

internal static class StackExtensions
{
public static IEnumerable<T> PopWhile<T>(this Stack<T> stack, Func<T, bool> predicate)
{
while (stack.TryPeek(out var item) && predicate(item))
{
yield return stack.Pop();
}
}
}
172 changes: 143 additions & 29 deletions src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Uno.Toolkit.RuntimeTests.Extensions;

#if IS_WINUI
using Microsoft.UI.Xaml.Markup;
Expand All @@ -15,6 +17,15 @@ namespace Uno.Toolkit.RuntimeTests.Helpers
{
internal static class XamlHelper
{
public static readonly IReadOnlyDictionary<string, string> KnownXmlnses = new Dictionary<string, string>
{
[string.Empty] = "http://schemas.microsoft.com/winfx/2006/xaml/presentation",
["x"] = "http://schemas.microsoft.com/winfx/2006/xaml",
["toolkit"] = "using:Uno.UI.Toolkit", // uno utilities
["utu"] = "using:Uno.Toolkit.UI", // this library
["muxc"] = "using:Microsoft.UI.Xaml.Controls",
};

/// <summary>
/// Matches right before the &gt; or \&gt; tail of any tag.
/// </summary>
Expand All @@ -28,56 +39,159 @@ internal static class XamlHelper
/// </summary>
private static readonly Regex NonXmlnsTagRegex = new Regex(@"<\w+[ />]");

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

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

if (autoInjectXmlns)
// we assume the input is either space or tab indented, not mixed.
// it doesnt really matter here if we count the depth in 1 or 2 or 4,
// since they will be compared against themselves, which hopefully follow the same "style".
var stack = new Stack<(string Indent, string Name)>();
void PopFrame((string Indent, string Name) frame)
{
foreach (var xmlns in KnownXmlnses)
buffer.AppendLine($"{frame.Indent}</{frame.Name}>");
}
void PopStack(Stack<(string Indent, string Name)> stack)
{
while (stack.TryPop(out var item))
{
var match = xmlns.Key == string.Empty
? NonXmlnsTagRegex.IsMatch(xaml)
// naively match the xmlns-prefix regardless if it is quoted,
// since false positive doesn't matter.
: xaml.Contains($"{xmlns.Key}:");
if (match)
PopFrame(item);
}
}

var lines = string.Concat(xaml.Split('\r')).Split('\n');
foreach (var line in lines)
{
if (line.TrimStart() is { Length: > 0 } content)
{
var depth = line.Length - content.Length;
var indent = line[0..depth];

// we should parse all tags on this line: Open OpenHanging SelfClose Close
// then close all 'open/open-hanging' tags in the stack with higher depth
// while pairing `Close` in the left-most part of current line with whats in stack that match name and depth, and eliminate them

var overflows = new Stack<(string Indent, string Name)>(stack.PopWhile(x => x.Indent.Length >= depth).Reverse());
var tags = XmlTagRegex.Matches(content).Select(x => x.Value).ToArray();
foreach (var tag in tags)
{
xmlnses.Add(xmlns.Key, xmlns.Value);
if (tag.StartsWith("<!"))
{
PopStack(overflows);
}
else if (tag.EndsWith("/>"))
{
PopStack(overflows);
}
else if (tag.StartsWith("</"))
{
var name = tag.Split(' ', '>')[0][2..];
while (overflows.TryPop(out var overflow))
{
if (overflow.Name == name) break;

PopFrame(overflow);
}
}
else
{
PopStack(overflows);

var name = tag.Split(' ', '/', '>')[0][1..];
stack.Push((indent, name));
}
}
}
buffer.AppendLine(line);
}

return LoadXaml<T>(xaml, xmlnses);
PopStack(new(stack.Reverse()));
return buffer.ToString();
}

/// <summary>
/// Inject any required xmlns.
/// </summary>
/// <param name="xaml"></param>
/// <param name="xmlnses">Optional; used to override <see cref="KnownXmlnses"/>.</param>
/// <param name="complementaryXmlnses">Completary xmlnses that adds to <paramref name="xmlnses"/></param>
/// <returns></returns>
internal static string InjectXmlns(string xaml, IDictionary<string, string>? xmlnses = null, IDictionary<string, string>? complementaryXmlnses = null)
{
var xmlnsLookup = (xmlnses?.AsReadOnly() ?? KnownXmlnses).Combine(complementaryXmlnses?.AsReadOnly());
var injectables = new Dictionary<string, string>();

foreach (var xmlns in xmlnsLookup)
{
var match = xmlns.Key == string.Empty
? NonXmlnsTagRegex.IsMatch(xaml)
// naively match the xmlns-prefix regardless if it is quoted,
// since false positive doesn't matter.
: xaml.Contains($"{xmlns.Key}:");
if (match)
{
injectables.Add(xmlns.Key, xmlns.Value);
}
}

if (injectables.Any())
{
var injection = " " + string.Join(" ", injectables
.Select(x => $"xmlns{(string.IsNullOrEmpty(x.Key) ? "" : $":{x.Key}")}=\"{x.Value}\"")
);

xaml = EndOfTagRegex.Replace(xaml, injection.TrimEnd(), 1);
}

return xaml;
}

/// <summary>
/// Load partial xaml with omittable closing tags.
/// </summary>
/// <param name="xaml">Xaml with single or double quotes</param>
/// <param name="xmlnses">Optional; xmlns that may be needed. <see cref="KnownXmlnses"/> will be used if null.</param>
/// <param name="complementaryXmlnses">Completary xmlnses that adds to <paramref name="xmlnses"/></param>
/// <returns></returns>
public static T LoadPartialXaml<T>(string xaml, IDictionary<string, string>? xmlnses = null, IDictionary<string, string>? complementaryXmlnses = null)
where T : class
{
xaml = XamlAutoFill(xaml);
xaml = InjectXmlns(xaml, xmlnses, complementaryXmlnses);

return LoadXaml<T>(xaml);
}

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

xaml = EndOfTagRegex.Replace(xaml, injection.TrimEnd(), 1);
return LoadXaml<T>(xaml, xmlnses);
}

/// <summary>
/// XamlReader.Load the xaml and type-check result.
/// </summary>
private static T LoadXaml<T>(string xaml) where T : class
{
var result = XamlReader.Load(xaml);
Assert.IsNotNull(result, "XamlReader.Load returned null");
Assert.IsInstanceOfType(result, typeof(T), "XamlReader.Load did not return the expected type");
Expand Down
86 changes: 86 additions & 0 deletions src/Uno.Toolkit.RuntimeTests/Tests/XamlHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Uno.Toolkit.RuntimeTests.Helpers;

namespace Uno.Toolkit.RuntimeTests.Tests;

[TestClass]
internal class XamlHelperTests
{
[TestMethod]
public void Complex_Test()
{
var result = XamlHelper.XamlAutoFill("""
<DataTemplate>
<StackPanel>
<Grid>
<!-- test -->
<Button />
<TextBlock>
<TextBlock>
<Grid Background="SkyBlue"
Tag="this unclosed node spans on multiple lines">
<Button>
<TextBlock>
<Button Tag="this one has closing">
<TextBlock>
</Button>
<Grid Tag="single-line multi-nesting"><Grid><Grid Tag="multi-line"
Background="Pink">
<Button>
<Grid Tag="self-closing tag, should not have have closing tag appended"/>
<Button />
<Grid><Border><Grid>
<Button Content="ThisShouldStillWork" />
</Grid></Border></Grid>
<GridA><Border><GridB>
""").TrimEnd();
var expectation = """
<DataTemplate>
<StackPanel>
<Grid>
<!-- test -->
<Button />
<TextBlock>
</TextBlock>
<TextBlock>
</TextBlock>
</Grid>
<Grid Background="SkyBlue"
Tag="this unclosed node spans on multiple lines">
<Button>
<TextBlock>
</TextBlock>
</Button>
<Button Tag="this one has closing">
<TextBlock>
</TextBlock>
</Button>
</Grid>
<Grid Tag="single-line multi-nesting"><Grid><Grid Tag="multi-line"
Background="Pink">
<Button>
</Button>
</Grid>
</Grid>
</Grid>
<Grid Tag="self-closing tag, should not have have closing tag appended"/>
<Button />
</StackPanel>
<Grid><Border><Grid>
<Button Content="ThisShouldStillWork" />
</Grid></Border></Grid>
<GridA><Border><GridB>
</GridB>
</Border>
</GridA>
</DataTemplate>
""".TrimEnd();

Assert.AreEqual(expectation, result);
}
}

0 comments on commit 96bcb90

Please sign in to comment.