theme-check 1.10.3 → 1.12.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. data/.github/workflows/cla.yml +22 -0
  5. data/.github/workflows/theme-check.yml +1 -1
  6. data/.gitignore +3 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CONTRIBUTING.md +82 -0
  9. data/README.md +11 -8
  10. data/Rakefile +7 -0
  11. data/TROUBLESHOOTING.md +65 -0
  12. data/config/default.yml +4 -0
  13. data/data/shopify_liquid/built_in_liquid_objects.json +60 -0
  14. data/data/shopify_liquid/documentation/filters.json +5528 -0
  15. data/data/shopify_liquid/documentation/latest.json +1 -0
  16. data/data/shopify_liquid/documentation/objects.json +19272 -0
  17. data/data/shopify_liquid/documentation/tags.json +1252 -0
  18. data/data/shopify_liquid/filters.yml +18 -0
  19. data/dev.yml +1 -1
  20. data/docs/checks/asset_preload.md +60 -0
  21. data/docs/checks/asset_size_javascript.md +2 -2
  22. data/docs/checks/missing_enable_comment.md +3 -3
  23. data/docs/checks/nested_snippet.md +8 -8
  24. data/docs/checks/translation_key_exists.md +4 -4
  25. data/docs/checks/valid_html_translation.md +1 -1
  26. data/lib/theme_check/analyzer.rb +18 -3
  27. data/lib/theme_check/check.rb +6 -1
  28. data/lib/theme_check/checks/asset_preload.rb +20 -0
  29. data/lib/theme_check/checks/deprecated_filter.rb +29 -5
  30. data/lib/theme_check/checks/missing_enable_comment.rb +4 -0
  31. data/lib/theme_check/checks/missing_required_template_files.rb +5 -1
  32. data/lib/theme_check/checks/missing_template.rb +5 -1
  33. data/lib/theme_check/checks/undefined_object.rb +4 -0
  34. data/lib/theme_check/checks/unused_assign.rb +6 -1
  35. data/lib/theme_check/checks/unused_snippet.rb +50 -2
  36. data/lib/theme_check/config.rb +2 -2
  37. data/lib/theme_check/disabled_checks.rb +11 -4
  38. data/lib/theme_check/file_system_storage.rb +2 -0
  39. data/lib/theme_check/in_memory_storage.rb +1 -1
  40. data/lib/theme_check/language_server/bridge.rb +31 -6
  41. data/lib/theme_check/language_server/completion_context.rb +52 -0
  42. data/lib/theme_check/language_server/completion_engine.rb +15 -21
  43. data/lib/theme_check/language_server/completion_provider.rb +16 -1
  44. data/lib/theme_check/language_server/completion_providers/assignments_completion_provider.rb +36 -0
  45. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +49 -6
  46. data/lib/theme_check/language_server/completion_providers/object_attribute_completion_provider.rb +47 -0
  47. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +10 -7
  48. data/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb +5 -1
  49. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +8 -1
  50. data/lib/theme_check/language_server/diagnostics_engine.rb +80 -34
  51. data/lib/theme_check/language_server/diagnostics_manager.rb +27 -6
  52. data/lib/theme_check/language_server/execute_command_providers/run_checks_execute_command_provider.rb +7 -6
  53. data/lib/theme_check/language_server/handler.rb +93 -9
  54. data/lib/theme_check/language_server/protocol.rb +9 -0
  55. data/lib/theme_check/language_server/server.rb +42 -14
  56. data/lib/theme_check/language_server/type_helper.rb +22 -0
  57. data/lib/theme_check/language_server/variable_lookup_finder/assignments_finder/node_handler.rb +63 -0
  58. data/lib/theme_check/language_server/variable_lookup_finder/assignments_finder/scope.rb +57 -0
  59. data/lib/theme_check/language_server/variable_lookup_finder/assignments_finder/scope_visitor.rb +42 -0
  60. data/lib/theme_check/language_server/variable_lookup_finder/assignments_finder.rb +76 -0
  61. data/lib/theme_check/language_server/variable_lookup_finder/constants.rb +43 -0
  62. data/lib/theme_check/language_server/variable_lookup_finder/liquid_fixer.rb +103 -0
  63. data/lib/theme_check/language_server/variable_lookup_finder/potential_lookup.rb +10 -0
  64. data/lib/theme_check/language_server/variable_lookup_finder/tolerant_parser.rb +94 -0
  65. data/lib/theme_check/language_server/variable_lookup_finder.rb +60 -100
  66. data/lib/theme_check/language_server/variable_lookup_traverser.rb +70 -0
  67. data/lib/theme_check/language_server/versioned_in_memory_storage.rb +17 -2
  68. data/lib/theme_check/language_server.rb +12 -0
  69. data/lib/theme_check/liquid_file.rb +22 -1
  70. data/lib/theme_check/liquid_node.rb +33 -1
  71. data/lib/theme_check/liquid_visitor.rb +1 -1
  72. data/lib/theme_check/remote_asset_file.rb +13 -7
  73. data/lib/theme_check/schema_helper.rb +1 -1
  74. data/lib/theme_check/shopify_liquid/documentation/markdown_template.rb +51 -0
  75. data/lib/theme_check/shopify_liquid/documentation.rb +44 -0
  76. data/lib/theme_check/shopify_liquid/filter.rb +4 -0
  77. data/lib/theme_check/shopify_liquid/object.rb +4 -0
  78. data/lib/theme_check/shopify_liquid/source_index/base_entry.rb +60 -0
  79. data/lib/theme_check/shopify_liquid/source_index/base_state.rb +23 -0
  80. data/lib/theme_check/shopify_liquid/source_index/filter_entry.rb +18 -0
  81. data/lib/theme_check/shopify_liquid/source_index/filter_state.rb +11 -0
  82. data/lib/theme_check/shopify_liquid/source_index/object_entry.rb +14 -0
  83. data/lib/theme_check/shopify_liquid/source_index/object_state.rb +11 -0
  84. data/lib/theme_check/shopify_liquid/source_index/parameter_entry.rb +21 -0
  85. data/lib/theme_check/shopify_liquid/source_index/property_entry.rb +9 -0
  86. data/lib/theme_check/shopify_liquid/source_index/return_type_entry.rb +37 -0
  87. data/lib/theme_check/shopify_liquid/source_index/tag_entry.rb +20 -0
  88. data/lib/theme_check/shopify_liquid/source_index/tag_state.rb +11 -0
  89. data/lib/theme_check/shopify_liquid/source_index.rb +56 -0
  90. data/lib/theme_check/shopify_liquid/source_manager.rb +111 -0
  91. data/lib/theme_check/shopify_liquid/tag.rb +4 -0
  92. data/lib/theme_check/shopify_liquid.rb +17 -1
  93. data/lib/theme_check/tags.rb +2 -1
  94. data/lib/theme_check/version.rb +1 -1
  95. data/shipit.rubygems.yml +3 -0
  96. data/theme-check.gemspec +5 -3
  97. metadata +45 -6
  98. data/.github/probots.yml +0 -3
