diff --git a/test/unit/fast_path_unit_test.rb b/test/unit/fast_path_unit_test.rb new file mode 100644 index 00000000..5505b40a --- /dev/null +++ b/test/unit/fast_path_unit_test.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Tests for the byte-walking fast paths introduced in the ByteTables optimization. +# Each fast path is tested for equivalence with the original regex-based code path +# it replaces, covering accepted inputs, rejected inputs, and boundary cases. +class FastPathUnitTest < Minitest::Test + # ── Expression.parse_number ──────────────────────────────────────── + # Fast path: byte-walk with ByteTables::DIGIT + # Replaces: INTEGER_REGEX, FLOAT_REGEX, StringScanner loop + + def test_parse_number_simple_integers + assert_equal(42, Liquid::Expression.parse_number("42")) + assert_equal(0, Liquid::Expression.parse_number("0")) + assert_equal(999, Liquid::Expression.parse_number("999")) + end + + def test_parse_number_negative_integers + assert_equal(-7, Liquid::Expression.parse_number("-7")) + assert_equal(-0, Liquid::Expression.parse_number("-0")) + assert_equal(-123, Liquid::Expression.parse_number("-123")) + end + + def test_parse_number_simple_floats + assert_equal(3.14, Liquid::Expression.parse_number("3.14")) + assert_equal(0.5, Liquid::Expression.parse_number("0.5")) + assert_equal(-0.5, Liquid::Expression.parse_number("-0.5")) + assert_equal(100.0, Liquid::Expression.parse_number("100.0")) + end + + def test_parse_number_trailing_dot + # "123." → 123.0 (truncate before dot) + assert_equal(123.0, Liquid::Expression.parse_number("123.")) + assert_equal(0.0, Liquid::Expression.parse_number("0.")) + end + + def test_parse_number_multi_dot_floats + # "1.2.3" → 1.2 (truncate at second dot) + assert_equal(1.2, Liquid::Expression.parse_number("1.2.3")) + assert_equal(1.2, Liquid::Expression.parse_number("1.2.3.4")) + assert_equal(0.0, Liquid::Expression.parse_number("0.0.0")) + end + + def test_parse_number_rejects_non_numeric + assert_nil(Liquid::Expression.parse_number("hello")) + assert_nil(Liquid::Expression.parse_number("")) + assert_nil(Liquid::Expression.parse_number("abc123")) + assert_nil(Liquid::Expression.parse_number(".5")) + assert_nil(Liquid::Expression.parse_number("-.5")) + end + + def test_parse_number_rejects_trailing_alpha_after_multi_dot + # "1.2.3a" must be nil, not 1.2 — these are not valid numbers + assert_nil(Liquid::Expression.parse_number("1.2.3a")) + assert_nil(Liquid::Expression.parse_number("1.2.3.4a")) + assert_nil(Liquid::Expression.parse_number("1.2.34a")) + assert_nil(Liquid::Expression.parse_number("-1.2.3a")) + end + + def test_parse_number_rejects_bare_dash + assert_nil(Liquid::Expression.parse_number("-")) + assert_nil(Liquid::Expression.parse_number("-a")) + end + + # ── Expression.parse strip guard ─────────────────────────────────── + # Fast path: skip String#strip when no leading/trailing whitespace + # Must produce identical results to unconditional .strip + + def test_parse_strips_leading_whitespace + assert_equal(42, Liquid::Expression.parse(" 42")) + assert_equal(42, Liquid::Expression.parse("\t42")) + assert_equal(42, Liquid::Expression.parse("\n42")) + end + + def test_parse_strips_trailing_whitespace + assert_equal(42, Liquid::Expression.parse("42 ")) + assert_equal(42, Liquid::Expression.parse("42\t")) + assert_equal(42, Liquid::Expression.parse("42\n")) + end + + def test_parse_strips_both_sides + assert_equal(42, Liquid::Expression.parse(" 42 ")) + assert_equal("hello", Liquid::Expression.parse(" 'hello' ")) + end + + def test_parse_no_strip_needed + assert_equal(42, Liquid::Expression.parse("42")) + assert_equal("hello", Liquid::Expression.parse("'hello'")) + assert_equal(true, Liquid::Expression.parse("true")) + end + + def test_parse_strips_null_bytes + # String#strip removes \x00 — the WHITESPACE table must match + assert_equal(true, Liquid::Expression.parse("\x00true")) + assert_equal(true, Liquid::Expression.parse("true\x00")) + end + + # ── VariableLookup.simple_lookup? ────────────────────────────────── + # Fast path: regex gate for simple a.b.c chains + # Must accept only inputs the byte-walk can handle correctly + + def test_simple_lookup_accepts_single_names + assert(Liquid::VariableLookup.simple_lookup?("product")) + assert(Liquid::VariableLookup.simple_lookup?("x")) + assert(Liquid::VariableLookup.simple_lookup?("_private")) + end + + def test_simple_lookup_accepts_dotted_chains + assert(Liquid::VariableLookup.simple_lookup?("product.title")) + assert(Liquid::VariableLookup.simple_lookup?("a.b.c.d")) + end + + def test_simple_lookup_accepts_question_marks + assert(Liquid::VariableLookup.simple_lookup?("product.available?")) + assert(Liquid::VariableLookup.simple_lookup?("empty?")) + end + + def test_simple_lookup_accepts_hyphens + assert(Liquid::VariableLookup.simple_lookup?("my-var")) + assert(Liquid::VariableLookup.simple_lookup?("my-var.some-field")) + end + + def test_simple_lookup_rejects_brackets + refute(Liquid::VariableLookup.simple_lookup?("product[0]")) + refute(Liquid::VariableLookup.simple_lookup?("hash['key']")) + refute(Liquid::VariableLookup.simple_lookup?("[0]")) + end + + def test_simple_lookup_rejects_empty_and_malformed + refute(Liquid::VariableLookup.simple_lookup?("")) + refute(Liquid::VariableLookup.simple_lookup?(".leading")) + refute(Liquid::VariableLookup.simple_lookup?("trailing.")) + refute(Liquid::VariableLookup.simple_lookup?("a..b")) + end + + # ── VariableLookup fast path equivalence ─────────────────────────── + # The fast path must produce identical name, lookups, and command_flags + # to the original VariableParser regex path + + def test_fast_path_simple_name + vl = Liquid::VariableLookup.new("product") + assert_equal("product", vl.name) + assert_equal([], vl.lookups) + end + + def test_fast_path_dotted_chain + vl = Liquid::VariableLookup.new("product.title") + assert_equal("product", vl.name) + assert_equal(["title"], vl.lookups) + end + + def test_fast_path_deep_chain + vl = Liquid::VariableLookup.new("a.b.c.d") + assert_equal("a", vl.name) + assert_equal(["b", "c", "d"], vl.lookups) + end + + def test_fast_path_command_methods + vl = Liquid::VariableLookup.new("items.size") + assert_equal("items", vl.name) + assert_equal(["size"], vl.lookups) + assert(vl.lookup_command?(0)) + + vl2 = Liquid::VariableLookup.new("items.first") + assert(vl2.lookup_command?(0)) + + vl3 = Liquid::VariableLookup.new("items.last") + assert(vl3.lookup_command?(0)) + end + + def test_fast_path_non_command_lookups + vl = Liquid::VariableLookup.new("product.title") + refute(vl.lookup_command?(0)) + end + + def test_fast_path_question_mark + vl = Liquid::VariableLookup.new("product.available?") + assert_equal("product", vl.name) + assert_equal(["available?"], vl.lookups) + end + + def test_bracket_lookup_falls_to_regex_path + vl = Liquid::VariableLookup.new("product[0]") + assert_equal("product", vl.name) + assert_equal([0], vl.lookups) + end + + # ── BlockBody.try_parse_tag_token ────────────────────────────────── + # Fast path: byte-walk tag tokens instead of FullToken regex + # Must produce identical [tag_name, markup, newline_count] or nil + + def test_tag_token_simple + body = Liquid::BlockBody.new + result = body.send(:try_parse_tag_token, "{% if x %}") + assert_equal(["if", "x ", 0], result) + end + + def test_tag_token_whitespace_control_leading + body = Liquid::BlockBody.new + result = body.send(:try_parse_tag_token, "{%- if x %}") + assert_equal(["if", "x ", 0], result) + end + + def test_tag_token_whitespace_control_trailing + body = Liquid::BlockBody.new + result = body.send(:try_parse_tag_token, "{% if x -%}") + assert_equal(["if", "x ", 0], result) + end + + def test_tag_token_whitespace_control_both + body = Liquid::BlockBody.new + result = body.send(:try_parse_tag_token, "{%- if x -%}") + assert_equal(["if", "x ", 0], result) + end + + def test_tag_token_no_markup + body = Liquid::BlockBody.new + result = body.send(:try_parse_tag_token, "{% endif %}") + assert_equal(["endif", "", 0], result) + end + + def test_tag_token_hash_comment + body = Liquid::BlockBody.new + result = body.send(:try_parse_tag_token, "{% # this is a comment %}") + assert_equal(["#", "this is a comment ", 0], result) + end + + def test_tag_token_with_newlines + body = Liquid::BlockBody.new + result = body.send(:try_parse_tag_token, "{% \n if \n x %}") + assert_equal(["if", "x ", 2], result) + end + + def test_tag_token_hyphenated_name_stops_at_hyphen + # TagName = /\w+/ does not include hyphens + body = Liquid::BlockBody.new + result = body.send(:try_parse_tag_token, "{% my-tag markup %}") + assert_equal("my", result[0]) + end + + def test_tag_token_complex_markup + body = Liquid::BlockBody.new + result = body.send(:try_parse_tag_token, "{% for item in collection reversed %}") + assert_equal("for", result[0]) + assert_equal("item in collection reversed ", result[1]) + end + + def test_tag_token_malformed_returns_nil + body = Liquid::BlockBody.new + # Token too short + assert_nil(body.send(:try_parse_tag_token, "{%")) + # No valid tag name start (digit) + assert_nil(body.send(:try_parse_tag_token, "{% 123 %}")) + end +end