Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add support for partial xaml parsing #1298

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}
Loading