@@ -4,6 +4,11 @@ require "logger"
4
4
  module ThemeCheck
5
5
  module LanguageServer
6
6
  class DiagnosticsManager
7
+ # The empty array is used in the protocol to mean that no
8
+ # diagnostics exist for this file. It's not always evident when
9
+ # reading code.
10
+ NO_DIAGNOSTICS = [].freeze
11
+
7
12
  # This class exists to facilitate LanguageServer diagnostics tracking.
8
13
  #
9
14
  # Motivations:
@@ -49,27 +54,27 @@ module ThemeCheck
49
54
  # When doing single file checks, we keep the whole theme old
50
55
  # ones and accept the new single ones
51
56
  if only_single_file && analyzed_paths.include?(path)
52
- single_file_diagnostics = current_diagnostics[path] || []
53
- whole_theme_diagnostics = whole_theme_diagnostics(path) || []
57
+ single_file_diagnostics = current_diagnostics[path] || NO_DIAGNOSTICS
58
+ whole_theme_diagnostics = whole_theme_diagnostics(path) || NO_DIAGNOSTICS
54
59
  [path, single_file_diagnostics + whole_theme_diagnostics]
55
60
 
56
61
  # If doing single file checks that are not in the
57
62
  # analyzed_paths array then we just keep the old
58
63
  # diagnostics
59
64
  elsif only_single_file
60
- [path, previous_diagnostics(path) || []]
65
+ [path, previous_diagnostics(path) || NO_DIAGNOSTICS]
61
66
 
62
67
  # When doing a full_check, we either send the current
