Browse Source
* Add support for parsing BoxShadows with colors expressions with parentheses * Added BoxShadowsTests * BoxShadow parsing should also respect parentheses * Add StringSplitter * Fix test * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * StringSplitter should not accept same bracket pairs * Returns empty array only when input is null * Add StringSplitterTests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>pull/20345/head
committed by
GitHub
5 changed files with 377 additions and 4 deletions
@ -0,0 +1,114 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
|
|||
namespace Avalonia.Utilities; |
|||
|
|||
/// <summary>
|
|||
/// Helpers for splitting strings.
|
|||
/// </summary>
|
|||
internal static class StringSplitter |
|||
{ |
|||
private const char DefaultOpeningParenthesis = '('; |
|||
private const char DefaultClosingParenthesis = ')'; |
|||
|
|||
/// <summary>
|
|||
/// Splits the provided string by the specified separators, but ignores separators that
|
|||
/// appear inside matching bracket pairs (<paramref name="openingBracket"/> / <paramref name="closingBracket"/>).
|
|||
/// </summary>
|
|||
/// <param name="s">The input string to split. If <c>null</c>, an empty array is returned.</param>
|
|||
/// <param name="separator">The separator character to split on.</param>
|
|||
/// <param name="openingBracket">The character that opens a bracketed section. <c>(</c> by default.</param>
|
|||
/// <param name="closingBracket">The character that closes a bracketed section. <c>)</c> by default.</param>
|
|||
/// <param name="options">Options for trimming entries and removing empty entries.</param>
|
|||
/// <returns>An array of split segments. Returns an empty array if the input is null or only whitespace.</returns>
|
|||
public static string[] SplitRespectingBrackets(string s, char separator, |
|||
char openingBracket = DefaultOpeningParenthesis, char closingBracket = DefaultClosingParenthesis, |
|||
StringSplitOptions options = StringSplitOptions.None) => |
|||
SplitRespectingBrackets(s, [separator], openingBracket, closingBracket, options); |
|||
|
|||
/// <summary>
|
|||
/// Splits the provided string by the specified separator, but ignores separators that
|
|||
/// appear inside matching bracket pairs (<paramref name="openingBracket"/> / <paramref name="closingBracket"/>).
|
|||
/// </summary>
|
|||
/// <param name="s">The input string to split. If <c>null</c>, an empty array is returned.</param>
|
|||
/// <param name="separators">The separator characters to split on.</param>
|
|||
/// <param name="openingBracket">The character that opens a bracketed section. <c>(</c> by default.</param>
|
|||
/// <param name="closingBracket">The character that closes a bracketed section. <c>)</c> by default.</param>
|
|||
/// <param name="options">Options for trimming entries and removing empty entries.</param>
|
|||
/// <returns>An array of split segments. Returns an empty array if the input is null or only whitespace.</returns>
|
|||
public static string[] SplitRespectingBrackets(string s, ReadOnlySpan<char> 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)); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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,<y,z>,w", '<', '>', new[] { "x", "<y,z>", "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<FormatException>(() => |
|||
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<ArgumentException>(() => |
|||
StringSplitter.SplitRespectingBrackets(input, ',', bracket1, bracket2)); |
|||
} |
|||
|
|||
#endregion
|
|||
|
|||
private static IEnumerable<StringSplitOptions> EnumerateStringSplitOptionsCombinations() |
|||
{ |
|||
yield return StringSplitOptions.None; |
|||
yield return StringSplitOptions.RemoveEmptyEntries; |
|||
yield return StringSplitOptions.TrimEntries; |
|||
yield return StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue