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

Introduce Intelligent Code Completion #672

Merged
merged 6 commits into from
Dec 9, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ Gemfile.lock
packaging/builds
.vscode
*.DS_Store

# Theme Liquid docs live at [email protected]:Shopify/theme-liquid-docs.git,
/data/shopify_liquid/documentation/
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ task :prerelease, [:version] do |_t, args|
ThemeCheck::Releaser.new.release(args.version)
end

desc("Download theme-liquid-docs")
task :download_theme_liquid_docs do
require 'theme_check/shopify_liquid/source_manager'

ThemeCheck::ShopifyLiquid::SourceManager.download
end

desc "Create a new check"
task :new_check, [:name] do |_t, args|
require "theme_check/string_helpers"
Expand Down
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."
}
]
12 changes: 12 additions & 0 deletions lib/theme_check/language_server.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# frozen_string_literal: true

require_relative "language_server/protocol"
require_relative "language_server/constants"
require_relative "language_server/configuration"
Expand All @@ -7,16 +8,27 @@
require_relative "language_server/io_messenger"
require_relative "language_server/bridge"
require_relative "language_server/uri_helper"
require_relative "language_server/type_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/node_handler"
require_relative "language_server/variable_lookup_finder/assignments_finder/scope_visitor"
require_relative "language_server/variable_lookup_finder/assignments_finder/scope"
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/variable_lookup_traverser"
require_relative "language_server/diagnostic"
require_relative "language_server/diagnostics_manager"
require_relative "language_server/diagnostics_engine"
require_relative "language_server/document_change_corrector"
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
52 changes: 52 additions & 0 deletions lib/theme_check/language_server/completion_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 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 buffer_until_previous_row
@buffer_without_current_row ||= buffer[0..absolute_cursor].lines[0...-1].join
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

def clone_and_overwrite(col:)
CompletionContext.new(storage, relative_path, line, col)
end
end
end
end
36 changes: 15 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,29 @@
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?
context = context(relative_path, line, col)

@providers
.flat_map { |provider| provider.completions(context) }
.uniq { |completion_item| completion_item[:label] }
rescue StandardError => error
@bridge || raise(error)

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

@providers.flat_map do |p|
p.completions(
token.content,
cursor - token.start
)
end
@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
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module ThemeCheck
module LanguageServer
class AssignmentsCompletionProvider < CompletionProvider
def completions(context)
content = context.buffer_until_previous_row

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

finder = VariableLookupFinder::AssignmentsFinder.new(content)
finder.find!

finder.assignments.map do |label, potential_lookup|
object, _property = VariableLookupTraverser.lookup_object_and_property(potential_lookup)
object_to_completion(label, object.name)
end
end

private

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

{
label: label,
kind: CompletionItemKinds::VARIABLE,
**doc_hash(content),
}
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ module ThemeCheck
module LanguageServer
class FilterCompletionProvider < CompletionProvider
NAMED_FILTER = /#{Liquid::FilterSeparator}\s*(\w+)/o
FILTER_SEPARATOR_INCLUDING_SPACES = /\s*#{Liquid::FilterSeparator}/
INPUT_TYPE_VARIABLE = 'variable'

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)) }

context = context_with_cursor_before_potential_filter_separator(context)
available_filters_for(determine_input_type(context))
.select { |w| w.name.start_with?(partial(content, cursor)) }
.map { |filter| filter_to_completion(filter) }
end

Expand All @@ -21,30 +29,65 @@ def can_complete?(content, cursor)

private

def available_labels
@labels ||= ShopifyLiquid::Filter.labels - ShopifyLiquid::DeprecatedFilter.labels
def context_with_cursor_before_potential_filter_separator(context)
content = context.content
diff = content.index(FILTER_SEPARATOR_INCLUDING_SPACES) - context.cursor

return context unless content.scan(FILTER_SEPARATOR_INCLUDING_SPACES).size == 1

context.clone_and_overwrite(col: context.col + diff)
end

def determine_input_type(context)
variable_lookup = VariableLookupFinder.lookup(context)

if variable_lookup
object, property = VariableLookupTraverser.lookup_object_and_property(variable_lookup)
return property.return_type if property
return object.name if object
end
end

def available_filters_for(input_type)
filters = ShopifyLiquid::SourceIndex.filters
.select { |filter| input_type.nil? || filter.input_type == input_type }
return all_labels if filters.empty?
return filters if input_type == INPUT_TYPE_VARIABLE

filters + available_filters_for(INPUT_TYPE_VARIABLE)
end

def all_labels
available_filters_for(nil)
end

def cursor_on_filter?(content, cursor)
return false unless content.match?(NAMED_FILTER)

matches(content, NAMED_FILTER).any? do |match|
match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
end
end

def partial(content, cursor)
return '' unless content.match?(NAMED_FILTER)

partial_match = matches(content, NAMED_FILTER).find do |match|
match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
end
return '' if partial_match.nil?

partial_match[1]
end

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

{
label: filter,
label: filter.name,
kind: CompletionItemKinds::FUNCTION,
tags: filter.deprecated? ? [CompletionItemTag::DEPRECATED] : [],
**doc_hash(content),
}
end
end
Expand Down
Loading