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

Commit d2c6464

Browse files
karreiromgmanzellaPoitrinJulien Poitrin
authored
Introduce Intelligent Code Completion (#672)
* Define the domain for the `theme-liquid-docs` files (#643) * Introduce the `ObjectAttributeCompletionProvider` module (#654) * Download theme-liquid-docs on package deploy or while running theme-check (#661) * Download theme-liquid-docs on package deploy * Add data fixtures to test downloading theme-liquid-docs * Introduce the `AssignmentsCompletionProvider` to suggest variables (#667) * Refresh theme-liquid-docs on theme-check startup (#671) * Add async download in SourceManager#refresh and mechanism to notify SourceIndex liquid schema is out of date * Tests for SourceIndex state classes and SourceManager#refresh, create helper for shared stubs * Cleanup -- fix test dir name to source_index, aggr requires * Remove class method mock (Net::HTTP.any_instance), affects other tests * Change SourceIndex#local_path to #local_path! to indicate danger, fix syntax of filename const * Suggest filters compatible with the object type (#669) ### WHY are these changes introduced? Fixes #658 ### WHAT is this pull request doing? 1. Adds logic to `FilterCompletionProvider` to… - determine the type of the variable/literal (string, number, array, …) sitting before the filter separator ("input type") - suggest filters that match the specific input type, and filters whose input type is _variable_ (i.e. for more than 1 specific type, e.g. ` | default`) 2. Displays deprecated filters too, so that users can still see the filter they wanted to use. Co-authored-by: Morisa Manzella <[email protected]> Co-authored-by: Julien Poitrin <[email protected]> Co-authored-by: Julien Poitrin <[email protected]>
1 parent 5a760c9 commit d2c6464

File tree

79 files changed

+4039
-203
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+4039
-203
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ Gemfile.lock
1414
packaging/builds
1515
.vscode
1616
*.DS_Store
17+
18+
# Theme Liquid docs live at [email protected]:Shopify/theme-liquid-docs.git,
19+
/data/shopify_liquid/documentation/

Rakefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ task :prerelease, [:version] do |_t, args|
5353
ThemeCheck::Releaser.new.release(args.version)
5454
end
5555

56+
desc("Download theme-liquid-docs")
57+
task :download_theme_liquid_docs do
58+
require 'theme_check/shopify_liquid/source_manager'
59+
60+
ThemeCheck::ShopifyLiquid::SourceManager.download
61+
end
62+
5663
desc "Create a new check"
5764
task :new_check, [:name] do |_t, args|
5865
require "theme_check/string_helpers"
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
[
2+
{
3+
"properties": [
4+
{
5+
"name": "first",
6+
"return_type": [
7+
{
8+
"type": "generic"
9+
}
10+
],
11+
"description": "Returns the first item of an array."
12+
},
13+
{
14+
"name": "size",
15+
"description": "Returns the number of items in an array."
16+
},
17+
{
18+
"name": "last",
19+
"return_type": [
20+
{
21+
"type": "generic"
22+
}
23+
],
24+
"description": "Returns the last item of an array."
25+
}
26+
],
27+
"name": "array",
28+
"description": "Arrays hold lists of variables of any type."
29+
},
30+
{
31+
"properties": [
32+
{
33+
"name": "size",
34+
"description": "Returns the number of characters in a string."
35+
}
36+
],
37+
"name": "string",
38+
"description": "Strings are sequences of characters wrapped in single or double quotes."
39+
},
40+
{
41+
"properties": [],
42+
"name": "number",
43+
"description": "Numeric values, including floats and integers."
44+
},
45+
{
46+
"properties": [],
47+
"name": "boolean",
48+
"description": "A binary value, either true or false."
49+
},
50+
{
51+
"properties": [],
52+
"name": "nil",
53+
"description": "An undefined value. Tags or outputs that return nil don't print anything to the page. They are also treated as false."
54+
},
55+
{
56+
"properties": [],
57+
"name": "empty",
58+
"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."
59+
}
60+
]

lib/theme_check/language_server.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# frozen_string_literal: true
2+
23
require_relative "language_server/protocol"
34
require_relative "language_server/constants"
45
require_relative "language_server/configuration"
@@ -7,16 +8,27 @@
78
require_relative "language_server/io_messenger"
89
require_relative "language_server/bridge"
910
require_relative "language_server/uri_helper"
11+
require_relative "language_server/type_helper"
1012
require_relative "language_server/server"
1113
require_relative "language_server/tokens"
14+
require_relative "language_server/variable_lookup_finder/potential_lookup"
15+
require_relative "language_server/variable_lookup_finder/tolerant_parser"
16+
require_relative "language_server/variable_lookup_finder/assignments_finder/node_handler"
17+
require_relative "language_server/variable_lookup_finder/assignments_finder/scope_visitor"
18+
require_relative "language_server/variable_lookup_finder/assignments_finder/scope"
19+
require_relative "language_server/variable_lookup_finder/assignments_finder"
20+
require_relative "language_server/variable_lookup_finder/constants"
21+
require_relative "language_server/variable_lookup_finder/liquid_fixer"
1222
require_relative "language_server/variable_lookup_finder"
23+
require_relative "language_server/variable_lookup_traverser"
1324
require_relative "language_server/diagnostic"
1425
require_relative "language_server/diagnostics_manager"
1526
require_relative "language_server/diagnostics_engine"
1627
require_relative "language_server/document_change_corrector"
1728
require_relative "language_server/versioned_in_memory_storage"
1829
require_relative "language_server/client_capabilities"
1930

31+
require_relative "language_server/completion_context"
2032
require_relative "language_server/completion_helper"
2133
require_relative "language_server/completion_provider"
2234
require_relative "language_server/completion_engine"
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
module ThemeCheck
4+
module LanguageServer
5+
class CompletionContext
6+
include PositionHelper
7+
8+
attr_reader :storage, :relative_path, :line, :col
9+
10+
def initialize(storage, relative_path, line, col)
11+
@storage = storage
12+
@relative_path = relative_path
13+
@line = line
14+
@col = col
15+
end
16+
17+
def buffer
18+
@buffer ||= storage.read(relative_path)
19+
end
20+
21+
def buffer_until_previous_row
22+
@buffer_without_current_row ||= buffer[0..absolute_cursor].lines[0...-1].join
23+
end
24+
25+
def absolute_cursor
26+
@absolute_cursor ||= from_row_column_to_index(buffer, line, col)
27+
end
28+
29+
def cursor
30+
@cursor ||= absolute_cursor - token&.start || 0
31+
end
32+
33+
def content
34+
@content ||= token&.content
35+
end
36+
37+
def token
38+
@token ||= Tokens.new(buffer).find do |t|
39+
# Here we include the next character and exclude the first
40+
# one becase when we want to autocomplete inside a token
41+
# and at most 1 outside it since the cursor could be placed
42+
# at the end of the token.
43+
t.start < absolute_cursor && absolute_cursor <= t.end
44+
end
45+
end
46+
47+
def clone_and_overwrite(col:)
48+
CompletionContext.new(storage, relative_path, line, col)
49+
end
50+
end
51+
end
52+
end

lib/theme_check/language_server/completion_engine.rb

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,29 @@
33
module ThemeCheck
44
module LanguageServer
55
class CompletionEngine
6-
include PositionHelper
7-
8-
def initialize(storage)
6+
def initialize(storage, bridge = nil)
97
@storage = storage
8+
@bridge = bridge
109
@providers = CompletionProvider.all.map { |x| x.new(storage) }
1110
end
1211

1312
def completions(relative_path, line, col)
14-
buffer = @storage.read(relative_path)
15-
cursor = from_row_column_to_index(buffer, line, col)
16-
token = find_token(buffer, cursor)
17-
return [] if token.nil?
13+
context = context(relative_path, line, col)
14+
15+
@providers
16+
.flat_map { |provider| provider.completions(context) }
17+
.uniq { |completion_item| completion_item[:label] }
18+
rescue StandardError => error
19+
@bridge || raise(error)
20+
21+
message = error.message
22+
backtrace = error.backtrace.join("\n")
1823

19-
@providers.flat_map do |p|
20-
p.completions(
21-
token.content,
22-
cursor - token.start
23-
)
24-
end
24+
@bridge.log("[completion error] error: #{message}\n#{backtrace}")
2525
end
2626

27-
def find_token(buffer, cursor)
28-
Tokens.new(buffer).find do |token|
29-
# Here we include the next character and exclude the first
30-
# one becase when we want to autocomplete inside a token
31-
# and at most 1 outside it since the cursor could be placed
32-
# at the end of the token.
33-
token.start < cursor && cursor <= token.end
34-
end
27+
def context(relative_path, line, col)
28+
CompletionContext.new(@storage, relative_path, line, col)
3529
end
3630
end
3731
end

lib/theme_check/language_server/completion_provider.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ class CompletionProvider
66
include CompletionHelper
77
include RegexHelpers
88

9+
attr_reader :storage
10+
11+
CurrentToken = Struct.new(:content, :cursor, :absolute_cursor, :buffer)
12+
913
class << self
1014
def all
1115
@all ||= []
@@ -20,9 +24,20 @@ def initialize(storage = InMemoryStorage.new)
2024
@storage = storage
2125
end
2226

23-
def completions(content, cursor)
27+
def completions(relative_path, line, col)
2428
raise NotImplementedError
2529
end
30+
31+
def doc_hash(content)
32+
return {} if content.nil? || content.empty?
33+
34+
{
35+
documentation: {
36+
kind: MarkupKinds::MARKDOWN,
37+
value: content,
38+
},
39+
}
40+
end
2641
end
2742
end
2843
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
module ThemeCheck
4+
module LanguageServer
5+
class AssignmentsCompletionProvider < CompletionProvider
6+
def completions(context)
7+
content = context.buffer_until_previous_row
8+
9+
return [] if content.nil?
10+
return [] unless (variable_lookup = VariableLookupFinder.lookup(context))
11+
return [] unless variable_lookup.lookups.empty?
12+
return [] if context.content[context.cursor - 1] == "."
13+
14+
finder = VariableLookupFinder::AssignmentsFinder.new(content)
15+
finder.find!
16+
17+
finder.assignments.map do |label, potential_lookup|
18+
object, _property = VariableLookupTraverser.lookup_object_and_property(potential_lookup)
19+
object_to_completion(label, object.name)
20+
end
21+
end
22+
23+
private
24+
25+
def object_to_completion(label, object)
26+
content = ShopifyLiquid::Documentation.object_doc(object)
27+
28+
{
29+
label: label,
30+
kind: CompletionItemKinds::VARIABLE,
31+
**doc_hash(content),
32+
}
33+
end
34+
end
35+
end
36+
end

lib/theme_check/language_server/completion_providers/filter_completion_provider.rb

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@ module ThemeCheck
44
module LanguageServer
55
class FilterCompletionProvider < CompletionProvider
66
NAMED_FILTER = /#{Liquid::FilterSeparator}\s*(\w+)/o
7+
FILTER_SEPARATOR_INCLUDING_SPACES = /\s*#{Liquid::FilterSeparator}/
8+
INPUT_TYPE_VARIABLE = 'variable'
79

8-
def completions(content, cursor)
10+
def completions(context)
11+
content = context.content
12+
cursor = context.cursor
13+
14+
return [] if content.nil?
915
return [] unless can_complete?(content, cursor)
10-
available_labels
11-
.select { |w| w.start_with?(partial(content, cursor)) }
16+
17+
context = context_with_cursor_before_potential_filter_separator(context)
18+
available_filters_for(determine_input_type(context))
19+
.select { |w| w.name.start_with?(partial(content, cursor)) }
1220
.map { |filter| filter_to_completion(filter) }
1321
end
1422

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

2230
private
2331

24-
def available_labels
25-
@labels ||= ShopifyLiquid::Filter.labels - ShopifyLiquid::DeprecatedFilter.labels
32+
def context_with_cursor_before_potential_filter_separator(context)
33+
content = context.content
34+
diff = content.index(FILTER_SEPARATOR_INCLUDING_SPACES) - context.cursor
35+
36+
return context unless content.scan(FILTER_SEPARATOR_INCLUDING_SPACES).size == 1
37+
38+
context.clone_and_overwrite(col: context.col + diff)
39+
end
40+
41+
def determine_input_type(context)
42+
variable_lookup = VariableLookupFinder.lookup(context)
43+
44+
if variable_lookup
45+
object, property = VariableLookupTraverser.lookup_object_and_property(variable_lookup)
46+
return property.return_type if property
47+
return object.name if object
48+
end
49+
end
50+
51+
def available_filters_for(input_type)
52+
filters = ShopifyLiquid::SourceIndex.filters
53+
.select { |filter| input_type.nil? || filter.input_type == input_type }
54+
return all_labels if filters.empty?
55+
return filters if input_type == INPUT_TYPE_VARIABLE
56+
57+
filters + available_filters_for(INPUT_TYPE_VARIABLE)
58+
end
59+
60+
def all_labels
61+
available_filters_for(nil)
2662
end
2763

2864
def cursor_on_filter?(content, cursor)
2965
return false unless content.match?(NAMED_FILTER)
66+
3067
matches(content, NAMED_FILTER).any? do |match|
3168
match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
3269
end
3370
end
3471

3572
def partial(content, cursor)
3673
return '' unless content.match?(NAMED_FILTER)
74+
3775
partial_match = matches(content, NAMED_FILTER).find do |match|
3876
match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
3977
end
4078
return '' if partial_match.nil?
79+
4180
partial_match[1]
4281
end
4382

4483
def filter_to_completion(filter)
84+
content = ShopifyLiquid::Documentation.render_doc(filter)
85+
4586
{
46-
label: filter,
87+
label: filter.name,
4788
kind: CompletionItemKinds::FUNCTION,
89+
tags: filter.deprecated? ? [CompletionItemTag::DEPRECATED] : [],
90+
**doc_hash(content),
4891
}
4992
end
5093
end

0 commit comments

Comments
 (0)