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
46 changes: 46 additions & 0 deletions Jint.Benchmark/StringSliceBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ namespace Jint.Benchmark;
/// previously fell below the zero-copy retention guard and copied on every call.
/// SliceSmall guards the small-result path; SliceThenRead guards lazy-materialization cost
/// when the result is actually consumed.
/// SearchOnSlice exercises indexOf/startsWith/endsWith/includes on a fresh large slice each
/// iteration — the case where the inherited base search methods materialize the whole substring
/// on every call. With zero-copy span search overrides this drops to ~0 allocation.
/// </summary>
[MemoryDiagnoser]
[HideColumns("Error", "Gen0", "Gen1", "Gen2")]
Expand All @@ -21,6 +24,8 @@ public class StringSliceBenchmarks
private Prepared<Script> _substringLargeDiscard;
private Prepared<Script> _sliceSmall;
private Prepared<Script> _sliceThenRead;
private Prepared<Script> _searchOnSlice;
private Prepared<Script> _searchOnFlat;

[GlobalSetup]
public void Setup()
Expand Down Expand Up @@ -71,6 +76,39 @@ public void Setup()
})();
""", strict: true);

_searchOnSlice = Engine.PrepareScript("""
(function() {
var hits = 0;
for (var i = 0; i < 5000; i++) {
var sub = str.slice(12000, -1);
if (sub.indexOf("~absent~") !== -1) hits++;
if (sub.startsWith("aB3$x")) hits++;
if (sub.endsWith("_kEw")) hits++;
if (sub.includes("Q9pLm")) hits++;
}
return hits;
})();
""", strict: true);

// Guard: searching a plain (non-view) JsString must not regress from making the base
// search methods virtual. str.slice(0) returns a flat JsString, not a SlicedString.
// Deliberately dispatch-bound (many cheap short searches that hit early / compare only the
// needle) so the measurement isolates per-call dispatch overhead rather than the highly
// thermal-sensitive throughput of one giant not-found scan.
_searchOnFlat = Engine.PrepareScript("""
(function() {
var hits = 0;
var flat = str.slice(0);
for (var i = 0; i < 100000; i++) {
if (flat.indexOf("aB3$x") === 0) hits++;
if (flat.startsWith("aB3$xQ9")) hits++;
if (flat.endsWith("kEwZ")) hits++;
if (flat.includes("aB3$x")) hits++;
}
return hits;
})();
""", strict: true);

_engine = new Engine(static options => options.Strict());
// Build the shared ~128K char base string once (dromaeo-style doubling).
_engine.Execute("""
Expand All @@ -83,6 +121,8 @@ public void Setup()
_engine.Evaluate(_substringLargeDiscard);
_engine.Evaluate(_sliceSmall);
_engine.Evaluate(_sliceThenRead);
_engine.Evaluate(_searchOnSlice);
_engine.Evaluate(_searchOnFlat);
}

[Benchmark]
Expand All @@ -96,4 +136,10 @@ public void Setup()

[Benchmark]
public JsValue SliceThenRead() => _engine.Evaluate(_sliceThenRead);

[Benchmark]
public JsValue SearchOnSlice() => _engine.Evaluate(_searchOnSlice);

[Benchmark]
public JsValue SearchOnFlat() => _engine.Evaluate(_searchOnFlat);
}
57 changes: 57 additions & 0 deletions Jint.Tests/Runtime/StringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,63 @@ public void ShouldCompareWithLocale()
Assert.Equal(-1, engine.Evaluate("'王五'.localeCompare('张三', 'zh-CN')").AsInteger());
}

