From 96bcb9037cb439d767be56588169537bf65a32a5 Mon Sep 17 00:00:00 2001 From: xiaoy312 Date: Mon, 2 Dec 2024 02:08:22 -0500 Subject: [PATCH] chore: add support for partial xaml parsing --- .../Extensions/DictionaryExtensions.cs | 42 +++++ .../Extensions/StackExtensions.cs | 15 ++ .../Helpers/XamlHelper.cs | 172 +++++++++++++++--- .../Tests/XamlHelperTests.cs | 86 +++++++++ 4 files changed, 286 insertions(+), 29 deletions(-) create mode 100644 src/Uno.Toolkit.RuntimeTests/Extensions/DictionaryExtensions.cs create mode 100644 src/Uno.Toolkit.RuntimeTests/Extensions/StackExtensions.cs create mode 100644 src/Uno.Toolkit.RuntimeTests/Tests/XamlHelperTests.cs 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..890f9a604 100644 --- a/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs +++ b/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs @@ -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; @@ -15,6 +17,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 +39,159 @@ 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(); - 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}"); + } + 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.StartsWith("')[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(new(stack.Reverse())); + 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. + /// + /// Xaml with single or double quotes + /// 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 + /// Optional; xmlns that may be needed. will be used if null. + /// Completary xmlnses that adds to + 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 = InjectXmlns(xaml, xmlnses, complementaryXmlnses); - xaml = EndOfTagRegex.Replace(xaml, injection.TrimEnd(), 1); + 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(""" + + + + + + + + + + + + + + + +