Skip to content
This repository has been archived by the owner on Jul 27, 2024. It is now read-only.

Introduce the ObjectAttributeCompletionProvider module #654

Merged
merged 14 commits into from
Nov 28, 2022
Merged
Show file tree
Hide file tree
Changes from 13 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
60 changes: 60 additions & 0 deletions data/shopify_liquid/built_in_liquid_objects.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
[
{
"properties": [
{
"name": "first",
"return_type": [
{
"type": "generic"
}
],
"description": "Returns the first item of an array."
},
{
"name": "size",
"description": "Returns the number of items in an array."
},
{
"name": "last",
"return_type": [
{
"type": "generic"
}
],
"description": "Returns the last item of an array."
}
],
"name": "array",
"description": "Arrays hold lists of variables of any type."
},
{
"properties": [
{
"name": "size",
"description": "Returns the number of characters in a string."
}
],
"name": "string",
"description": "Strings are sequences of characters wrapped in single or double quotes."
},
{
"properties": [],
"name": "number",
"description": "Numeric values, including floats and integers."
},
{
"properties": [],
"name": "boolean",
"description": "A binary value, either true or false."
},
{
"properties": [],
"name": "nil",
"description": "An undefined value. Tags or outputs that return nil don't print anything to the page. They are also treated as false."
},
{
"properties": [],
"name": "empty",
"description": "An empty object is returned if you try to access an object that is defined, but has no value. For example a page or product that’s been deleted, or a setting with no value. You can compare an object with empty to check whether an object exists before you access any of its attributes."
}
]
6 changes: 6 additions & 0 deletions lib/theme_check/language_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
require_relative "language_server/uri_helper"
require_relative "language_server/server"
require_relative "language_server/tokens"
require_relative "language_server/variable_lookup_finder/potential_lookup"
require_relative "language_server/variable_lookup_finder/tolerant_parser"
require_relative "language_server/variable_lookup_finder/assignments_finder"
require_relative "language_server/variable_lookup_finder/constants"
require_relative "language_server/variable_lookup_finder/liquid_fixer"
require_relative "language_server/variable_lookup_finder"
require_relative "language_server/diagnostic"
require_relative "language_server/diagnostics_manager"
Expand All @@ -17,6 +22,7 @@
require_relative "language_server/versioned_in_memory_storage"
require_relative "language_server/client_capabilities"

require_relative "language_server/completion_context"
require_relative "language_server/completion_helper"
require_relative "language_server/completion_provider"
require_relative "language_server/completion_engine"
Expand Down
44 changes: 44 additions & 0 deletions lib/theme_check/language_server/completion_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module ThemeCheck
module LanguageServer
class CompletionContext
include PositionHelper

attr_reader :storage, :relative_path, :line, :col

def initialize(storage, relative_path, line, col)
@storage = storage
@relative_path = relative_path
@line = line
@col = col
end

def buffer
@buffer ||= storage.read(relative_path)
end

def absolute_cursor
@absolute_cursor ||= from_row_column_to_index(buffer, line, col)
end

def cursor
@cursor ||= absolute_cursor - token&.start || 0
end

def content
@content ||= token&.content
end

def token
@token ||= Tokens.new(buffer).find do |t|
# Here we include the next character and exclude the first
# one becase when we want to autocomplete inside a token
# and at most 1 outside it since the cursor could be placed
# at the end of the token.
t.start < absolute_cursor && absolute_cursor <= t.end
end
end
end
end
end
34 changes: 13 additions & 21 deletions lib/theme_check/language_server/completion_engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,27 @@
module ThemeCheck
module LanguageServer
class CompletionEngine
include PositionHelper

def initialize(storage)
def initialize(storage, bridge = nil)
@storage = storage
@bridge = bridge
@providers = CompletionProvider.all.map { |x| x.new(storage) }
end

def completions(relative_path, line, col)
buffer = @storage.read(relative_path)
cursor = from_row_column_to_index(buffer, line, col)
token = find_token(buffer, cursor)
return [] if token.nil?

@providers.flat_map do |p|
p.completions(
token.content,
cursor - token.start
)
@providers.flat_map do |provider|
provider.completions(context(relative_path, line, col))
end
rescue StandardError => error
@bridge || raise(error)

message = error.message
backtrace = error.backtrace.join("\n")

@bridge.log("[completion error] error: #{message}\n#{backtrace}")
end

def find_token(buffer, cursor)
Tokens.new(buffer).find do |token|
# Here we include the next character and exclude the first
# one becase when we want to autocomplete inside a token
# and at most 1 outside it since the cursor could be placed
# at the end of the token.
token.start < cursor && cursor <= token.end
end
def context(relative_path, line, col)
CompletionContext.new(@storage, relative_path, line, col)
end
end
end
Expand Down
17 changes: 16 additions & 1 deletion lib/theme_check/language_server/completion_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ class CompletionProvider
include CompletionHelper
include RegexHelpers

attr_reader :storage