63
68
  # diagnostics or an empty array to clear the diagnostics
64
69
  # for that file.
65
70
  elsif full_check
66
- [path, current_diagnostics[path] || []]
71
+ [path, current_diagnostics[path] || NO_DIAGNOSTICS]
67
72
 
68
73
  # When doing a partial check, the single file diagnostics
69
74
  # from the previous runs should be sent. Otherwise the
70
75
  # latest results are the good ones.
71
76
  else
72
- new_diagnostics = current_diagnostics[path] || []
77
+ new_diagnostics = current_diagnostics[path] || NO_DIAGNOSTICS
73
78
  should_use_cached_results = !analyzed_paths.include?(path)
74
79
  old_diagnostics = should_use_cached_results ? single_file_diagnostics(path) : []
75
80
  [path, new_diagnostics + old_diagnostics]
@@ -113,6 +118,13 @@ module ThemeCheck
113
118
  end.to_h
114
119
  end
115
120
 
121
+ # For when you know there shouldn't be anything on that file
122
+ # anymore. (e.g. file delete or file rename)
123
+ def clear_diagnostics(relative_path)
124
+ relative_path = sanitize_path(relative_path)
125
+ @latest_diagnostics.delete(relative_path)
126
+ end
127
+
116
128
  private
117
129
 
118
130
  def sanitize(diagnostics)
@@ -120,8 +132,17 @@ module ThemeCheck
120
132
  diagnostics
121
133
  end
122
134
 
135
+ def sanitize_path(relative_path)
136
+ case relative_path
137
+ when String
138
+ Pathname.new(relative_path)
139
+ else
140
+ relative_path
141
+ end
142
+ end
143
+
123
144
  def delete(relative_path, diagnostic)
124
- relative_path = Pathname.new(relative_path) if relative_path.is_a?(String)
145
+ relative_path = sanitize_path(relative_path)
125
146
  @mutex.synchronize do
126
147
  @latest_diagnostics[relative_path]&.delete(diagnostic)
127
148
  @latest_diagnostics.delete(relative_path) if @latest_diagnostics[relative_path]&.empty?
@@ -7,17 +7,18 @@ module ThemeCheck
7
7
 
8
8
  command "runChecks"
9
9
 
10
- def initialize(diagnostics_engine, root_path, root_config)
10
+ def initialize(diagnostics_engine, storage, linter_config, language_server_config)
11
11
  @diagnostics_engine = diagnostics_engine
12
- @root_path = root_path
13
- @root_config = root_config
12
+ @storage = storage
13
+ @linter_config = linter_config
14
+ @language_server_config = language_server_config
14
15
  end
15
16
 
16
17
  def execute(_args)
17
18
  @diagnostics_engine.analyze_and_send_offenses(
18
- @root_path,
19
- @root_config,
20
- only_single_file: false,
19
+ @storage.opened_files.map { |relative_path| @storage.path(relative_path) },
20
+ @linter_config,
21
+ only_single_file: @language_server_config.only_single_file?,
21
22
  force: true
22
23
  )
23
24
  nil
@@ -12,6 +12,16 @@ module ThemeCheck
12
12
  version: ThemeCheck::VERSION,
13
13
  }
14
14
 
15
+ # https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#fileOperationFilter
16
+ FILE_OPERATION_FILTER = {
17
+ filters: [{
18
+ scheme: 'file',
19
+ pattern: {
20
+ glob: '**/*',
21
+ },
22
+ }],
23
+ }
24
+
15
25
  CAPABILITIES = {
16
26
  completionProvider: {
17
27
  triggerCharacters: ['.', '{{ ', '{% '],
@@ -33,6 +43,13 @@ module ThemeCheck
33
43
  willSave: false,
34
44
  save: true,
35
45
  },
46
+ workspace: {
47
+ fileOperations: {
48
+ didCreate: FILE_OPERATION_FILTER,
49
+ didDelete: FILE_OPERATION_FILTER,
50
+ willRename: FILE_OPERATION_FILTER,
51
+ },
52
+ },
36
53
  }
37
54
 
38
55
  def initialize(bridge)
@@ -50,12 +67,17 @@ module ThemeCheck
50
67
  @bridge.supports_work_done_progress = @client_capabilities.supports_work_done_progress?
51
68
  @storage = in_memory_storage(@root_path)
