diff --git a/src/Avalonia.Base/Media/BoxShadow.cs b/src/Avalonia.Base/Media/BoxShadow.cs index 9ea2a992d7..7da504c14c 100644 --- a/src/Avalonia.Base/Media/BoxShadow.cs +++ b/src/Avalonia.Base/Media/BoxShadow.cs @@ -12,6 +12,8 @@ namespace Avalonia.Media public struct BoxShadow { private readonly static char[] s_Separator = new char[] { ' ', '\t' }; + private const char OpeningParenthesis = '('; + private const char ClosingParenthesis = ')'; /// /// Gets or sets the horizontal offset (distance) of the shadow. @@ -208,7 +210,10 @@ namespace Avalonia.Media throw new FormatException(); } - var p = s.Split(s_Separator, StringSplitOptions.RemoveEmptyEntries); + var p = StringSplitter.SplitRespectingBrackets( + s, s_Separator, + OpeningParenthesis, ClosingParenthesis, + StringSplitOptions.RemoveEmptyEntries); if (p.Length == 1 && p[0] == "none") { return default; diff --git a/src/Avalonia.Base/Media/BoxShadows.cs b/src/Avalonia.Base/Media/BoxShadows.cs index 02e9d4279b..7a926b1191 100644 --- a/src/Avalonia.Base/Media/BoxShadows.cs +++ b/src/Avalonia.Base/Media/BoxShadows.cs @@ -9,7 +9,9 @@ namespace Avalonia.Media /// public struct BoxShadows { - private static readonly char[] s_Separators = new[] { ',' }; + private const char Separator = ','; + private const char OpeningParenthesis = '('; + private const char ClosingParenthesis = ')'; private readonly BoxShadow _first; private readonly BoxShadow[]? _list; @@ -120,7 +122,9 @@ namespace Avalonia.Media /// A new collection. public static BoxShadows Parse(string s) { - var sp = s.Split(s_Separators, StringSplitOptions.RemoveEmptyEntries); + var sp = StringSplitter.SplitRespectingBrackets( + s, Separator, OpeningParenthesis, ClosingParenthesis, + StringSplitOptions.RemoveEmptyEntries); if (sp.Length == 0 || (sp.Length == 1 && (string.IsNullOrWhiteSpace(sp[0]) @@ -236,7 +240,7 @@ namespace Avalonia.Media /// /// true if the two collections are equal; otherwise, false. /// - public static bool operator ==(BoxShadows left, BoxShadows right) => + public static bool operator ==(BoxShadows left, BoxShadows right) => left.Equals(right); /// diff --git a/src/Avalonia.Base/Utilities/StringSplitter.cs b/src/Avalonia.Base/Utilities/StringSplitter.cs new file mode 100644 index 0000000000..95e1049f8f --- /dev/null +++ b/src/Avalonia.Base/Utilities/StringSplitter.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; + +namespace Avalonia.Utilities; + +/// +/// Helpers for splitting strings. +/// +internal static class StringSplitter +{ + private const char DefaultOpeningParenthesis = '('; + private const char DefaultClosingParenthesis = ')'; + + /// + /// Splits the provided string by the specified separators, but ignores separators that + /// appear inside matching bracket pairs ( / ). + /// + /// The input string to split. If null, an empty array is returned. + /// The separator character to split on. + /// The character that opens a bracketed section. ( by default. + /// The character that closes a bracketed section. ) by default. + /// Options for trimming entries and removing empty entries. + /// An array of split segments. Returns an empty array if the input is null or only whitespace. + public static string[] SplitRespectingBrackets(string s, char separator, + char openingBracket = DefaultOpeningParenthesis, char closingBracket = DefaultClosingParenthesis, + StringSplitOptions options = StringSplitOptions.None) => + SplitRespectingBrackets(s, [separator], openingBracket, closingBracket, options); + + /// + /// Splits the provided string by the specified separator, but ignores separators that + /// appear inside matching bracket pairs ( / ). + /// + /// The input string to split. If null, an empty array is returned. + /// The separator characters to split on. + /// The character that opens a bracketed section. ( by default. + /// The character that closes a bracketed section. ) by default. + /// Options for trimming entries and removing empty entries. + /// An array of split segments. Returns an empty array if the input is null or only whitespace. + public static string[] SplitRespectingBrackets(string s, ReadOnlySpan separators, + char openingBracket = DefaultOpeningParenthesis, char closingBracket = DefaultClosingParenthesis, + StringSplitOptions options = StringSplitOptions.None) + { + if (openingBracket == closingBracket) + throw new ArgumentException($"Opening bracket and closing bracket cannot be the same character '{openingBracket}'.", nameof(closingBracket)); + + if (s is null) + return []; + + var span = s.AsSpan(); + + var ranges = new List<(int start, int length)>(); + int depth = 0; + int segStart = 0; + + bool removeEmptyEntries = options.HasFlag(StringSplitOptions.RemoveEmptyEntries); + bool trimEntries = options.HasFlag(StringSplitOptions.TrimEntries); + + for (int i = 0; i < span.Length; i++) + { + char ch = span[i]; + if (ch == openingBracket) + depth++; + else if (ch == closingBracket) + { + if (depth <= 0) + throw new FormatException($"Unmatched closing bracket '{closingBracket}' at position {i}."); + depth--; + } + else if (separators.Contains(ch)) + { + if (depth != 0) + continue; + ProcessSegment(segStart, i - 1); + segStart = i + 1; + } + } + + if (depth != 0) + throw new FormatException($"Unmatched opening bracket '{openingBracket}' in input string."); + // last segment + ProcessSegment(segStart, span.Length - 1); + + if (ranges.Count == 0) + return []; + + var result = new string[ranges.Count]; + for (int i = 0; i < ranges.Count; i++) + { + var r = ranges[i]; +#if NET6_0_OR_GREATER + result[i] = new string(span.Slice(r.start, r.length)); +#else + result[i] = span.Slice(r.start, r.length).ToString(); +#endif + } + + return result; + + void ProcessSegment(int start, int end) + { + if (trimEntries) + { + while (start <= end && char.IsWhiteSpace(s[start])) + start++; + while (end >= start && char.IsWhiteSpace(s[end])) + end--; + } + + int length = end - start + 1; + if (length > 0 || !removeEmptyEntries) + ranges.Add((start, length)); + } + } +} diff --git a/tests/Avalonia.Base.UnitTests/Media/BoxShadowsTests.cs b/tests/Avalonia.Base.UnitTests/Media/BoxShadowsTests.cs new file mode 100644 index 0000000000..8090ab7d50 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Media/BoxShadowsTests.cs @@ -0,0 +1,75 @@ +using Avalonia.Media; +using Xunit; + +namespace Avalonia.Base.UnitTests.Media +{ + public class BoxShadowsTests + { + [Theory] + [InlineData("none")] + [InlineData(" none ")] + public void Parse_None_ReturnsEmpty(string input) + { + var bs = BoxShadows.Parse(input); + Assert.Equal(0, bs.Count); + Assert.Equal(default, bs); + Assert.Equal("none", bs.ToString()); + } + + [Theory] + [InlineData("0 0 5 0 #FF0000")] + [InlineData("10 20 30 5 rgba(0,0,0,0.5)")] + [InlineData("10 20 30 5 rgba(0, 0, 0, 0.5)")] + [InlineData(" 10 20 30 5 rgba(0, 0, 0, 0.5) ")] + public void Parse_SingleShadow_ToString_RoundTrip(string input) + { + var bs = BoxShadows.Parse(input); + Assert.Equal(1, bs.Count); + var str = bs.ToString(); + var reparsed = BoxShadows.Parse(str); + Assert.Equal(bs, reparsed); + } + + [Theory] + [InlineData("0 0 5 0 #FF0000", 10.0)] + [InlineData("0 0 10 0 rgba(0,0,0,0.5)", 20.0)] + public void TransformBounds_IncludesShadowExpansion(string input, double minExpansion) + { + var bs = BoxShadows.Parse(input); + var rect = new Rect(0, 0, 100, 100); + var transformed = bs.TransformBounds(rect); + Assert.True(transformed.Width >= rect.Width + minExpansion); + Assert.True(transformed.Height >= rect.Height + minExpansion); + } + + [Theory] + [InlineData("5 5 10 0 rgba(10,20,30,0.4)")] + [InlineData("5 5 10 0 hsla(10,20%,30%,0.4)")] + [InlineData("5 5 10 0 hsva(10,20%,30%,0.4)")] + public void Parse_ColorFunction_IsHandled(string input) + { + var bs = BoxShadows.Parse(input); + Assert.Equal(1, bs.Count); + var reparsed = BoxShadows.Parse(bs.ToString()); + Assert.Equal(bs, reparsed); + } + + [Theory] + [InlineData("1 2 3 0 #FF0000", 1)] + [InlineData("10 20 30 5 rgba(0,0,0,0.5)", 1)] + [InlineData("1 2 3 0 #FF0000, 1 2 3 0 #FF0000", 2)] + [InlineData("10 20 30 5 rgba(0,0,0,0.5), 1 2 3 0 #FF0000", 2)] + [InlineData("10 20 30 5 rgba(0,0,0,0.5), 10 20 30 5 rgba(0,0,0,0.5)", 2)] + [InlineData("10 20 30 5 rgba(0,0,0,0.5), 10 20 30 5 rgba(0,0,0,0.5), 10 20 30 5 rgba(0,0,0,0.5)", 3)] + [InlineData("10 20 30 5 rgba(0,0,0,0.5), 10 20 30 5 #ffffff, 10 20 30 5 Red", 3)] + [InlineData(" 10 20 30 5 rgba(0, 0, 0, 0.5), 10 20 30 5 rgba(0, 0, 0, 0.5), 10 20 30 5 rgba(0, 0, 0, 0.5) ", 3)] + [InlineData(" 10 20 30 5 rgba(0, 0, 0, 0.5), 10 20 30 5 #ffffff, 10 20 30 5 Red ", 3)] + public void Parse_MultipleShadows(string input, int count) + { + var bs = BoxShadows.Parse(input); + Assert.Equal(count, bs.Count); + var reparsed = BoxShadows.Parse(bs.ToString()); + Assert.Equal(bs, reparsed); + } + } +} diff --git a/tests/Avalonia.Base.UnitTests/Utilities/StringSplitterTests.cs b/tests/Avalonia.Base.UnitTests/Utilities/StringSplitterTests.cs new file mode 100644 index 0000000000..52bbdbff56 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Utilities/StringSplitterTests.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using Avalonia.Utilities; +using Xunit; + +namespace Avalonia.Base.UnitTests.Utilities; + +public class StringSplitterTests +{ + #region Tests without brackets - should match string.Split behavior + + [Fact] + public void SplitRespectingBrackets_WithoutBrackets_NullReturnsEmptyArray() + { + var result = StringSplitter.SplitRespectingBrackets(null, ','); + Assert.Empty(result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n")] + [InlineData("abc")] + [InlineData("a,b,c")] + [InlineData("a,,c")] + [InlineData("a,,,b")] + [InlineData(",a,b,")] + [InlineData(" a , b , c ")] + [InlineData(" a ,,,, c ")] + [InlineData(" a , , c ")] + [InlineData(" a, b ,c ")] + [InlineData(" , a , b , ")] + [InlineData(" a , b , c ")] + [InlineData("First,Second,Third")] + [InlineData("Header\nBody\nFooter\n", '\n')] + [InlineData("Width;Height;Margin;Padding", ';')] + [InlineData("Avalonia.Utilities.StringSplitter", '.')] + public void SplitRespectingBrackets_WithoutBrackets_SingleSeparator(string input, char separator = ',') + { + foreach (var options in EnumerateStringSplitOptionsCombinations()) + { + var result = StringSplitter.SplitRespectingBrackets(input, separator, options: options); + var expected = input.Split(separator, options); + Assert.Equal(expected, result); + } + } + + [Theory] + [InlineData("a,b;c,d")] + [InlineData("a,b;,;c,d")] + [InlineData(" a , b ; c , d ")] + [InlineData(" a , b ; ; c , d ")] + [InlineData(" a , b ;,; c , d ")] + [InlineData(" ; a , b ; c , d ; ")] + public void SplitRespectingBrackets_WithoutBrackets_MultipleSeparators(string input) + { + char[] separators = [',', ';']; + foreach (var options in EnumerateStringSplitOptionsCombinations()) + { + var result = StringSplitter.SplitRespectingBrackets(input, separators, options: options); + var expected = input.Split(separators, options); + Assert.Equal(expected, result); + } + } + + #endregion + + #region Tests with brackets - should respect bracket pairs + + [Theory] + [InlineData("(a)(b,c)", new[] { "(a)(b,c)" })] + [InlineData("a,(),b", new[] { "a", "()", "b" })] + [InlineData("a,(b,c),d", new[] { "a", "(b,c)", "d" })] + [InlineData("a,(b,(c,d)),e", new[] { "a", "(b,(c,d))", "e" })] + [InlineData(",a,(b,c),d,", new[] { "", "a", "(b,c)", "d", "" })] + [InlineData("(a,b),(c,d),(e,f)", new[] { "(a,b)", "(c,d)", "(e,f)" })] + [InlineData("a,(b,(c,(d,e))),f", new[] { "a", "(b,(c,(d,e)))", "f" })] + [InlineData("Button,TextBox(Width=100,Height=50),Label", new[] { "Button", "TextBox(Width=100,Height=50)", "Label" })] + [InlineData("string,List(int),Dictionary(string,object)", new[] { "string", "List(int)", "Dictionary(string,object)" })] + [InlineData("FirstItem,Item(param1,param2,param3),x,VeryLongItemName(a,b),Short", new[] { "FirstItem", "Item(param1,param2,param3)", "x", "VeryLongItemName(a,b)", "Short" })] + [InlineData("BindingPath,Converter(Type=MyConverter,Parameter=Value123),Mode=TwoWay", new[] { "BindingPath", "Converter(Type=MyConverter,Parameter=Value123)", "Mode=TwoWay" })] + [InlineData("Observable(List(Dictionary(string,int))),SimpleType,AnotherObservable(string)", new[] { "Observable(List(Dictionary(string,int)))", "SimpleType", "AnotherObservable(string)" })] + [InlineData("OuterType(InnerType(DeepType(VeryDeepValue1,VeryDeepValue2),InnerValue),OuterValue)", new[] { "OuterType(InnerType(DeepType(VeryDeepValue1,VeryDeepValue2),InnerValue),OuterValue)" })] + [InlineData("0 4 6 -1 #FF000000,0 2 4 -1 rgba(0,0,0,0.06),inset 0 1 2 0 rgba(255,255,255,0.1)", new[] { "0 4 6 -1 #FF000000", "0 2 4 -1 rgba(0,0,0,0.06)", "inset 0 1 2 0 rgba(255,255,255,0.1)" })] + public void SplitRespectingBrackets_WithBrackets_DefaultBrackets(string input, string[] expected) + { + var result = StringSplitter.SplitRespectingBrackets(input, ','); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("a,(b,c;d);e", new[] { "a", "(b,c;d)", "e" })] + [InlineData("Width=100,Height=200;Margin(10;20;30;40),Padding=5", new[] { "Width=100", "Height=200", "Margin(10;20;30;40)", "Padding=5" })] + public void SplitRespectingBrackets_WithBrackets_MultipleSeparators(string input, string[] expected) + { + var result = StringSplitter.SplitRespectingBrackets(input, [',', ';']); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("a,(b,c),d", '[', ']', new[] { "a", "(b", "c)", "d" })] + [InlineData("a,[b,c],d", '[', ']', new[] { "a", "[b,c]", "d" })] + [InlineData("x,,w", '<', '>', new[] { "x", "", "w" })] + [InlineData("Property1,Property2[Index1,Index2],Property3", '[', ']', new[] { "Property1", "Property2[Index1,Index2]", "Property3" })] + public void SplitRespectingBrackets_WithBrackets_CustomBrackets(string input, char openingBracket, char closingBracket, string[] expected) + { + var result = StringSplitter.SplitRespectingBrackets(input, ',', openingBracket, closingBracket); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("a,,(b,c),,d", StringSplitOptions.None, new[] { "a", "", "(b,c)", "", "d" })] + [InlineData("a,,(b,c),,d", StringSplitOptions.RemoveEmptyEntries, new[] { "a", "(b,c)", "d" })] + [InlineData(",a,(b,c),d,", StringSplitOptions.None, new[] { "", "a", "(b,c)", "d", "" })] + [InlineData(",a,(b,c),d,", StringSplitOptions.RemoveEmptyEntries, new[] { "a", "(b,c)", "d" })] + [InlineData(" a , (b, c) , d ", StringSplitOptions.None, new[] { " a ", " (b, c) ", " d " })] + [InlineData(" a , (b, c) , d ", StringSplitOptions.TrimEntries, new[] { "a", "(b, c)", "d" })] + [InlineData(" a , , (b, c) , , d ", StringSplitOptions.None, new[] { " a ", " ", " (b, c) ", " ", " d " })] + [InlineData(" a , , (b, c) , , d ", StringSplitOptions.TrimEntries, new[] { "a", "", "(b, c)", "", "d" })] + [InlineData(" a , , (b, c) , , d ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries, new[] { "a", "(b, c)", "d" })] + [InlineData(" , a , ( b , ( c , d ) ) , , e , ", StringSplitOptions.None, new[] { " ", " a ", " ( b , ( c , d ) ) ", " ", " e ", " " })] + [InlineData(" , a , ( b , ( c , d ) ) , , e , ", StringSplitOptions.TrimEntries, new[] { "", "a", "( b , ( c , d ) )", "", "e", "" })] + [InlineData(" , a , ( b , ( c , d ) ) , , e , ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries, new[] { "a", "( b , ( c , d ) )", "e" })] + public void SplitRespectingBrackets_WithBrackets_WithOptions(string input, StringSplitOptions options, string[] expected) + { + var result = StringSplitter.SplitRespectingBrackets(input, ',', options: options); + Assert.Equal(expected, result); + } + + #endregion + + #region Tests for mismatched brackets - should throw exceptions + + [Theory] + [InlineData("(")] + [InlineData(")")] + [InlineData(")a,b(")] + [InlineData("a,b),c")] + [InlineData("a,(b,c")] + [InlineData("a,b))c")] + [InlineData("a,((b,c)")] + [InlineData("a,(b,(c)),d)")] + [InlineData("x,[y,z", '[', ']')] + [InlineData("x,y],z", '[', ']')] + [InlineData("Type1,Type2(Inner1,Inner2)),Type3")] + [InlineData("Property1,Property2(Parameter1,Parameter2,Property3")] + [InlineData("OuterType(InnerType(DeepType(Value1,Value2),MiddleType(Value3)")] + public void SplitRespectingBrackets_UnmatchedBrackets_ThrowsFormatException(string input, char openingBracket = '(', char closingBracket = ')') + { + Assert.Throws(() => + StringSplitter.SplitRespectingBrackets(input, ',', openingBracket, closingBracket)); + } + + [Theory] + [InlineData('(', '(')] + [InlineData('[', '[')] + [InlineData('.', '.')] + public void SplitRespectingBrackets_SameOpeningAndClosingBracket_ThrowsArgumentException(char bracket1, char bracket2) + { + var input = "a,b,c"; + + Assert.Throws(() => + StringSplitter.SplitRespectingBrackets(input, ',', bracket1, bracket2)); + } + + #endregion + + private static IEnumerable EnumerateStringSplitOptionsCombinations() + { + yield return StringSplitOptions.None; + yield return StringSplitOptions.RemoveEmptyEntries; + yield return StringSplitOptions.TrimEntries; + yield return StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries; + } +}