CurrentToken = Struct.new(:content, :cursor, :absolute_cursor, :buffer)

class << self
def all
@all ||= []
Expand All @@ -20,9 +24,20 @@ def initialize(storage = InMemoryStorage.new)
@storage = storage
end

def completions(content, cursor)
def completions(relative_path, line, col)
raise NotImplementedError
end

def doc_hash(content)
return {} if content.nil? || content.empty?

{
documentation: {
kind: MarkupKinds::MARKDOWN,
value: content,
},
}
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ module LanguageServer
class FilterCompletionProvider < CompletionProvider
NAMED_FILTER = /#{Liquid::FilterSeparator}\s*(\w+)/o

def completions(content, cursor)
def completions(context)
content = context.content
cursor = context.cursor

return [] if content.nil?
return [] unless can_complete?(content, cursor)

available_labels
.select { |w| w.start_with?(partial(content, cursor)) }
.map { |filter| filter_to_completion(filter) }
Expand Down Expand Up @@ -42,9 +47,12 @@ def partial(content, cursor)
end

def filter_to_completion(filter)
content = ShopifyLiquid::Documentation.filter_doc(filter)

{
label: filter,
kind: CompletionItemKinds::FUNCTION,
**doc_hash(content),
}
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# frozen_string_literal: true

module ThemeCheck
module LanguageServer
class ObjectAttributeCompletionProvider < CompletionProvider
def completions(context)
content = context.content
cursor = context.cursor

return [] if content.nil?
return [] unless (variable_lookup = VariableLookupFinder.lookup(context))
return [] if content[cursor - 1] == "." && content[cursor - 2] == "."

# Navigate through lookups until the last valid [object, property] level
object, property = lookup_object_and_property(variable_lookup)

# If the last lookup level is incomplete/invalid, use the partial term
# to filter object properties.
partial = partial_property_name(property, variable_lookup)

return [] unless object

object
.properties
.select { |prop| partial.nil? || prop.name.start_with?(partial) }
.map { |prop| property_doc(prop) }
end

private

def lookup_object_and_property(variable_lookup)
object, generic_type = find_object_and_generic_type(variable_lookup)
property = nil

variable_lookup.lookups.each do |name|
prop = find_property(object, name)

next unless prop

generic_type = generic_type(prop) if generic_type?(prop)

property = prop
property.return_type = generic_type if prop.generic_type?
object = find_object(prop.return_type)
end

[object, property]
end

def find_object_and_generic_type(variable_lookup)
generic_type = nil
object = find_object(variable_lookup.name)

# Objects like 'product' are a complex structure with fields
# and their return type is not present.
#
# However, we also handle objects that have simple built-in types,
# like 'current_tags', which is an 'array'. So, we follow them until
# the source type:
while object&.return_type
generic_type = generic_type(object) if generic_type?(object)
object = find_object(object.return_type)
end

[object, generic_type]
end

def partial_property_name(property, variable_lookup)
last_property = variable_lookup.lookups.last
last_property if last_property != property&.name
end

def property_doc(prop)
content = ShopifyLiquid::Documentation.render_doc(prop)

{
label: prop.name,
kind: CompletionItemKinds::PROPERTY,
**doc_hash(content),
}
end

# Currently, we're handling generic types only for arrays,
# so we get the array type
def generic_type(object)
object.array_type
end

# Currently, we're handling generic types only for arrays,
# so we check if it's an array type
def generic_type?(object)
object.array_type?
end

def find_property(object, property_name)
object
&.properties
&.find { |property| property.name == property_name }
end

def find_object(object_name)
ShopifyLiquid::SourceIndex
.objects
.find { |entry| entry.name == object_name }
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,32 @@
module ThemeCheck
module LanguageServer
class ObjectCompletionProvider < CompletionProvider
def completions(content, cursor)
return [] unless (variable_lookup = variable_lookup_at_cursor(content, cursor))
def completions(context)
content = context.content

return [] if content.nil?
return [] unless (variable_lookup = VariableLookupFinder.lookup(context))
return [] unless variable_lookup.lookups.empty?
return [] if content[cursor - 1] == "."
return [] if content[context.cursor - 1] == "."

ShopifyLiquid::Object.labels
.select { |w| w.start_with?(partial(variable_lookup)) }
.map { |object| object_to_completion(object) }
end

def variable_lookup_at_cursor(content, cursor)
VariableLookupFinder.lookup(content, cursor)
end

def partial(variable_lookup)
variable_lookup.name || ''
end

private

def object_to_completion(object)
content = ShopifyLiquid::Documentation.object_doc(object)

{
label: object,
kind: CompletionItemKinds::VARIABLE,
**doc_hash(content),
}
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
module ThemeCheck
module LanguageServer
class RenderSnippetCompletionProvider < CompletionProvider
def completions(content, cursor)
def completions(context)
content = context.content
cursor = context.cursor

return [] if content.nil?
return [] unless cursor_on_quoted_argument?(content, cursor)
partial = snippet(content) || ''
snippets
Expand Down
Loading