Improve string parsing in declarative workflows (#7535)

This commit is contained in:
Peter Ibekwe
2026-08-07 11:07:56 -07:00
committed by GitHub
parent c987529df3
commit 5eb3eb745e
3 changed files with 185 additions and 13 deletions
@@ -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('_');
@@ -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);
@@ -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()
{