Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Avoid splitting entire strings into arrays when computing string Var lengths or slices with an omitted or unit step.
38 changes: 37 additions & 1 deletion packages/reflex-base/src/reflex_base/vars/sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,12 @@ def __getitem__(self, i: Any) -> StringVar:
The string slice operation.
"""
if isinstance(i, slice):
if i.step is None or (isinstance(i.step, int) and i.step == 1):
return _string_slice_operation(
Comment on lines +792 to +793

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Literal Unit Steps Missed

A unit step represented as a constant LiteralNumberVar, such as LiteralNumberVar.create(1), is supported by the existing slice machinery but fails the isinstance(i.step, int) check. It therefore still generates the allocation-heavy split("").slice(...).join("") path, leaving the intended constant-unit-step optimization incomplete. Recognize integer literal Vars whose value is 1 and add a regression case alongside the Python int case.

self,
i.start if i.start is not None else Var(_js_expr="undefined"),
i.stop if i.stop is not None else Var(_js_expr="undefined"),
)
return self.split()[i].join()
if not isinstance(i, (int, NumberVar)) or (
isinstance(i, NumberVar) and i._is_strict_float()
Expand All @@ -802,7 +808,7 @@ def length(self) -> NumberVar:
Returns:
The string length operation.
"""
return self.split().length()
return _string_length_operation(self)

def lower(self) -> StringVar:
"""Convert the string to lowercase.
Expand Down Expand Up @@ -1597,6 +1603,36 @@ def create(
)


@var_operation
def _string_length_operation(string: StringVar[Any]):
"""Get a string's length in UTF-16 code units.

Args:
string: The string.

Returns:
The string length.
"""
return var_operation_return(js_expression=f"{string}.length", var_type=int)


@var_operation
def _string_slice_operation(string: StringVar[Any], start: Var | int, stop: Var | int):
"""Slice a string using UTF-16 code-unit boundaries.

Args:
string: The string.
start: The starting index, or an undefined Var.
stop: The ending index, or an undefined Var.

Returns:
The string slice.
"""
return var_operation_return(
js_expression=f"{string}.slice({start}, {stop})", var_type=str
)


@var_operation
def string_split_operation(string: StringVar[Any], sep: StringVar | str = ""):
"""Split a string.
Expand Down
24 changes: 24 additions & 0 deletions tests/benchmarks/test_string_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Benchmarks for generating string Var operations."""

import pytest
from pytest_codspeed import BenchmarkFixture
from reflex_base.vars.base import Var


@pytest.mark.parametrize("operation", ["length", "slice", "unit_slice"])
def test_string_operation_codegen(operation: str, benchmark: BenchmarkFixture) -> None:
"""Measure Python expression generation, excluding JavaScript execution.

Args:
operation: The string operation to generate.
benchmark: The CodSpeed benchmark fixture.
"""
value = Var(_js_expr="state.text").to(str)
start = Var(_js_expr="state.start").to(int)
stop = Var(_js_expr="state.stop").to(int)
operations = {
"length": value.length,
"slice": lambda: value[start:stop],
"unit_slice": lambda: value[start:stop:1],
}
benchmark(lambda: str(operations[operation]()))
81 changes: 81 additions & 0 deletions tests/units/reflex_base/vars/test_sequence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""String operation code generation and JavaScript semantics."""

import json
import shutil
import subprocess

import pytest
from pytest_mock import MockerFixture
from reflex_base.utils.imports import ImportVar
from reflex_base.vars.base import Var, VarData
from reflex_base.vars.sequence import ArraySliceOperation


@pytest.mark.parametrize("step", [None, 1])
def test_string_slice_preserves_metadata(step: int | None) -> None:
"""Direct slices retain source and dynamic-bound imports and hooks."""
source_data = VarData(imports={"source": [ImportVar(tag="text")]})
start_data = VarData(hooks={"const start = useStart()": None})
stop_data = VarData(imports={"bounds": [ImportVar(tag="stop")]})
source = Var(_js_expr="text", _var_data=source_data).to(str)
start = Var(_js_expr="start", _var_data=start_data).to(int)
stop = Var(_js_expr="stop", _var_data=stop_data).to(int)

result = source[start:stop:step]

