# frozen_string_literal: true module ThemeCheck module LanguageServer module VariableLookupFinder extend self UNCLOSED_SQUARE_BRACKET = /\[[^\]]*\Z/ ENDS_IN_BRACKET_POSITION_THAT_CANT_BE_COMPLETED = %r{ ( # quotes not preceded by a [ (?<!\[)['"]| # closing ] \]| # opening [ \[ )$ }x VARIABLE_LOOKUP_CHARACTERS = /[a-z0-9_.'"\]\[]/i VARIABLE_LOOKUP = /#{VARIABLE_LOOKUP_CHARACTERS}+/o SYMBOLS_PRECEDING_POTENTIAL_LOOKUPS = %r{ (?: \s(?: if|elsif|unless|and|or|#{Liquid::Condition.operators.keys.join("|")} |echo |case|when |cycle |in ) |[:,=] ) \s+ }omix ENDS_WITH_BLANK_POTENTIAL_LOOKUP = /#{SYMBOLS_PRECEDING_POTENTIAL_LOOKUPS}$/oimx ENDS_WITH_POTENTIAL_LOOKUP = /#{SYMBOLS_PRECEDING_POTENTIAL_LOOKUPS}#{VARIABLE_LOOKUP}$/oimx def lookup(content, cursor) return if cursor_is_on_bracket_position_that_cant_be_completed(content, cursor) potential_lookup = lookup_liquid_variable(content, cursor) || lookup_liquid_tag(content, cursor) # And we only return it if it's parsed by Liquid as VariableLookup return unless potential_lookup.is_a?(Liquid::VariableLookup) potential_lookup end private def cursor_is_on_bracket_position_that_cant_be_completed(content, cursor) content[0..cursor - 1] =~ ENDS_IN_BRACKET_POSITION_THAT_CANT_BE_COMPLETED end def cursor_is_on_liquid_variable_lookup_position(content, cursor) previous_char = content[cursor - 1] is_liquid_variable = content =~ Liquid::VariableStart is_in_variable_segment = previous_char =~ VARIABLE_LOOKUP_CHARACTERS is_on_blank_variable_lookup_position = content[0..cursor - 1] =~ /[{:,-]\s+$/ ( is_liquid_variable && ( is_in_variable_segment || is_on_blank_variable_lookup_position ) ) end def lookup_liquid_variable(content, cursor) return unless cursor_is_on_liquid_variable_lookup_position(content, cursor) start_index = content.match(/#{Liquid::VariableStart}-?/o).end(0) + 1 end_index = cursor - 1 # We take the following content # - start after the first two {{ # - end at cursor position # # That way, we'll have a partial liquid variable that # can be parsed such that the "last" variable_lookup # will be the one we're trying to complete. markup = content[start_index..end_index] # Early return for incomplete variables return empty_lookup if markup =~ /\s+$/ # Now we go to hack city... The cursor might be in the middle # of a string/square bracket lookup. We need to close those # otherwise the variable parse won't work. markup += "'" if markup.count("'").odd? markup += '"' if markup.count('"').odd? markup += "]" if markup =~ UNCLOSED_SQUARE_BRACKET variable = variable_from_markup(markup) variable_lookup_for_liquid_variable(variable) end def cursor_is_on_liquid_tag_lookup_position(content, cursor) markup = content[0..cursor - 1] is_liquid_tag = content.match?(Liquid::TagStart) is_in_variable_segment = markup =~ ENDS_WITH_POTENTIAL_LOOKUP is_on_blank_variable_lookup_position = markup =~ ENDS_WITH_BLANK_POTENTIAL_LOOKUP ( is_liquid_tag && ( is_in_variable_segment || is_on_blank_variable_lookup_position ) ) end # Context: # # We know full well that the code as it is being typed is probably not # something that can be parsed by liquid. # # How this works: # # 1. Attempt to turn the code of the token until the cursor position into # valid liquid code with some hacks. # 2. If the code ends in space at a "potential lookup" spot # a. Then return an empty variable lookup # 3. Parse the valid liquid code # 4. Attempt to extract a VariableLookup from Liquid::Template def lookup_liquid_tag(content, cursor) return unless cursor_is_on_liquid_tag_lookup_position(content, cursor) markup = parseable_markup(content, cursor) return empty_lookup if markup == :empty_lookup_markup template = Liquid::Template.parse(markup) current_tag = template.root.nodelist[0] case current_tag.tag_name when "if", "unless" variable_lookup_for_if_tag(current_tag) when "case" variable_lookup_for_case_tag(current_tag) when "cycle" variable_lookup_for_cycle_tag(current_tag) when "for" variable_lookup_for_for_tag(current_tag) when "tablerow" variable_lookup_for_tablerow_tag(current_tag) when "render" variable_lookup_for_render_tag(current_tag) when "assign" variable_lookup_for_assign_tag(current_tag) when "echo" variable_lookup_for_echo_tag(current_tag) end # rubocop:disable Style/RedundantReturn rescue Liquid::SyntaxError # We don't complete variable for liquid syntax errors return end # rubocop:enable Style/RedundantReturn def parseable_markup(content, cursor) start_index = 0 end_index = cursor - 1 markup = content[start_index..end_index] # Welcome to Hackcity markup += "'" if markup.count("'").odd? markup += '"' if markup.count('"').odd? markup += "]" if markup =~ UNCLOSED_SQUARE_BRACKET # Now check if it's a liquid tag is_liquid_tag = markup =~ tag_regex('liquid') ends_with_blank_potential_lookup = markup =~ ENDS_WITH_BLANK_POTENTIAL_LOOKUP last_line = markup.rstrip.lines.last markup = "{% #{last_line}" if is_liquid_tag # Close the tag markup += ' %}' # if statements is_if_tag = markup =~ tag_regex('if') return :empty_lookup_markup if is_if_tag && ends_with_blank_potential_lookup markup += '{% endif %}' if is_if_tag # unless statements is_unless_tag = markup =~ tag_regex('unless') return :empty_lookup_markup if is_unless_tag && ends_with_blank_potential_lookup markup += '{% endunless %}' if is_unless_tag # elsif statements is_elsif_tag = markup =~ tag_regex('elsif') return :empty_lookup_markup if is_elsif_tag && ends_with_blank_potential_lookup markup = '{% if x %}' + markup + '{% endif %}' if is_elsif_tag # case statements is_case_tag = markup =~ tag_regex('case') return :empty_lookup_markup if is_case_tag && ends_with_blank_potential_lookup markup += "{% endcase %}" if is_case_tag # when is_when_tag = markup =~ tag_regex('when') return :empty_lookup_markup if is_when_tag && ends_with_blank_potential_lookup markup = "{% case x %}" + markup + "{% endcase %}" if is_when_tag # for statements is_for_tag = markup =~ tag_regex('for') return :empty_lookup_markup if is_for_tag && ends_with_blank_potential_lookup markup += "{% endfor %}" if is_for_tag # tablerow statements is_tablerow_tag = markup =~ tag_regex('tablerow') return :empty_lookup_markup if is_tablerow_tag && ends_with_blank_potential_lookup markup += "{% endtablerow %}" if is_tablerow_tag markup end def variable_lookup_for_if_tag(if_tag) condition = if_tag.blocks.last variable_lookup_for_condition(condition) end def variable_lookup_for_condition(condition) return variable_lookup_for_condition(condition.child_condition) if condition.child_condition return condition.right if condition.right condition.left end def variable_lookup_for_case_tag(case_tag) return variable_lookup_for_case_block(case_tag.blocks.last) unless case_tag.blocks.empty? case_tag.left end def variable_lookup_for_case_block(condition) condition.right end def variable_lookup_for_cycle_tag(cycle_tag) cycle_tag.variables.last end def variable_lookup_for_for_tag(for_tag) for_tag.collection_name end def variable_lookup_for_tablerow_tag(tablerow_tag) tablerow_tag.collection_name end def variable_lookup_for_render_tag(render_tag) return empty_lookup if render_tag.raw =~ /:\s*$/ render_tag.attributes.values.last end def variable_lookup_for_assign_tag(assign_tag) variable_lookup_for_liquid_variable(assign_tag.from) end def variable_lookup_for_echo_tag(echo_tag) variable_lookup_for_liquid_variable(echo_tag.variable) end def variable_lookup_for_liquid_variable(variable) has_filters = !variable.filters.empty? # Can complete after trailing comma or : if has_filters && variable.raw =~ /[:,]\s*$/ empty_lookup elsif has_filters last_filter_argument(variable.filters) elsif variable.name.nil? empty_lookup else variable.name end end def empty_lookup Liquid::VariableLookup.parse('') end # We want the last thing in variable.filters which is at most # an array that looks like [name, positional_args, hash_arg] def last_filter_argument(filters) filter = filters.last return filter[2].values.last if filter.size == 3 return filter[1].last if filter.size == 2 nil end def variable_from_markup(markup, parse_context = Liquid::ParseContext.new) Liquid::Variable.new(markup, parse_context) end def tag_regex(tag) ShopifyLiquid::Tag.tag_regex(tag) end end end end