From fd853a3593f2b8f7c7d03de582aba1aa2c5e546b Mon Sep 17 00:00:00 2001 From: Chris Pak Date: Sun, 5 Apr 2026 20:53:53 -0700 Subject: [PATCH] VariableLookup: fast path for simple identifier chains Skip the expensive recursive VariableParser regex for simple lookups like 'product.title' (~90% of real-world cases). SIMPLE_LOOKUP_RE validates the input is a plain a.b.c chain (no brackets, no quotes). On match, byte-walks on dots to split segments instead of invoking the regex engine. Falls through to the original VariableParser scan for complex inputs. --- lib/liquid/variable_lookup.rb | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb index 4fba2a65..56054c28 100644 --- a/lib/liquid/variable_lookup.rb +++ b/lib/liquid/variable_lookup.rb @@ -4,6 +4,25 @@ module Liquid class VariableLookup COMMAND_METHODS = ['size', 'first', 'last'].freeze + # Matches simple identifier chains: name(.name)* with no brackets/quotes + SIMPLE_LOOKUP_RE = /\A[\w-]+\??(?:\.[\w-]+\??)*\z/ + + # Returns true when markup is a simple dotted identifier chain that the + # fast path in initialize can handle. Accepts: + # - Single names: "product", "item" + # - Dotted chains: "product.title", "cart.items.first" + # - Question-mark suffixes: "product.available?" + # - Hyphens in names: "my-var.some-field" + # Rejects (falls through to VariableParser regex): + # - Bracket lookups: "product[0]", "hash['key']" + # - Quoted strings, empty input, leading/trailing dots + # Fallback: when this returns false, initialize uses the original + # markup.scan(VariableParser) path — behavior is identical to + # the pre-optimization code for any input the fast path rejects. + def self.simple_lookup?(markup) + markup.bytesize > 0 && markup.match?(SIMPLE_LOOKUP_RE) + end + attr_reader :name, :lookups def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil) @@ -11,6 +30,31 @@ module Liquid end def initialize(markup, string_scanner = StringScanner.new(""), cache = nil) + if self.class.simple_lookup?(markup) + dot_pos = markup.index('.') + if dot_pos.nil? + @name = markup + @lookups = Const::EMPTY_ARRAY + @command_flags = 0 + return + end + + @name = markup.byteslice(0, dot_pos) + @lookups = [] + @command_flags = 0 + pos = dot_pos + 1 + len = markup.bytesize + while pos < len + seg_start = pos + pos += 1 while pos < len && markup.getbyte(pos) != ByteTables::DOT + seg = markup.byteslice(seg_start, pos - seg_start) + @command_flags |= 1 << @lookups.length if COMMAND_METHODS.include?(seg) + @lookups << seg + pos += 1 # skip dot + end + return + end + lookups = markup.scan(VariableParser) name = lookups.shift