diff --git a/src/Uno.Toolkit.RuntimeTests/Extensions/DictionaryExtensions.cs b/src/Uno.Toolkit.RuntimeTests/Extensions/DictionaryExtensions.cs
new file mode 100644
index 000000000..2884da9c5
--- /dev/null
+++ b/src/Uno.Toolkit.RuntimeTests/Extensions/DictionaryExtensions.cs
@@ -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
+{
+ ///
+ /// Combine two dictionaries into a new one.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static IDictionary Combine(
+ this IReadOnlyDictionary dict,
+ IReadOnlyDictionary? other,
+ bool preferOther = true,
+ IEqualityComparer? comparer = null
+ ) where TKey : notnull
+ {
+ var result = new Dictionary(dict, comparer);
+ if (other is { })
+ {
+ foreach (var kvp in other)
+ {
+ if (preferOther || !result.ContainsKey(kvp.Key))
+ {
+ result[kvp.Key] = kvp.Value;
+ }
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/src/Uno.Toolkit.RuntimeTests/Extensions/StackExtensions.cs b/src/Uno.Toolkit.RuntimeTests/Extensions/StackExtensions.cs
new file mode 100644
index 000000000..5dee82da3
--- /dev/null
+++ b/src/Uno.Toolkit.RuntimeTests/Extensions/StackExtensions.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+
+namespace Uno.Toolkit.RuntimeTests.Extensions;
+
+internal static class StackExtensions
+{
+ public static IEnumerable PopWhile(this Stack stack, Func predicate)
+ {
+ while (stack.TryPeek(out var item) && predicate(item))
+ {
+ yield return stack.Pop();
+ }
+ }
+}
diff --git a/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs b/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs
index 8f10d8a3e..384e07f08 100644
--- a/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs
+++ b/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs
@@ -1,9 +1,13 @@
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;
+using static Uno.UI.FeatureConfiguration;
+
#if IS_WINUI
using Microsoft.UI.Xaml.Markup;
@@ -15,6 +19,15 @@ namespace Uno.Toolkit.RuntimeTests.Helpers
{
internal static class XamlHelper
{
+ public static readonly IReadOnlyDictionary KnownXmlnses = new Dictionary
+ {
+ [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",
+ };
+
///
/// Matches right before the > or \> tail of any tag.
///
@@ -28,56 +41,156 @@ internal static class XamlHelper
///
private static readonly Regex NonXmlnsTagRegex = new Regex(@"<\w+[ />]");
- private static readonly IReadOnlyDictionary KnownXmlnses = new Dictionary
- {
- [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",
- };
+ ///
+ /// Matches any open/open-hanging/self-close/close tag.
+ ///
+ /// open-hanging refers to xml tag that opens, but span on multiple lines.
+ private static readonly Regex XmlTagRegex = new Regex("<[^>]+(>|$)");
///
- /// XamlReader.Load the xaml and type-check result.
+ /// Auto complete any unclosed tag.
///
- /// Xaml with single or double quotes
- /// Toggle automatic detection of xmlns required and inject to the xaml
- public static T LoadXaml(string xaml, bool autoInjectXmlns = true) where T : class
+ ///
+ ///
+ internal static string XamlAutoFill(string xaml)
{
- var xmlnses = new Dictionary();
+ var buffer = new StringBuilder();
+
+ // 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)
+ {
+ buffer.AppendLine($"{frame.Indent}{frame.Name}>");
+ }
+ void PopStack(Stack<(string Indent, string Name)> stack)
+ {
+ while (stack.TryPop(out var item))
+ {
+ PopFrame(item);
+ }
+ }
- if (autoInjectXmlns)
+ var lines = string.Concat(xaml.Split('\r')).Split('\n');
+ foreach (var line in lines)
{
- foreach (var xmlns in KnownXmlnses)
+ if (line.TrimStart() is { Length: > 0 } content)
{
- 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)
+ 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 = Regex.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.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(xaml, xmlnses);
+ PopStack(stack);
+ return buffer.ToString();
+ }
+
+ ///
+ /// Inject any required xmlns.
+ ///
+ ///
+ /// Optional; used to override .
+ /// Completary xmlnses that adds to
+ ///
+ internal static string InjectXmlns(string xaml, IDictionary? xmlnses = null, IDictionary? complementaryXmlnses = null)
+ {
+ var xmlnsLookup = (xmlnses?.AsReadOnly() ?? KnownXmlnses).Combine(complementaryXmlnses?.AsReadOnly());
+ var injectables = new Dictionary();
+
+ 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;
+ }
+
+ ///
+ /// Load partial xaml with omittable closing tags.
+ ///
+ ///
+ ///
+ /// Optional; xmlns that may be needed. will be used if null.
+ /// Completary xmlnses that adds to
+ ///
+ public static T LoadPartialXaml(string xaml, IDictionary? xmlnses = null, IDictionary? complementaryXmlnses = null)
+ where T : class
+ {
+ xaml = XamlAutoFill(xaml);
+ xaml = InjectXmlns(xaml, xmlnses, complementaryXmlnses);
+
+ return LoadXaml(xaml);
}
///
/// XamlReader.Load the xaml and type-check result.
///
/// Xaml with single or double quotes
- /// Xmlns to inject; use string.Empty for the default xmlns' key
- public static T LoadXaml(string xaml, Dictionary xmlnses) where T : class
+ /// Toggle automatic detection of xmlns required and inject to the xaml
+ public static T LoadXaml(string xaml, IDictionary? xmlnses = null, IDictionary? complementaryXmlnses = null)
+ where T : class
{
- var injection = " " + string.Join(" ", xmlnses
- .Select(x => $"xmlns{(string.IsNullOrEmpty(x.Key) ? "" : $":{x.Key}")}=\"{x.Value}\"")
- );
-
- xaml = EndOfTagRegex.Replace(xaml, injection.TrimEnd(), 1);
+ xaml = InjectXmlns(xaml, xmlnses, complementaryXmlnses);
+ return LoadXaml(xaml, xmlnses);
+ }
+
+ ///
+ /// XamlReader.Load the xaml and type-check result.
+ ///
+ private static T LoadXaml(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");
diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/XamlHelperTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/XamlHelperTests.cs
new file mode 100644
index 000000000..f08b38db5
--- /dev/null
+++ b/src/Uno.Toolkit.RuntimeTests/Tests/XamlHelperTests.cs
@@ -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("""
+
+
+
+
+
+
+
+
+