Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/milestone-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Sync Release with Milestone
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/on-push-do-doco.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ jobs:
docs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# global.json pins a preview SDK that the runner image does not carry, so every dotnet
# command in the repo directory fails to resolve one. Install the pinned SDK rather than
# relying on what happens to be preinstalled
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: Run MarkdownSnippets
run: |
dotnet tool install --global MarkdownSnippets.Tool
Expand Down
10 changes: 6 additions & 4 deletions src/Argon.JsonPath/ArrayIndexFilter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class ArrayIndexFilter :
class ArrayIndexFilter :
PathFilter
{
public int? Index { get; set; }
Expand All @@ -9,11 +9,13 @@ public override IEnumerable<JToken> ExecuteFilter(JToken root, IEnumerable<JToke
{
if (Index == null)
{
if (t is JArray)
if (t is JArray array)
{
foreach (var v in t)
// index the backing list: foreach over a JToken goes through Children(),
// which boxes an enumerator per input token
for (var i = 0; i < array.Count; i++)
{
yield return v;
yield return array[i];
}
}
else
Expand Down
27 changes: 25 additions & 2 deletions src/Argon.JsonPath/BooleanQueryExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ class BooleanQueryExpression(QueryOperator @operator, object left, object? right
readonly (string Pattern, RegexOptions Options)? regex =
@operator == QueryOperator.RegexEquals ? ParseRegex(right) : null;

// the constructed Regex is cached with the timeout it was built for. evaluating via the
// static Regex.IsMatch costs a process-wide cache probe per candidate token, and silently
// degrades to a full pattern re-parse once more than Regex.CacheSize patterns are in play.
// built lazily so an invalid pattern still surfaces during evaluation rather than at parse
// time, and held as a single reference so a concurrently evaluated cached JPath can never
// observe a Regex paired with the wrong timeout
volatile CachedRegex? cachedRegex;

sealed class CachedRegex(Regex regex, TimeSpan timeout)
{
public readonly Regex Regex = regex;
public readonly TimeSpan Timeout = timeout;
}

static (string Pattern, RegexOptions Options) ParseRegex(object? right)
{
if (right is not JValue {Value: string regexText})
Expand Down Expand Up @@ -194,9 +208,18 @@ bool RegexEquals(JValue input, JsonSelectSettings settings)
return false;
}

var (pattern, options) = regex!.Value;
var timeout = settings.RegexMatchTimeout ?? Regex.InfiniteMatchTimeout;
return Regex.IsMatch((string) input.GetValue(), pattern, options, timeout);

var cached = cachedRegex;
if (cached == null ||
cached.Timeout != timeout)
{
var (pattern, options) = regex!.Value;
cached = new(new(pattern, options, timeout), timeout);
cachedRegex = cached;
}

return cached.Regex.IsMatch((string) input.GetValue());
}

static bool EqualsWithStringCoercion(JValue value, JValue queryValue)
Expand Down
47 changes: 39 additions & 8 deletions src/Argon.JsonPath/JPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -560,32 +560,32 @@ bool TryParseValue(out object? value)

if (char.IsDigit(currentChar) || currentChar == '-')
{
var stringBuilder = new StringBuilder();
stringBuilder.Append(currentChar);
// parse the slice of the expression the number occupies: accumulating it into a
// StringBuilder first allocated the builder and a string for every number in a path
var start = currentIndex;

currentIndex++;
while (currentIndex < expression.Length)
{
currentChar = expression[currentIndex];
if (currentChar is ' ' or ')' or '&' or '|')
{
var numberText = stringBuilder.ToString();
var numberText = expression.AsSpan(start, currentIndex - start);

if (numberText.IndexOfAny(floatCharacters) == -1)
{
var result = long.TryParse(numberText, NumberStyles.Integer, InvariantCulture, out var l);
var result = TryParseInt64(numberText, out var l);
value = l;
return result;
}
else
{
var result = double.TryParse(numberText, NumberStyles.Float | NumberStyles.AllowThousands, InvariantCulture, out var d);
var result = TryParseDouble(numberText, out var d);
value = d;
return result;
}
}

stringBuilder.Append(currentChar);
currentIndex++;
}
}
Expand Down Expand Up @@ -623,16 +623,39 @@ bool TryParseValue(out object? value)
return false;
}

// the span overloads of the number parsers need Polyfill on the old frameworks, and this
// project does not reference it, so those keep paying for a string
#if NET6_0_OR_GREATER
static bool TryParseInt64(CharSpan text, out long value) =>
long.TryParse(text, NumberStyles.Integer, InvariantCulture, out value);

static bool TryParseDouble(CharSpan text, out double value) =>
double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, InvariantCulture, out value);
#else
static bool TryParseInt64(CharSpan text, out long value) =>
long.TryParse(text.ToString(), NumberStyles.Integer, InvariantCulture, out value);

static bool TryParseDouble(CharSpan text, out double value) =>
double.TryParse(text.ToString(), NumberStyles.Float | NumberStyles.AllowThousands, InvariantCulture, out value);
#endif

string ReadQuotedString()
{
var stringBuilder = new StringBuilder();
// the builder is only created once an escape is found: a string with no escapes in it
// is a slice of the expression
StringBuilder? stringBuilder = null;
var start = currentIndex + 1;
var copiedTo = start;

currentIndex++;
while (currentIndex < expression.Length)
{
var currentChar = expression[currentIndex];
if (currentChar == '\\' && currentIndex + 1 < expression.Length)
{
stringBuilder ??= new();
stringBuilder.Append(expression, copiedTo, currentIndex - copiedTo);

currentIndex++;
currentChar = expression[currentIndex];

Expand Down Expand Up @@ -667,16 +690,24 @@ string ReadQuotedString()
stringBuilder.Append(resolvedChar);

currentIndex++;
copiedTo = currentIndex;
}
else if (currentChar == '\'')
{
if (stringBuilder == null)
{
var text = expression.Substring(start, currentIndex - start);
currentIndex++;
return text;
}

stringBuilder.Append(expression, copiedTo, currentIndex - copiedTo);
currentIndex++;
return stringBuilder.ToString();
}
else
{
currentIndex++;
stringBuilder.Append(currentChar);
}
}

Expand Down
14 changes: 12 additions & 2 deletions src/Argon.JsonPath/QueryFilter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class QueryFilter(QueryExpression expression) :
class QueryFilter(QueryExpression expression) :
PathFilter
{
internal QueryExpression Expression = expression;
Expand All @@ -7,12 +7,22 @@ public override IEnumerable<JToken> ExecuteFilter(JToken root, IEnumerable<JToke
{
foreach (var token in current)
{
foreach (var v in token)
if (token is not JContainer container)
{
continue;
}

// walk the children through the sibling links: foreach over a JToken goes through
// Children(), which boxes an enumerator per input token
var v = container.First;
while (v != null)
{
if (Expression.IsMatch(root, v, settings))
{
yield return v;
}

v = v.Next;
}
}
}
Expand Down
20 changes: 3 additions & 17 deletions src/Argon/DefaultJsonNameTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,23 +150,9 @@ void Grow()
Volatile.Write(ref mask, newMask);
}

static bool TextEquals(string str1, char[] str2, int str2Start, int str2Length)
{
if (str1.Length != str2Length)
{
return false;
}

for (var i = 0; i < str1.Length; i++)
{
if (str1[i] != str2[str2Start + i])
{
return false;
}
}

return true;
}
// SequenceEqual vectorizes the comparison and short circuits on a length mismatch
static bool TextEquals(string str1, char[] str2, int str2Start, int str2Length) =>
str1.AsSpan().SequenceEqual(str2.AsSpan(str2Start, str2Length));

class Entry
{
Expand Down
2 changes: 2 additions & 0 deletions src/Argon/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@
global using System.Reflection.Emit;
global using System.Text.RegularExpressions;
global using System.Collections.Frozen;
global using System.Runtime.CompilerServices;
global using System.Runtime.InteropServices;
8 changes: 6 additions & 2 deletions src/Argon/JsonConvert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,12 @@ public static string ToString(bool value) =>
/// <summary>
/// Converts the <see cref="Char" /> to its JSON string representation.
/// </summary>
public static string ToString(char value) =>
ToString(new[]{value}.AsSpan());
public static string ToString(char value)
{
Span<char> chars = stackalloc char[1];
chars[0] = value;
return ToString(chars);
}

/// <summary>
/// Converts the <see cref="Enum" /> to its JSON string representation.
Expand Down
23 changes: 21 additions & 2 deletions src/Argon/JsonPosition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,29 @@ struct JsonPosition(JsonContainerType type)
internal string? PropertyName = null;
internal bool HasIndex = TypeHasIndex(type);

// set instead of PropertyName when the name arrived as a span. the chars are copied into a
// buffer owned by this position rather than materialized into a string, because the name is
// only ever read back to build a path for an error message
internal char[]? NameChars = null;
internal int NameLength = 0;

readonly CharSpan Name
{
get
{
if (PropertyName != null)
{
return PropertyName.AsSpan();
}

return NameChars.AsSpan(0, NameLength);
}
}

int CalculateLength() =>
Type switch
{
JsonContainerType.Object => PropertyName!.Length + 5,
JsonContainerType.Object => Name.Length + 5,
JsonContainerType.Array => MathUtils.IntLength((ulong) Position) + 2,
_ => throw new ArgumentOutOfRangeException(nameof(Type))
};
Expand All @@ -34,7 +53,7 @@ void WriteTo(StringBuilder builder, ref StringWriter? writer, ref char[]? buffer
switch (Type)
{
case JsonContainerType.Object:
var propertyName = PropertyName!.AsSpan();
var propertyName = Name;
if (propertyName.IndexOfAny(specialCharacters) != -1)
{
builder.Append("['");
Expand Down
5 changes: 2 additions & 3 deletions src/Argon/JsonReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,7 @@ bool ReadArrayElementIntoByteArrayReportDone(List<byte> buffer)
return d;
}

if (ConvertUtils.DecimalTryParse(s.ToCharArray(), 0, s.Length, out d) == ParseResult.Success)
if (ConvertUtils.DecimalTryParse(s.AsSpan(), 0, s.Length, out d) == ParseResult.Success)
{
// This is to handle strings like "96.014e-05" that are not supported by traditional decimal.TryParse
SetToken(d);
Expand All @@ -783,8 +783,7 @@ bool ReadArrayElementIntoByteArrayReportDone(List<byte> buffer)
}

// fallback handles strings like "96.014e-05" not supported by decimal.TryParse
var chars = s.ToArray();
if (ConvertUtils.DecimalTryParse(chars, 0, chars.Length, out d) == ParseResult.Success)
if (ConvertUtils.DecimalTryParse(s, 0, s.Length, out d) == ParseResult.Success)
{
SetToken(d);
return d;
Expand Down
38 changes: 36 additions & 2 deletions src/Argon/JsonTextReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ void ParseReadString(char quote, ReadType readType)
data = [];
}
else if (stringReference.Length == 36 &&
ConvertUtils.TryConvertGuid(stringReference.ToString(), out var g))
ConvertUtils.TryConvertGuid(stringReference.AsSpan(), out var g))
{
data = g.ToByteArray();
}
Expand Down Expand Up @@ -137,8 +137,10 @@ void ShiftBufferIfNeeded()
// once in the last 10% of the buffer, or buffer is already very large then
// shift the remaining content to the start to avoid unnecessarily increasing
// the buffer size when reading numbers/strings
// integer math: this runs once per string/number token, and the double conversion and
// multiply it replaces showed up next to the actual copy
var length = charBuffer.Length;
if (length - charPos <= length * 0.1 || length >= LargeBufferLength)
if ((length - charPos) * 10L <= length || length >= LargeBufferLength)
{
var count = charsUsed - charPos;
if (count > 0)
Expand Down Expand Up @@ -963,6 +965,35 @@ bool ReadNullChar()
return false;
}

#if NET8_0_OR_GREATER
// every char a JSON string can contain that ReadStringIntoBuffer has to act on. '\0' is
// included because the buffer is always '\0' terminated at charsUsed, so the terminator
// doubles as the "need more data" sentinel and stops the scan at the end of valid content
static readonly SearchValues<char> stringDelimiters = SearchValues.Create("\0\\\r\n\"'");

// vectorized skip past the run of chars needing no handling, so the per char switch in
// ReadStringIntoBuffer only runs on chars that actually do something. most strings contain
// none of these, so this collapses the whole scan into a single IndexOfAny
int SkipToNextStringDelimiter(int charPos)
{
var remaining = charsUsed - charPos;
if (remaining <= 0)
{
return charPos;
}

var index = charBuffer.AsSpan(charPos, remaining).IndexOfAny(stringDelimiters);
if (index == -1)
{
// no delimiter in the buffered content, so jump to the '\0' terminator at
// charsUsed and let the switch trigger a read for more data
return charsUsed;
}

return charPos + index;
}
#endif

void ReadStringIntoBuffer(char quote)
{
var charPos = this.charPos;
Expand All @@ -972,6 +1003,9 @@ void ReadStringIntoBuffer(char quote)

while (true)
{
#if NET8_0_OR_GREATER
charPos = SkipToNextStringDelimiter(charPos);
#endif
switch (charBuffer[charPos++])
{
case '\0':
Expand Down
Loading