[Fact]
public void SlicedStringSearchMatchesFlatString()
{
// A large slice is represented internally as a zero-copy view (SlicedString) whose
// indexOf/includes/startsWith/endsWith search the backing span directly. This differential
// test compares those results against an identical *flat* string (built via a JSON round-trip,
// which routes through the base ToString-backed search) for many needles and positions. The
// sliced value is deliberately searched while still un-materialized, then again after being
// forced to materialize, so both AsSpan() sources are exercised.
const string script = @"
var seed = 'aB3$xQ9pLm0_kEwZ';
var s = seed;
while (s.length < 4096) s += s; // 4096 chars
var sub = s.slice(100, 4000); // length 3900 -> SlicedString (zero-copy view)
var flat = JSON.parse(JSON.stringify(s.slice(100, 4000))); // identical content, plain string

if (sub.length !== flat.length) throw new Error('length mismatch');

var needles = ['aB3$x', 'Q9pLm', 'kEwZ', 'ZaB', '~none~', '', 'm0_', seed, seed + seed, 'Z'];
var positions = [-1, 0, 1, 50, 100, 1000, 3899, 3900, 3901, 5000];

function compareAll() {
for (var n = 0; n < needles.length; n++) {
var ndl = needles[n];
if (sub.indexOf(ndl) !== flat.indexOf(ndl)) return 'indexOf:' + n;
if (sub.includes(ndl) !== flat.includes(ndl)) return 'includes:' + n;
if (sub.startsWith(ndl) !== flat.startsWith(ndl)) return 'startsWith:' + n;
if (sub.endsWith(ndl) !== flat.endsWith(ndl)) return 'endsWith:' + n;
for (var p = 0; p < positions.length; p++) {
var pos = positions[p];
if (sub.indexOf(ndl, pos) !== flat.indexOf(ndl, pos)) return 'indexOf@' + n + ',' + pos;
if (sub.includes(ndl, pos) !== flat.includes(ndl, pos)) return 'includes@' + n + ',' + pos;
if (sub.startsWith(ndl, pos) !== flat.startsWith(ndl, pos)) return 'startsWith@' + n + ',' + pos;
if (sub.endsWith(ndl, pos) !== flat.endsWith(ndl, pos)) return 'endsWith@' + n + ',' + pos;
}
}
return 'ok';
}

var first = compareAll(); // sub is still an un-materialized view here
var forced = sub + 'x'; // forces sub to materialize its backing substring
var second = compareAll(); // now searches the materialized span

// Absolute spot-checks (sub begins at s[100]; 100 % 16 == 4 -> 'x'), so a bug shared by both
// the sliced and flat search paths cannot make the differential pass silently.
var abs = sub.charAt(0) === 'x'
&& sub.startsWith('xQ9pLm0_kEwZ')
&& sub.indexOf('aB3$x') === 12
&& sub.indexOf('x', 1) === 16
&& sub.includes('kEwZaB3$')
&& sub.endsWith(flat.slice(-7));

first + '|' + second + '|' + (abs ? 'ok' : 'absfail');
";
Assert.Equal("ok|ok|ok", _engine.Evaluate(script).AsString());
}

public static TheoryData<string, string> GetLithuaniaTestsData()
{
return new StringTetsLithuaniaData().TestData();
Expand Down
39 changes: 35 additions & 4 deletions Jint/Native/JsString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ internal sealed override bool ToBoolean()

public override string ToString() => _value;

internal bool Contains(char c)
internal virtual bool Contains(char c)
{
if (c == 0)
{
Expand All @@ -298,7 +298,7 @@ internal bool Contains(char c)
return ToString().Contains(c);
}

internal int IndexOf(string value, int startIndex = 0)
internal virtual int IndexOf(string value, int startIndex = 0)
{
if (Length - startIndex < value.Length)
{
Expand All @@ -307,12 +307,12 @@ internal int IndexOf(string value, int startIndex = 0)
return ToString().IndexOf(value, startIndex, StringComparison.Ordinal);
}

internal bool StartsWith(string value, int start = 0)
internal virtual bool StartsWith(string value, int start = 0)
{
return value.Length + start <= Length && ToString().AsSpan(start).StartsWith(value.AsSpan(), StringComparison.Ordinal);
}

internal bool EndsWith(string value, int end = 0)
internal virtual bool EndsWith(string value, int end = 0)
{
var start = end - value.Length;
return start >= 0 && ToString().AsSpan(start, value.Length).EndsWith(value.AsSpan(), StringComparison.Ordinal);
Expand Down Expand Up @@ -528,6 +528,37 @@ public override string ToString()

private ReadOnlySpan<char> AsSpan() => _value is not null ? _value.AsSpan() : _source.AsSpan(_start, _length);

// Search directly over the slice's span. The inherited base implementations route through
// ToString(), which materializes (and allocates) the whole substring on every search; these
// overrides keep a discarded slice zero-copy. Ordinal char comparison is binary/sequence
// equality, so the parameterless span overloads match the base StringComparison.Ordinal paths.
internal override int IndexOf(string value, int startIndex = 0)
{
if (_length - startIndex < value.Length)
{
return -1;
}

var index = AsSpan().Slice(startIndex).IndexOf(value.AsSpan());
return index < 0 ? -1 : index + startIndex;
}

internal override bool StartsWith(string value, int start = 0)
{
return value.Length + start <= _length && AsSpan().Slice(start).StartsWith(value.AsSpan());
}

internal override bool EndsWith(string value, int end = 0)
{
var start = end - value.Length;
return start >= 0 && AsSpan().Slice(start, value.Length).EndsWith(value.AsSpan());
}

internal override bool Contains(char c)
{
return c != 0 && AsSpan().IndexOf(c) >= 0;
}

internal override JsString EnsureCapacity(int capacity)
{
// base implementation reads _value directly, which may not be materialized yet
Expand Down
Loading