diff --git a/packages/reflex-base/news/+direct-string-operations.performance.md b/packages/reflex-base/news/+direct-string-operations.performance.md new file mode 100644 index 00000000000..e83468d07e3 --- /dev/null +++ b/packages/reflex-base/news/+direct-string-operations.performance.md @@ -0,0 +1 @@ +Avoid splitting entire strings into arrays when computing string Var lengths or slices with an omitted or unit step. diff --git a/packages/reflex-base/src/reflex_base/vars/sequence.py b/packages/reflex-base/src/reflex_base/vars/sequence.py index 0b621e25fb8..e1b7df14cf5 100644 --- a/packages/reflex-base/src/reflex_base/vars/sequence.py +++ b/packages/reflex-base/src/reflex_base/vars/sequence.py @@ -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( + 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() @@ -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. @@ -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. diff --git a/tests/benchmarks/test_string_operations.py b/tests/benchmarks/test_string_operations.py new file mode 100644 index 00000000000..56a1b63725f --- /dev/null +++ b/tests/benchmarks/test_string_operations.py @@ -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]())) diff --git a/tests/units/reflex_base/vars/test_sequence.py b/tests/units/reflex_base/vars/test_sequence.py new file mode 100644 index 00000000000..091814be1c4 --- /dev/null +++ b/tests/units/reflex_base/vars/test_sequence.py @@ -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) diff --git a/tests/units/test_var.py b/tests/units/test_var.py index 942d7d78d31..1320b848d7e 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -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(): @@ -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()'