Improve string parsing in declarative workflows (#7535)
This commit is contained in:
+72
-13
@@ -2,29 +2,40 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.PowerFx.Types;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
|
||||
internal static partial class StringExtensions
|
||||
internal static class StringExtensions
|
||||
{
|
||||
#if NET
|
||||
[GeneratedRegex(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Multiline)]
|
||||
private static partial Regex TrimJsonDelimiterRegex();
|
||||
#else
|
||||
private static Regex TrimJsonDelimiterRegex() => s_trimJsonDelimiterRegex;
|
||||
private static readonly Regex s_trimJsonDelimiterRegex = new(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Compiled | RegexOptions.Multiline);
|
||||
#endif
|
||||
private const string JsonDelimiter = "```";
|
||||
|
||||
public static string TrimJsonDelimiter(this string value)
|
||||
{
|
||||
value = value.Trim();
|
||||
|
||||
Match match = TrimJsonDelimiterRegex().Match(value);
|
||||
return match.Success ?
|
||||
match.Groups[1].Value.Trim() :
|
||||
value;
|
||||
// Scan linearly so malformed fenced input cannot trigger regex backtracking.
|
||||
int openingDelimiterIndex = FindOpeningDelimiter(value);
|
||||
if (openingDelimiterIndex < 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
int contentIndex = openingDelimiterIndex + JsonDelimiter.Length;
|
||||
while (contentIndex < value.Length && IsWordCharacter(value[contentIndex]))
|
||||
{
|
||||
contentIndex++;
|
||||
}
|
||||
|
||||
while (contentIndex < value.Length && char.IsWhiteSpace(value[contentIndex]))
|
||||
{
|
||||
contentIndex++;
|
||||
}
|
||||
|
||||
int closingDelimiterIndex = FindClosingDelimiter(value, contentIndex);
|
||||
return closingDelimiterIndex < 0 ?
|
||||
value :
|
||||
value.Substring(contentIndex, closingDelimiterIndex - contentIndex).Trim();
|
||||
}
|
||||
|
||||
public static FormulaValue ToFormula(this string? value) =>
|
||||
@@ -34,6 +45,54 @@ internal static partial class StringExtensions
|
||||
|
||||
public static string FormatName(this string identifier) => FormatIdentifier(identifier, skipFirst: true);
|
||||
|
||||
private static int FindOpeningDelimiter(string value)
|
||||
{
|
||||
for (int index = 0; index <= value.Length - JsonDelimiter.Length; index++)
|
||||
{
|
||||
if ((index == 0 || value[index - 1] == '\n') && IsDelimiterAt(value, index))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int FindClosingDelimiter(string value, int startIndex)
|
||||
{
|
||||
for (int index = startIndex; index <= value.Length - JsonDelimiter.Length; index++)
|
||||
{
|
||||
if (IsDelimiterAt(value, index) && IsLineEnd(value, index + JsonDelimiter.Length))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool IsDelimiterAt(string value, int index) =>
|
||||
value[index] == '`' &&
|
||||
value[index + 1] == '`' &&
|
||||
value[index + 2] == '`';
|
||||
|
||||
private static bool IsLineEnd(string value, int index) =>
|
||||
index == value.Length ||
|
||||
value[index] == '\n' ||
|
||||
(value[index] == '\r' && index + 1 < value.Length && value[index + 1] == '\n');
|
||||
|
||||
// Keep language qualifier handling compatible with .NET regex \w semantics.
|
||||
private static bool IsWordCharacter(char value) =>
|
||||
char.GetUnicodeCategory(value) is
|
||||
UnicodeCategory.UppercaseLetter or
|
||||
UnicodeCategory.LowercaseLetter or
|
||||
UnicodeCategory.TitlecaseLetter or
|
||||
UnicodeCategory.ModifierLetter or
|
||||
UnicodeCategory.OtherLetter or
|
||||
UnicodeCategory.NonSpacingMark or
|
||||
UnicodeCategory.DecimalDigitNumber or
|
||||
UnicodeCategory.ConnectorPunctuation;
|
||||
|
||||
private static string FormatIdentifier(string identifier, bool skipFirst = false)
|
||||
{
|
||||
string[] words = identifier.Split('_');
|
||||
|
||||
+24
@@ -104,6 +104,30 @@ public sealed class ObjectExtensionsTests
|
||||
VerifyConversion(Json, VariableType.Record(("id", typeof(string)), ("count", typeof(int))), expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertFencedJson()
|
||||
{
|
||||
// Arrange
|
||||
const string Json =
|
||||
"""
|
||||
```json
|
||||
{
|
||||
"id": "item1",
|
||||
"count": 5
|
||||
}
|
||||
```
|
||||
""";
|
||||
Dictionary<string, object?> expected =
|
||||
new()
|
||||
{
|
||||
{ "id", "item1"},
|
||||
{ "count", 5},
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
VerifyConversion(Json, VariableType.Record(("id", typeof(string)), ("count", typeof(int))), expected);
|
||||
}
|
||||
|
||||
private static void VerifyConversion(object? sourceValue, VariableType targetType, object? expectedValue)
|
||||
{
|
||||
object? actualValue = sourceValue.ConvertType(targetType);
|
||||
|
||||
+89
@@ -31,6 +31,7 @@ public sealed class StringExtensionsTests
|
||||
""",
|
||||
result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimJsonWithPadding()
|
||||
{
|
||||
@@ -150,6 +151,94 @@ public sealed class StringExtensionsTests
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimJsonWithSurroundingText()
|
||||
{
|
||||
// Arrange
|
||||
const string Input =
|
||||
"""
|
||||
Here is the result:
|
||||
```json
|
||||
{
|
||||
"key": "value"
|
||||
}
|
||||
```
|
||||
Additional explanation.
|
||||
""";
|
||||
|
||||
// Act
|
||||
string result = Input.TrimJsonDelimiter();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(
|
||||
"""
|
||||
{
|
||||
"key": "value"
|
||||
}
|
||||
""",
|
||||
result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimJsonWithSurroundingTextAndWindowsLineEndings()
|
||||
{
|
||||
// Arrange
|
||||
const string Input = "Here is the result:\r\n```json\r\n{\"key\":\"value\"}\r\n```\r\nAdditional explanation.";
|
||||
|
||||
// Act
|
||||
string result = Input.TrimJsonDelimiter();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("{\"key\":\"value\"}", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimJsonUsesFirstFencedBlock()
|
||||
{
|
||||
// Arrange
|
||||
const string Input =
|
||||
"""
|
||||
```json
|
||||
{"key":"first"}
|
||||
```
|
||||
```json
|
||||
{"key":"second"}
|
||||
```
|
||||
""";
|
||||
|
||||
// Act
|
||||
string result = Input.TrimJsonDelimiter();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("{\"key\":\"first\"}", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimJsonPreservesNonWordCharacterAfterQualifier()
|
||||
{
|
||||
// Arrange
|
||||
const string Input = "```json\u0903{\"key\":\"value\"}```";
|
||||
|
||||
// Act
|
||||
string result = Input.TrimJsonDelimiter();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("\u0903{\"key\":\"value\"}", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimJsonWithUnterminatedDelimiterReturnsTrimmedInput()
|
||||
{
|
||||
// Arrange
|
||||
string input = $" ```json\n{new string(' ', 64)}X ";
|
||||
|
||||
// Act
|
||||
string result = input.TrimJsonDelimiter();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(input.Trim(), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimEmptyString()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user