assert str(result) == "text.slice(start, stop)"
assert result._var_type is str
assert result._get_all_var_data() == VarData.merge(
source_data, start_data, stop_data
)
assert str(source.length()) == "text.length"
assert source.length()._var_type is int
assert source.length()._get_all_var_data() == source_data


@pytest.mark.parametrize("step", [2, -1, -2, 0, Var(_js_expr="step").to(int)])
def test_string_slice_other_steps_keep_array_path(
step: int | Var, mocker: MockerFixture
) -> None:
"""Non-unit and dynamic steps still delegate to the existing array slicer."""
source = Var(_js_expr="text").to(str)
array_slice = mocker.patch.object(
ArraySliceOperation, "create", return_value=Var(_js_expr="sliced").to(list[str])
)
index = slice(1, 8, step)

source[index]

array_slice.assert_called_once()
assert str(array_slice.call_args.args[0]) == 'text.split("")'
assert array_slice.call_args.args[1] is index


def test_string_length_and_slices_preserve_utf16() -> None:
"""Generated operations preserve code-unit semantics in JavaScript."""
node = shutil.which("node")
if node is None:
pytest.skip("Node.js is required to execute generated string operations")
source = Var(_js_expr="text").to(str)
start = Var(_js_expr="start").to(int)
stop = Var(_js_expr="stop").to(int)
expressions = [str(source[start:stop]), str(source[start:stop:1])]
values = ["", "abc", "😎abc\ud800", "a\u0301bc", "\0\n\r"]
script = f"""const strings = {json.dumps(values)};
const slices = {json.dumps(expressions)}.map(
expression => new Function("text", "start", "stop", `return ${{expression}}`)
);
const length = new Function("text", {json.dumps(f"return {source.length()!s}")});
for (const text of strings) {{
if (length(text) !== text.split("").length) throw Error("length mismatch");
for (const start of [undefined, -100, -3, 0, 1, 2, 100]) {{
for (const stop of [undefined, -100, -3, 0, 1, 2, 100]) {{
const expected = text.split("").slice(start, stop).join("");
for (const slice of slices) {{
if (slice(text, start, stop) !== expected) throw Error("slice mismatch");
}}
}}
}}
}}
"""
subprocess.run([node, "-e", script], check=True, capture_output=True, text=True)
18 changes: 9 additions & 9 deletions tests/units/test_var.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,16 +720,16 @@ def test_str_var_slicing():
assert str_var[:1]._var_type is str

# Test basic slicing.
assert str(str_var[:1]) == 'str.split("").slice(undefined, 1).join("")'
assert str(str_var[1:]) == 'str.split("").slice(1, undefined).join("")'
assert str(str_var[:]) == 'str.split("").slice(undefined, undefined).join("")'
assert str(str_var[1:2]) == 'str.split("").slice(1, 2).join("")'
assert str(str_var[:1]) == "str.slice(undefined, 1)"
assert str(str_var[1:]) == "str.slice(1, undefined)"
assert str(str_var[:]) == "str.slice(undefined, undefined)"
assert str(str_var[1:2]) == "str.slice(1, 2)"

# Test negative slicing.
assert str(str_var[:-1]) == 'str.split("").slice(undefined, -1).join("")'
assert str(str_var[-1:]) == 'str.split("").slice(-1, undefined).join("")'
assert str(str_var[:-2]) == 'str.split("").slice(undefined, -2).join("")'
assert str(str_var[-2:]) == 'str.split("").slice(-2, undefined).join("")'
assert str(str_var[:-1]) == "str.slice(undefined, -1)"
assert str(str_var[-1:]) == "str.slice(-1, undefined)"
assert str(str_var[:-2]) == "str.slice(undefined, -2)"
assert str(str_var[-2:]) == "str.slice(-2, undefined)"


def test_dict_indexing():
Expand Down Expand Up @@ -1060,7 +1060,7 @@ def add(a: NumberVar | int, b: NumberVar | int):
def test_string_operations():
basic_string = LiteralStringVar.create("Hello, World!")

assert str(basic_string.length()) == '"Hello, World!".split("").length'
assert str(basic_string.length()) == '"Hello, World!".length'
assert str(basic_string.lower()) == '"Hello, World!".toLowerCase()'
assert str(basic_string.lstrip()) == 'pyLstrip("Hello, World!", null)'
assert str(basic_string.upper()) == '"Hello, World!".toUpperCase()'
Expand Down
Loading