52
69
  @diagnostics_manager = DiagnosticsManager.new
53
- @completion_engine = CompletionEngine.new(@storage)
70
+ @completion_engine = CompletionEngine.new(@storage, @bridge)
54
71
  @document_link_engine = DocumentLinkEngine.new(@storage)
55
72
  @diagnostics_engine = DiagnosticsEngine.new(@storage, @bridge, @diagnostics_manager)
56
73
  @execute_command_engine = ExecuteCommandEngine.new
57
74
  @execute_command_engine << CorrectionExecuteCommandProvider.new(@storage, @bridge, @diagnostics_manager)
58
- @execute_command_engine << RunChecksExecuteCommandProvider.new(@diagnostics_engine, @root_path, config_for_path(@root_path))
75
+ @execute_command_engine << RunChecksExecuteCommandProvider.new(
76
+ @diagnostics_engine,
77
+ @storage,
78
+ config_for_path(@root_path),
79
+ @configuration,
80
+ )
59
81
  @code_action_engine = CodeActionEngine.new(@storage, @diagnostics_manager)
60
82
  @bridge.send_response(id, {
61
83
  capabilities: CAPABILITIES,
@@ -65,8 +87,11 @@ module ThemeCheck
65
87
 
66
88
  def on_initialized(_id, _params)
67
89
  return unless @configuration
90
+
68
91
  @configuration.fetch
69
92
  @configuration.register_did_change_capability
93
+
94
+ ShopifyLiquid::SourceManager.download_or_refresh_files
70
95
  end
71
96
 
72
97
  def on_shutdown(id, _params)
@@ -91,9 +116,17 @@ module ThemeCheck
91
116
 
92
117
  def on_text_document_did_close(_id, params)
93
118
  relative_path = relative_path_from_text_document_uri(params)
94
- file_system_content = Pathname.new(text_document_uri(params)).read(mode: 'rb', encoding: 'UTF-8')
95
- # On close, the file system becomes the source of truth
96
- @storage.write(relative_path, file_system_content, nil)
119
+ begin
120
+ file_system_content = Pathname.new(text_document_uri(params)).read(mode: 'rb', encoding: 'UTF-8')
121
+ # On close, the file system becomes the source of truth
122
+ @storage.write(relative_path, file_system_content, nil)
123
+
124
+ # the file no longer exists because either the user deleted it, or the user renamed it.
125
+ rescue Errno::ENOENT
126
+ @storage.remove(relative_path)
127
+ ensure
128
+ @diagnostics_engine.clear_diagnostics(relative_path) if @configuration.only_single_file?
129
+ end
97
130
  end
98
131
 
99
132
  def on_text_document_did_save(_id, params)
@@ -125,6 +158,52 @@ module ThemeCheck
125
158
  ))
126
159
  end
127
160
 
161
+ def on_workspace_did_create_files(_id, params)
162
+ paths = params[:files]
163
+ &.map { |file| file[:uri] }
164
+ &.map { |uri| file_path(uri) }
165
+ return unless paths
166
+
167
+ paths.each do |path|
168
+ relative_path = @storage.relative_path(path)
169
+ file_system_content = Pathname.new(path).read(mode: 'rb', encoding: 'UTF-8')
170
+ @storage.write(relative_path, file_system_content, nil)
171
+ end
172
+ end
173
+
174
+ def on_workspace_did_delete_files(_id, params)
175
+ absolute_paths = params[:files]
176
+ &.map { |file| file[:uri] }
177
+ &.map { |uri| file_path(uri) }
178
+ return unless absolute_paths
179
+
180
+ absolute_paths.each do |path|
181
+ relative_path = @storage.relative_path(path)
182
+ @storage.remove(relative_path)
183
+ end
184
+
185
+ analyze_and_send_offenses(absolute_paths)
186
+ end
187
+
188
+ # We're using workspace/willRenameFiles here because we want this to run
189
+ # before textDocument/didOpen and textDocumetn/didClose of the files
190
+ # (which might trigger another theme analysis).
191
+ def on_workspace_will_rename_files(id, params)
192
+ relative_paths = params[:files]
193
+ &.map { |file| [file[:oldUri], file[:newUri]] }
194
+ &.map { |(old_uri, new_uri)| [relative_path_from_uri(old_uri), relative_path_from_uri(new_uri)] }
195
+ return @bridge.send_response(id, nil) unless relative_paths
196
+
197
+ relative_paths.each do |(old_path, new_path)|
198
+ @storage.write(new_path, @storage.read(old_path), nil)
199
+ @storage.remove(old_path)
200
+ end
201
+ @bridge.send_response(id, nil)
202
+
203
+ absolute_paths = relative_paths.flatten(2).map { |p| @storage.path(p) }
204
+ analyze_and_send_offenses(absolute_paths)
205
+ end
206
+
128
207
  def on_workspace_execute_command(id, params)
129
208
  @bridge.send_response(id, @execute_command_engine.execute(
130
209
  params[:command],
@@ -159,6 +238,10 @@ module ThemeCheck
159
238
  file_path(params.dig(:textDocument, :uri))
160
239
  end
161
240
 
241
+ def relative_path_from_uri(uri)
242
+ @storage.relative_path(file_path(uri))
243
+ end
244
+
162
245
  def relative_path_from_text_document_uri(params)
163
246
  @storage.relative_path(text_document_uri(params))
164
247
  end
@@ -185,15 +268,16 @@ module ThemeCheck
185
268
  params.dig(:contentChanges, 0, :text)
186
269
  end
187
270
 
188
- def config_for_path(path)
271
+ def config_for_path(path_or_paths)
272
+ path = path_or_paths.is_a?(Array) ? path_or_paths[0] : path_or_paths
189
273
  root = ThemeCheck::Config.find(path) || @root_path
190
274
  ThemeCheck::Config.from_path(root)
191
275
  end
192
276
 
193
- def analyze_and_send_offenses(absolute_path, only_single_file: nil)
277
+ def analyze_and_send_offenses(absolute_path_or_paths, only_single_file: nil)
194
278
  @diagnostics_engine.analyze_and_send_offenses(
195
- absolute_path,
196
- config_for_path(absolute_path),
279
+ absolute_path_or_paths,
280
+ config_for_path(absolute_path_or_paths),
197
281
  only_single_file: only_single_file.nil? ? @configuration.only_single_file? : only_single_file
198
282
  )
199
283
  end
@@ -41,5 +41,14 @@ module ThemeCheck
41
41
  module ErrorCodes
42
42
  INTERNAL_ERROR = -32603
43
43
  end
44
+
45
+ module MarkupKinds
46
+ PLAIN_TEXT = 'plaintext'
47
+ MARKDOWN = 'markdown'
48
+ end
49
+
50
+ module CompletionItemTag
51
+ DEPRECATED = 1
52
+ end
44
53
  end
45
54
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'json'
3
4
  require 'stringio'
5
+ require 'timeout'
4
6
 
5
7
  module ThemeCheck
6
8
  module LanguageServer
@@ -45,10 +47,29 @@ module ThemeCheck
45
47
  def listen
46
48
  start_handler_threads
47
49
  start_json_rpc_thread
48
- status_code = status_code_from_error(@error.pop)
50
+ err = @error.pop
51
+ status_code = status_code_from_error(err)
52
+
53
+ if status_code > 0
54
+ # For a reason I can't comprehend, this hangs but prints
55
+ # anyway. So it's wrapped in this ugly timeout...
56
+ Timeout.timeout(1) do
57
+ $stderr.puts err.full_message
58
+ end
59
+
60
+ # Warn user of error, otherwise server might restart
61
+ # without telling you.
62
+ @bridge.send_notification("window/showMessage", {
63
+ type: 1,
64
+ message: "A theme-check-language-server error has occurred, search OUTPUT logs for details.",
65
+ })
66
+ end
67
+
49
68
  cleanup(status_code)
50
69
  rescue SignalException
51
70
  0
71
+ rescue StandardError
72
+ 2
52
73
  end
53
74
 
54
75
  def start_json_rpc_thread
@@ -65,36 +86,43 @@ module ThemeCheck
65
86
  else
66
87
  @queue << message
67
88
  end
68
- rescue Exception => e # rubocop:disable Lint/RescueException
69
- break @error << e
70
89
  end
90
+ rescue Exception => e # rubocop:disable Lint/RescueException
91
+ @bridge.log("rescuing #{e.class} in jsonrpc thread")
92
+ @error << e
71
93
  end
72
94
  end
73
95
 
74
96
  def start_handler_threads
75
97
  @number_of_threads.times do
76
98
  @handlers << Thread.new do
77
- loop do
78
- message = @queue.pop
79
- break if @queue.closed? && @queue.empty?
80
- handle_message(message)
81
- rescue Exception => e # rubocop:disable Lint/RescueException
82
- break @error << e
83
- end
99
+ handle_messages
84
100
  end
85
101
  end
86
102
  end
87
103
 
104
+ def handle_messages
105
+ loop do
106
+ message = @queue.pop
107
+ return if @queue.closed? && @queue.empty?
108
+
109
+ handle_message(message)
110
+ end
111
+ rescue Exception => e # rubocop:disable Lint/RescueException
112
+ @bridge.log("rescuing #{e.class} in handler thread")
113
+ @error << e
114
+ end
115
+
88
116
  def status_code_from_error(e)
89
117
  raise e
90
118
 
91
119
  # support ctrl+c and stuff
92
120
  rescue SignalException, DoneStreaming
93
121
  0
94
-
95
122
  rescue Exception => e # rubocop:disable Lint/RescueException
96
123
  raise e if should_raise_errors
97
- @bridge.log("#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}")
124
+
125
+ @bridge.log("Fatal #{e.class}")
98
126
  2
99
127
  end
100
128
 
@@ -109,15 +137,14 @@ module ThemeCheck
109
137
  if @handler.respond_to?(method_name)
110
138
  @handler.send(method_name, id, params)
111
139
  end
112
-
113
140
  rescue DoneStreaming => e
114
141
  raise e
115
142
  rescue StandardError => e
116
143
  is_request = id
117
144
  raise e unless is_request
145
+
118
146
  # Errors obtained in request handlers should be sent
119
147
  # back as internal errors instead of closing the program.
120
- @bridge.log("#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}")
121
148
  @bridge.send_internal_error(id, e)
122
149
  end
123
150
 
@@ -132,6 +159,7 @@ module ThemeCheck
132
159
  end
133
160
 
134
161
  def cleanup(status_code)
162
+ @bridge.log("Closing server... status code = #{status_code}")
135
163
  # Stop listenting to RPC calls
136
164
  @messenger.close_input
137
165
  # Wait for rpc loop to close
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ module TypeHelper
6
+ def input_type_of(literal)
7
+ case literal
8
+ when String
9
+ 'string'
10
+ when Numeric
11
+ 'number'
12
+ when TrueClass, FalseClass
13
+ 'boolean'
14
+ when NilClass
15
+ 'nil'
16
+ else
17
+ 'untyped'
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ module VariableLookupFinder
6
+ class AssignmentsFinder
7
+ class NodeHandler
8
+ def on_assign(node, scope)
9
+ # When a variable is redefined in a new scope we
10
+ # no longer can guarantee the type in the global scope
11
+ #
12
+ # Example:
13
+ # ```liquid
14
+ # {%- liquid
15
+ # assign var1 = some_value
16
+ #
17
+ # if condition
18
+ # assign var1 = another_value
19
+ # ^^^^ from here we no longer can guarantee
20
+ # the type of `var1` in the global scope
21
+ # -%}
22
+ # ```
23
+ p_scope = scope
24
+ while (p_scope = p_scope.parent)
25
+ p_scope.variables.delete(node.value.to)
26
+ end
27
+
28
+ scope << node
29
+ scope
30
+ end
31
+
32
+ ##
33
+ # Table row tags do not rely on blocks to define scopes,
34
+ # so we index their value here
35
+ def on_table_row(node, scope)
36
+ scope = scope.new_child
37
+
38
+ scope << node
39
+ scope
40
+ end
41
+
42
+ ##
43
+ # Define a new scope every time a new block is created
44
+ def on_block_body(node, scope)
45
+ scope = scope.new_child
46
+
47
+ ##
48
+ # 'for' tags handle blocks flattenly and differently
49
+ # than the other tags (if, unless, case).
50
+ #
51
+ # The scope of 'for' tags exists only in the first
52
+ # block, as the following one refers to the else
53
+ # statement of the iteration.
54
+ parent = node.parent
55
+
56
+ scope << parent if parent.type_name == :for && parent.children.first == node
57
+ scope
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ module VariableLookupFinder
6
+ class AssignmentsFinder
7
+ class Scope < Struct.new(:variables, :parent)
8
+ include TypeHelper
9
+
10
+ def new_child
11
+ child_scope = dup
12
+ child_scope.variables = variables.dup
13
+ child_scope.parent = self
14
+ child_scope
15
+ end
16
+
17
+ def <<(node)
18
+ tag = node.value
19
+
20
+ case tag
21
+ when Liquid::Assign
22
+ variable_name = tag.to
23
+ variables[variable_name] = assign_tag_as_potential_lookup(tag)
24
+ when Liquid::For, Liquid::TableRow
25
+ variable_name = tag.variable_name
26
+ variables[variable_name] = iteration_tag_as_potential_lookup(tag)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def assign_tag_as_potential_lookup(tag)
33
+ variable_lookup = tag.from.name
34
+
35
+ unless variable_lookup.is_a?(Liquid::VariableLookup)
36
+ return PotentialLookup.new(input_type_of(variable_lookup), [], variables)
37
+ end
38
+
39
+ name = variable_lookup.name
40
+ lookups = variable_lookup.lookups
41
+
42
+ PotentialLookup.new(name, lookups, variables)
43
+ end
44
+
45
+ def iteration_tag_as_potential_lookup(tag)
46
+ variable_lookup = tag.collection_name
47
+
48
+ name = variable_lookup.name
49
+ lookups = [*variable_lookup.lookups, 'first']
50
+
51
+ PotentialLookup.new(name, lookups, variables)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ module VariableLookupFinder
6
+ class AssignmentsFinder
7
+ class ScopeVisitor
8
+ attr_reader :global_scope, :current_scope
9
+
10
+ def initialize
11
+ @node_handler = NodeHandler.new
12
+ @global_scope = Scope.new({})
13
+ @current_scope = Scope.new({})
14
+ end
15
+
16
+ def visit_template(template)
17
+ return unless template
18
+
19
+ visit(liquid_node(template), global_scope)
20
+ end
21
+
22
+ private
23
+
24
+ def visit(node, scope)
25
+ return if node.type_name == :variable_lookup
26
+
27
+ method = :"on_#{node.type_name}"
28
+ scope = @node_handler.send(method, node, scope) if @node_handler.respond_to?(method)
29
+
30
+ @current_scope = scope
31
+
32
+ node.children.each { |child| visit(child, scope) }
33
+ end
34
+
35
+ def liquid_node(template)
36
+ LiquidNode.new(template.root, nil, template)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ module VariableLookupFinder
6
+ class AssignmentsFinder
7
+ include RegexHelpers
8
+
9
+ attr_reader :content, :scope_visitor
10
+
11
+ def initialize(content)
12
+ @content = close_tag(content)
13
+ @scope_visitor = ScopeVisitor.new
14
+ end
15
+
16
+ def find!
17
+ template = parse(content)
18
+
19
+ if template
20
+ visit_template(template)
21
+ return
22
+ end
23
+
24
+ liquid_tags.each do |tag|
25
+ visit_template(last_line_parse(tag))
26
+ end
27
+ end
28
+
29
+ def assignments
30
+ current_scope = scope_visitor.current_scope
31
+ current_scope.variables
32
+ end
33
+
34
+ private
35
+
36
+ def visit_template(template)
37
+ scope_visitor.visit_template(template)
38
+ end
39
+
40
+ def liquid_tags
41
+ matches(content, LIQUID_TAG_OR_VARIABLE)
42
+ .flat_map { |match| match[0] }
43
+ end
44
+
45
+ def parse(content)
46
+ regular_parse(content) || tolerant_parse(content)
47
+ end
48
+
49
+ def regular_parse(content)
50
+ Liquid::Template.parse(content)
51
+ rescue Liquid::SyntaxError
52
+ # Ignore syntax errors at the regular parse phase
53
+ end
54
+
55
+ def tolerant_parse(content)
56
+ TolerantParser::Template.parse(content)
57
+ rescue StandardError
58
+ # Ignore any error at the tolerant parse phase
59
+ end
60
+
61
+ def last_line_parse(content)
62
+ parsable_content = LiquidFixer.new(content).parsable
63
+
64
+ regular_parse(parsable_content)
65
+ end
66
+
67
+ def close_tag(content)
68
+ lines = content.lines
69
+ end_tag = lines.last =~ VARIABLE_START ? ' }}' : ' %}'
70
+
71
+ content + end_tag
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end