theme-check 1.7.0 → 1.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +49 -0
  4. data/README.md +10 -0
  5. data/RELEASING.md +13 -0
  6. data/config/default.yml +5 -0
  7. data/data/shopify_liquid/deprecated_filters.yml +4 -0
  8. data/data/shopify_liquid/filters.yml +3 -1
  9. data/data/shopify_liquid/tags.yml +9 -9
  10. data/docs/checks/TEMPLATE.md.erb +24 -19
  11. data/docs/checks/schema_json_format.md +76 -0
  12. data/docs/language_server/code-action-command-palette.png +0 -0
  13. data/docs/language_server/code-action-flow.png +0 -0
  14. data/docs/language_server/code-action-keyboard.png +0 -0
  15. data/docs/language_server/code-action-light-bulb.png +0 -0
  16. data/docs/language_server/code-action-problem.png +0 -0
  17. data/docs/language_server/code-action-quickfix.png +0 -0
  18. data/docs/language_server/how_to_correct_code_with_code_actions_and_execute_command.md +197 -0
  19. data/exe/theme-check-language-server +0 -4
  20. data/lib/theme_check/checks/asset_size_app_block_css.rb +2 -3
  21. data/lib/theme_check/checks/asset_size_app_block_javascript.rb +2 -3
  22. data/lib/theme_check/checks/asset_url_filters.rb +2 -0
  23. data/lib/theme_check/checks/default_locale.rb +1 -1
  24. data/lib/theme_check/checks/deprecated_filter.rb +79 -4
  25. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +2 -3
  26. data/lib/theme_check/checks/matching_schema_translations.rb +14 -9
  27. data/lib/theme_check/checks/matching_translations.rb +1 -0
  28. data/lib/theme_check/checks/missing_required_template_files.rb +3 -3
  29. data/lib/theme_check/checks/missing_template.rb +1 -1
  30. data/lib/theme_check/checks/pagination_size.rb +2 -3
  31. data/lib/theme_check/checks/remote_asset.rb +5 -0
  32. data/lib/theme_check/checks/required_directories.rb +1 -1
  33. data/lib/theme_check/checks/required_layout_theme_object.rb +9 -4
  34. data/lib/theme_check/checks/schema_json_format.rb +29 -0
  35. data/lib/theme_check/checks/space_inside_braces.rb +132 -87
  36. data/lib/theme_check/checks/translation_key_exists.rb +33 -25
  37. data/lib/theme_check/checks/unused_assign.rb +3 -2
  38. data/lib/theme_check/checks/unused_snippet.rb +1 -1
  39. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  40. data/lib/theme_check/checks/valid_schema.rb +2 -2
  41. data/lib/theme_check/corrector.rb +34 -23
  42. data/lib/theme_check/exceptions.rb +1 -0
  43. data/lib/theme_check/file_system_storage.rb +8 -3
  44. data/lib/theme_check/html_node.rb +99 -6
  45. data/lib/theme_check/html_visitor.rb +1 -32
  46. data/lib/theme_check/in_memory_storage.rb +9 -0
  47. data/lib/theme_check/json_helpers.rb +14 -0
  48. data/lib/theme_check/language_server/bridge.rb +142 -0
  49. data/lib/theme_check/language_server/channel.rb +69 -0
  50. data/lib/theme_check/language_server/client_capabilities.rb +27 -0
  51. data/lib/theme_check/language_server/code_action_engine.rb +32 -0
  52. data/lib/theme_check/language_server/code_action_provider.rb +42 -0
  53. data/lib/theme_check/language_server/code_action_providers/quickfix_code_action_provider.rb +83 -0
  54. data/lib/theme_check/language_server/code_action_providers/source_fix_all_code_action_provider.rb +40 -0
  55. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +3 -1
  56. data/lib/theme_check/language_server/configuration.rb +69 -0
  57. data/lib/theme_check/language_server/diagnostic.rb +124 -0
  58. data/lib/theme_check/language_server/diagnostics_engine.rb +80 -0
  59. data/lib/theme_check/language_server/diagnostics_manager.rb +136 -0
  60. data/lib/theme_check/language_server/document_change_corrector.rb +267 -0
  61. data/lib/theme_check/language_server/document_link_provider.rb +6 -6
  62. data/lib/theme_check/language_server/execute_command_engine.rb +19 -0
  63. data/lib/theme_check/language_server/execute_command_provider.rb +30 -0
  64. data/lib/theme_check/language_server/execute_command_providers/correction_execute_command_provider.rb +48 -0
  65. data/lib/theme_check/language_server/execute_command_providers/run_checks_execute_command_provider.rb +22 -0
  66. data/lib/theme_check/language_server/handler.rb +92 -217
  67. data/lib/theme_check/language_server/io_messenger.rb +112 -0
  68. data/lib/theme_check/language_server/messenger.rb +12 -42
  69. data/lib/theme_check/language_server/protocol.rb +4 -0
  70. data/lib/theme_check/language_server/server.rb +54 -110
  71. data/lib/theme_check/language_server/uri_helper.rb +1 -0
  72. data/lib/theme_check/language_server/versioned_in_memory_storage.rb +69 -0
  73. data/lib/theme_check/language_server.rb +28 -6
  74. data/lib/theme_check/liquid_node.rb +255 -12
  75. data/lib/theme_check/locale_diff.rb +48 -10
  76. data/lib/theme_check/node.rb +16 -0
  77. data/lib/theme_check/offense.rb +27 -23
  78. data/lib/theme_check/position.rb +4 -4
  79. data/lib/theme_check/regex_helpers.rb +1 -1
  80. data/lib/theme_check/schema_helper.rb +70 -0
  81. data/lib/theme_check/shopify_liquid/system_translations.rb +35 -0
  82. data/lib/theme_check/shopify_liquid/tag.rb +19 -1
  83. data/lib/theme_check/shopify_liquid.rb +1 -0
  84. data/lib/theme_check/storage.rb +4 -0
  85. data/lib/theme_check/tags.rb +0 -1
  86. data/lib/theme_check/theme.rb +1 -1
  87. data/lib/theme_check/theme_file.rb +8 -1
  88. data/lib/theme_check/theme_file_rewriter.rb +28 -6
  89. data/lib/theme_check/version.rb +1 -1
  90. data/lib/theme_check.rb +11 -2
  91. metadata +31 -3
  92. data/lib/theme_check/language_server/diagnostics_tracker.rb +0 -66
@@ -13,30 +13,18 @@ module ThemeCheck
13
13
  attr_reader :should_raise_errors
14
14
 
15
15
  def initialize(
16
- in_stream: STDIN,
17
- out_stream: STDOUT,
18
- err_stream: STDERR,
16
+ messenger:,
19
17
  should_raise_errors: false,
20
18
  number_of_threads: 2
21
19
  )
22
- validate!([in_stream, out_stream, err_stream])
20
+ # This is what does the IO
21
+ @messenger = messenger
23
22
 
24
- @handler = Handler.new(self)
25
- @in = in_stream
26
- @out = out_stream
27
- @err = err_stream
23
+ # This is what you use to communicate with the language client
24
+ @bridge = Bridge.new(@messenger)
28
25
 
29
- # Because programming is fun,
30
- #
31
- # Ruby on Windows turns \n into \r\n. Which means that \r\n
32
- # gets turned into \r\r\n. Which means that the protocol
33
- # breaks on windows unless we turn STDOUT into binary mode.
34
- #
35
- # Hours wasted: 9.
36
- @out.binmode
37
-
38
- @out.sync = true # do not buffer
39
- @err.sync = true # do not buffer
26
+ # The handler handles messages from the language client
27
+ @handler = Handler.new(@bridge)
40
28
 
41
29
  # The queue holds the JSON RPC messages
42
30
  @queue = Queue.new
@@ -48,12 +36,8 @@ module ThemeCheck
48
36
  @number_of_threads = number_of_threads
49
37
  @handlers = []
50
38
 
51
- # The messenger permits requests to be made from the handler
52
- # to the language client and for those messages to be resolved in place.
53
- @messenger = Messenger.new
54
-
55
39
  # The error queue holds blocks the main thread. When filled, we exit the program.
56
- @error = SizedQueue.new(1)
40
+ @error = SizedQueue.new(number_of_threads)
57
41
 
58
42
  @should_raise_errors = should_raise_errors
59
43
  end
@@ -61,19 +45,23 @@ module ThemeCheck
61
45
  def listen
62
46
  start_handler_threads
63
47
  start_json_rpc_thread
64
- status_code_from_error(@error.pop)
48
+ status_code = status_code_from_error(@error.pop)
49
+ cleanup(status_code)
65
50
  rescue SignalException
66
51
  0
67
- ensure
68
- cleanup
69
52
  end
70
53
 
71
54
  def start_json_rpc_thread
72
55
  @json_rpc_thread = Thread.new do
73
56
  loop do
74
- message = read_json_rpc_message
75
- if message['method'] == 'initialize'
57
+ message = @bridge.read_message
58
+ if message[:method] == 'initialize'
76
59
  handle_message(message)
60
+ elsif message.key?(:result)
61
+ # Responses are handled on the main thread to prevent
62
+ # a potential deadlock caused by all handlers waiting
63
+ # for a responses.
64
+ handle_response(message)
77
65
  else
78
66
  @queue << message
79
67
  end
@@ -106,103 +94,46 @@ module ThemeCheck
106
94
 
107
95
  rescue Exception => e # rubocop:disable Lint/RescueException
108
96
  raise e if should_raise_errors
109
- log(e)
110
- log(e.backtrace)
97
+ @bridge.log("#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}")
111
98
  2
112
99
  end
113
100
 
114
- def request(&block)
115
- @messenger.request(&block)
116
- end
117
-
118
- def send_message(message)
119
- message_body = JSON.dump(message)
120
- log(JSON.pretty_generate(message)) if $DEBUG
121
-
122
- @out.write("Content-Length: #{message_body.bytesize}\r\n")
123
- @out.write("\r\n")
124
- @out.write(message_body)
125
- @out.flush
126
- end
127
-
128
- def log(message)
129
- @err.puts(message)
130
- @err.flush
131
- end
132
-
133
101
  private
134
102
 
135
- def supported_io_classes
136
- [IO, StringIO]
137
- end
138
-
139
- def validate!(streams = [])
140
- streams.each do |stream|
141
- unless supported_io_classes.find { |klass| stream.is_a?(klass) }
142
- raise IncompatibleStream, incompatible_stream_message
143
- end
144
- end
145
- end
146
-
147
- def incompatible_stream_message
148
- 'if provided, in_stream, out_stream, and err_stream must be a kind of '\
149
- "one of the following: #{supported_io_classes.join(', ')}"
150
- end
151
-
152
- def read_json_rpc_message
153
- message_body = read_new_content
154
- message_json = JSON.parse(message_body)
155
- log(JSON.pretty_generate(message_json)) if $DEBUG
156
- message_json
157
- end
158
-
159
103
  def handle_message(message)
160
- id = message['id']
161
- method_name = message['method']
104
+ id = message[:id]
105
+ method_name = message[:method]
162
106
  method_name &&= "on_#{to_snake_case(method_name)}"
163
- params = message['params']
164
- result = message['result']
107
+ params = message[:params]
165
108
 
166
- if message.key?('result')
167
- @messenger.respond(id, result)
168
- elsif @handler.respond_to?(method_name)
109
+ if @handler.respond_to?(method_name)
169
110
  @handler.send(method_name, id, params)
170
111
  end
171
- end
172
112
 
173
- def to_snake_case(method_name)
174
- StringHelpers.underscore(method_name.gsub(/[^\w]/, '_'))
113
+ rescue DoneStreaming => e
114
+ raise e
115
+ rescue StandardError => e
116
+ is_request = id
117
+ raise e unless is_request
118
+ # Errors obtained in request handlers should be sent
119
+ # back as internal errors instead of closing the program.
120
+ @bridge.log("#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}")
121
+ @bridge.send_internal_error(id, e)
175
122
  end
176
123
 
177
- def initial_line
178
- # Scanning for lines that fit the protocol.
179
- while true
180
- initial_line = @in.gets
181
- # gets returning nil means the stream was closed.
182
- raise DoneStreaming if initial_line.nil?
183
-
184
- if initial_line.match(/Content-Length: (\d+)/)
185
- break
186
- end
187
- end
188
- initial_line
124
+ def handle_response(message)
125
+ id = message[:id]
126
+ result = message[:result]
127
+ @bridge.receive_response(id, result)
189
128
  end
190
129
 
191
- def read_new_content
192
- length = initial_line.match(/Content-Length: (\d+)/)[1].to_i
193
- content = ''
194
- while content.length < length + 2
195
- # Why + 2? Because \r\n
196
- content += @in.read(length + 2)
197
- raise DoneStreaming if @in.closed?
198
- end
199
-
200
- content
130
+ def to_snake_case(method_name)
131
+ StringHelpers.underscore(method_name.gsub(/[^\w]/, '_'))
201
132
  end
202
133
 
203
- def cleanup
134
+ def cleanup(status_code)
204
135
  # Stop listenting to RPC calls
205
- @in.close unless @in.closed?
136
+ @messenger.close_input
206
137
  # Wait for rpc loop to close
207
138
  @json_rpc_thread&.join if @json_rpc_thread&.alive?
208
139
  # Close the queue
@@ -210,9 +141,22 @@ module ThemeCheck
210
141
  # Give 10 seconds for the handlers to wrap up what they were
211
142
  # doing/emptying the queue. 👀 unit tests.
212
143
  @handlers.each { |thread| thread.join(10) if thread.alive? }
144
+
145
+ # Hijack the status_code if an error occurred while cleaning up.
146
+ # 👀 unit tests.
147
+ until @error.empty?
148
+ code = status_code_from_error(@error.pop)
149
+ # Promote the status_code to ERROR if one of the threads
150
+ # resulted in an error, otherwise leave the status_code as
151
+ # is. That's because one thread could end successfully in a
152
+ # DoneStreaming error while the other failed with an
153
+ # internal error. If we had an internal error, we should
154
+ # return with a status_code that fits.
155
+ status_code = code if code > status_code
156
+ end
157
+ status_code
213
158
  ensure
214
- @err.close
215
- @out.close
159
+ @messenger.close_output
216
160
  end
217
161
  end
218
162
  end
@@ -15,6 +15,7 @@ module ThemeCheck
15
15
  #
16
16
  # Exists because of https://github.com/Shopify/theme-check/issues/360
17
17
  def file_uri(absolute_path)
18
+ return if absolute_path.nil?
18
19
  "file://" + absolute_path
19
20
  .to_s
20
21
  .split('/')
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class VersionedInMemoryStorage < InMemoryStorage
5
+ Version = Struct.new(:id, :version)
6
+
7
+ attr_reader :versions
8
+
9
+ def initialize(files, root = "/dev/null")
10
+ super(files, root)
11
+ @versions = {}
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # Motivations:
16
+ # - Need way for LanguageServer to know on which version of a file
17
+ # the check was run on, because we need to know where the
18
+ # TextEdit goes. If the text changed, our TextEdit might not be
19
+ # in the right spot. e.g.
20
+ #
21
+ # Example:
22
+ #
23
+ # ```
24
+ # Hi
25
+ # {{world}}
26
+ # ```
27
+ #
28
+ # Would produce two "SpaceInsideBrace" errors:
29
+ #
30
+ # - One after {{ at index 5 to 6
31
+ # - One before }} at index 10 to 11
32
+ #
33
+ # If the user goes in and changes Hi to Sup, and _then_
34
+ # right clicks to apply the code edit at index 5 to 6, he'd
35
+ # get the following:
36
+ #
37
+ # ```
38
+ # Sup
39
+ # { {world}}
40
+ # ```
41
+ #
42
+ # Which is not a fix at all.
43
+ #
44
+ # Solution:
45
+ # - Have the LanguageServer store the version on textDocument/did{Open,Change,Close}
46
+ # - Have ThemeFile store the version right after @storage.read.
47
+ # - Add version to the diagnostic meta data
48
+ # - Use diagnostic meta data to determine if we can make a code edit or not
49
+ # - Only offer fixes on "clean" files (or offer the change but specify the version so the editor knows what to do with it)
50
+ def write(relative_path, content, version)
51
+ @mutex.synchronize do
52
+ @versions[relative_path] = version
53
+ super(relative_path, content)
54
+ end
55
+ end
56
+
57
+ def read_version(relative_path)
58
+ @mutex.synchronize { [read(relative_path), version(relative_path)] }
59
+ end
60
+
61
+ def versioned?
62
+ true
63
+ end
64
+
65
+ def version(relative_path)
66
+ @versions[relative_path.to_s]
67
+ end
68
+ end
69
+ end
@@ -1,31 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "language_server/protocol"
3
- require_relative "language_server/messenger"
4
3
  require_relative "language_server/constants"
4
+ require_relative "language_server/configuration"
5
+ require_relative "language_server/channel"
6
+ require_relative "language_server/messenger"
7
+ require_relative "language_server/io_messenger"
8
+ require_relative "language_server/bridge"
5
9
  require_relative "language_server/uri_helper"
6
- require_relative "language_server/handler"
7
10
  require_relative "language_server/server"
8
11
  require_relative "language_server/tokens"
9
12
  require_relative "language_server/variable_lookup_finder"
13
+ require_relative "language_server/diagnostic"
14
+ require_relative "language_server/diagnostics_manager"
15
+ require_relative "language_server/diagnostics_engine"
16
+ require_relative "language_server/document_change_corrector"
17
+ require_relative "language_server/versioned_in_memory_storage"
18
+ require_relative "language_server/client_capabilities"
19
+
10
20
  require_relative "language_server/completion_helper"
11
21
  require_relative "language_server/completion_provider"
12
22
  require_relative "language_server/completion_engine"
23
+ Dir[__dir__ + "/language_server/completion_providers/*.rb"].each do |file|
24
+ require file
25
+ end
26
+
13
27
  require_relative "language_server/document_link_provider"
14
28
  require_relative "language_server/document_link_engine"
15
- require_relative "language_server/diagnostics_tracker"
29
+ Dir[__dir__ + "/language_server/document_link_providers/*.rb"].each do |file|
30
+ require file
31
+ end
16
32
 
17
- Dir[__dir__ + "/language_server/completion_providers/*.rb"].each do |file|
33
+ require_relative "language_server/execute_command_provider"
34
+ require_relative "language_server/execute_command_engine"
35
+ Dir[__dir__ + "/language_server/execute_command_providers/*.rb"].each do |file|
18
36
  require file
19
37
  end
20
38
 
21
- Dir[__dir__ + "/language_server/document_link_providers/*.rb"].each do |file|
39
+ require_relative "language_server/code_action_provider"
40
+ require_relative "language_server/code_action_engine"
41
+ Dir[__dir__ + "/language_server/code_action_providers/*.rb"].each do |file|
22
42
  require file
23
43
  end
24
44
 
45
+ require_relative "language_server/handler"
46
+
25
47
  module ThemeCheck
26
48
  module LanguageServer
27
49
  def self.start
28
- Server.new.listen
50
+ Server.new(messenger: IOMessenger.new).listen
29
51
  end
30
52
  end
31
53
  end
@@ -45,11 +45,47 @@ module ThemeCheck
45
45
  def markup
46
46
  if tag?
47
47
  tag_markup
48
+ elsif literal?
49
+ value.to_s
48
50
  elsif @value.instance_variable_defined?(:@markup)
49
51
  @value.instance_variable_get(:@markup)
50
52
  end
51
53
  end
52
54
 
55
+ # The original source code of the node. Does contain wrapping braces.
56
+ def outer_markup
57
+ if literal?
58
+ markup
59
+ elsif variable_lookup?
60
+ ''
61
+ elsif variable?
62
+ start_token + markup + end_token
63
+ elsif tag? && block?
64
+ start_index = block_start_start_index
65
+ end_index = block_start_end_index
66
+ end_index += inner_markup.size
67
+ end_index = find_block_delimiter(end_index)&.end(0)
68
+ source[start_index...end_index]
69
+ elsif tag?
70
+ source[block_start_start_index...block_start_end_index]
71
+ else
72
+ inner_markup
73
+ end
74
+ end
75
+
76
+ def inner_markup
77
+ return '' unless block?
78
+ @inner_markup ||= source[block_start_end_index...block_end_start_index]
79
+ end
80
+
81
+ def inner_json
82
+ return nil unless schema?
83
+ @inner_json ||= JSON.parse(inner_markup)
84
+ rescue JSON::ParserError
85
+ # Handled by ValidSchema
86
+ @inner_json = nil
87
+ end
88
+
53
89
  def markup=(markup)
54
90
  if @value.instance_variable_defined?(:@markup)
55
91
  @value.instance_variable_set(:@markup, markup)
@@ -70,10 +106,26 @@ module ThemeCheck
70
106
  position.start_index
71
107
  end
72
108
 
109
+ def start_row
110
+ position.start_row
111
+ end
112
+
113
+ def start_column
114
+ position.start_column
115
+ end
116
+
73
117
  def end_index
74
118
  position.end_index
75
119
  end
76
120
 
121
+ def end_row
122
+ position.end_row
123
+ end
124
+
125
+ def end_column
126
+ position.end_column
127
+ end
128
+
77
129
  # Literals are hard-coded values in the liquid file.
78
130
  def literal?
79
131
  @value.is_a?(String) || @value.is_a?(Integer)
@@ -88,6 +140,14 @@ module ThemeCheck
88
140
  @value.is_a?(Liquid::Variable)
89
141
  end
90
142
 
143
+ def assigned_or_echoed_variable?
144
+ variable? && start_token == ""
145
+ end
146
+
147
+ def variable_lookup?
148
+ @value.is_a?(Liquid::VariableLookup)
149
+ end
150
+
91
151
  # A {% comment %} block node?
92
152
  def comment?
93
153
  @value.is_a?(Liquid::Comment)
@@ -114,6 +174,10 @@ module ThemeCheck
114
174
  block_tag? || block_body? || document?
115
175
  end
116
176
 
177
+ def schema?
178
+ @value.is_a?(ThemeCheck::Tags::Schema)
179
+ end
180
+
117
181
  # The `:under_score_name` of this type of node. Used to dispatch to the `on_<type_name>`
118
182
  # and `after_<type_name>` check methods.
119
183
  def type_name
@@ -124,6 +188,86 @@ module ThemeCheck
124
188
  theme_file&.source
125
189
  end
126
190
 
191
+ def block_start_markup
192
+ source[block_start_start_index...block_start_end_index]
193
+ end
194
+
195
+ def block_start_start_index
196
+ @block_start_start_index ||= if inside_liquid_tag?
197
+ backtrack_on_whitespace(source, start_index, /[ \t]/)
198
+ elsif tag?
199
+ backtrack_on_whitespace(source, start_index) - start_token.length
200
+ else
201
+ position.start_index - start_token.length
202
+ end
203
+ end
204
+
205
+ def block_start_end_index
206
+ @block_start_end_index ||= position.end_index + end_token.size
207
+ end
208
+
209
+ def block_end_markup
210
+ source[block_end_start_index...block_end_end_index]
211
+ end
212
+
213
+ def block_end_start_index
214
+ return block_start_end_index unless tag? && block?
215
+ @block_end_start_index ||= block_end_match&.begin(0) || block_start_end_index
216
+ end
217
+
218
+ def block_end_end_index
219
+ return block_end_start_index unless tag? && block?
220
+ @block_end_end_index ||= block_end_match&.end(0) || block_start_end_index
221
+ end
222
+
223
+ def outer_markup_start_index
224
+ outer_markup_position.start_index
225
+ end
226
+
227
+ def outer_markup_end_index
228
+ outer_markup_position.end_index
229
+ end
230
+
231
+ def outer_markup_start_row
232
+ outer_markup_position.start_row
233
+ end
234
+
235
+ def outer_markup_start_column
236
+ outer_markup_position.start_column
237
+ end
238
+
239
+ def outer_markup_end_row
240
+ outer_markup_position.end_row
241
+ end
242
+
243
+ def outer_markup_end_column
244
+ outer_markup_position.end_column
245
+ end
246
+
247
+ def inner_markup_start_index
248
+ inner_markup_position.start_index
249
+ end
250
+
251
+ def inner_markup_end_index
252
+ inner_markup_position.end_index
253
+ end
254
+
255
+ def inner_markup_start_row
256
+ inner_markup_position.start_row
257
+ end
258
+
259
+ def inner_markup_start_column
260
+ inner_markup_position.start_column
261
+ end
262
+
263
+ def inner_markup_end_row
264
+ inner_markup_position.end_row
265
+ end
266
+
267
+ def inner_markup_end_column
268
+ inner_markup_position.end_column
269
+ end
270
+
127
271
  WHITESPACE = /\s/
128
272
 
129
273
  # Is this node inside a `{% liquid ... %}` block?
@@ -165,21 +309,37 @@ module ThemeCheck
165
309
  end
166
310
 
167
311
  def start_token
168
- return "" if inside_liquid_tag?
169
- output = ""
170
- output += "{{" if variable?
171
- output += "{%" if tag?
172
- output += "-" if whitespace_trimmed_start?
173
- output
312
+ if inside_liquid_tag?
313
+ ""
314
+ elsif variable? && source[start_index - 3..start_index - 1] == "{{-"
315
+ "{{-"
316
+ elsif variable? && source[start_index - 2..start_index - 1] == "{{"
317
+ "{{"
318
+ elsif tag? && whitespace_trimmed_start?
319
+ "{%-"
320
+ elsif tag?
321
+ "{%"
322
+ else
323
+ ""
324
+ end
174
325
  end
175
326
 
176
327
  def end_token
177
- return "" if inside_liquid_tag?
178
- output = ""
179
- output += "-" if whitespace_trimmed_end?
180
- output += "}}" if variable?
181
- output += "%}" if tag?
182
- output
328
+ if inside_liquid_tag? && source[end_index] == "\n"
329
+ "\n"
330
+ elsif inside_liquid_tag?
331
+ ""
332
+ elsif variable? && source[end_index...end_index + 3] == "-}}"
333
+ "-}}"
334
+ elsif variable? && source[end_index...end_index + 2] == "}}"
335
+ "}}"
336
+ elsif tag? && whitespace_trimmed_end?
337
+ "-%}"
338
+ elsif tag?
339
+ "%}"
340
+ else # this could happen because we're in an assign statement (variable)
341
+ ""
342
+ end
183
343
  end
184
344
 
185
345
  private
@@ -192,6 +352,22 @@ module ThemeCheck
192
352
  )
193
353
  end
194
354
 
355
+ def outer_markup_position
356
+ @outer_markup_position ||= StrictPosition.new(
357
+ outer_markup,
358
+ source,
359
+ block_start_start_index,
360
+ )
361
+ end
362
+
363
+ def inner_markup_position
364
+ @inner_markup_position ||= StrictPosition.new(
365
+ inner_markup,
366
+ source,
367
+ block_start_end_index,
368
+ )
369
+ end
370
+
195
371
  # Here we're hacking around a glorious bug in Liquid that makes it so the
196
372
  # line_number and markup of a tag is wrong if there's whitespace
197
373
  # between the tag_name and the markup of the tag.
@@ -287,5 +463,72 @@ module ThemeCheck
287
463
  # return the real raw content
288
464
  @tag_markup = source[tag_start...markup_end]
289
465
  end
466
+
467
+ # Returns the index of the leftmost consecutive whitespace
468
+ # starting from start going backwards.
469
+ #
470
+ # e.g. backtrack_on_whitespace("01 45", 4) would return 2.
471
+ # e.g. backtrack_on_whitespace("{% render %}", 5) would return 2.
472
+ def backtrack_on_whitespace(string, start, whitespace = WHITESPACE)
473
+ i = start
474
+ i -= 1 while string[i - 1] =~ whitespace && i > 0
475
+ i
476
+ end
477
+
478
+ def find_block_delimiter(start_index)
479
+ return nil unless tag? && block?
480
+
481
+ tag_start, tag_end = if inside_liquid_tag?
482
+ [
483
+ /^\s*#{@value.tag_name}\s*/,
484
+ /^\s*end#{@value.tag_name}\s*/,
485
+ ]
486
+ else
487
+ [
488
+ /#{Liquid::TagStart}-?\s*#{@value.tag_name}/mi,
489
+ /#{Liquid::TagStart}-?\s*end#{@value.tag_name}\s*-?#{Liquid::TagEnd}/mi,
490
+ ]
491
+ end
492
+
493
+ # This little algorithm below find the _correct_ block delimiter
494
+ # (endif, endcase, endcomment) for the current tag. What do I
495
+ # mean by correct? It means the one you'd expect. Making sure
496
+ # that we don't do the naive regex find. Since you can have
497
+ # nested ifs, fors, etc.
498
+ #
499
+ # It works by having a stack, pushing onto the stack when we
500
+ # open a tag of our type_name. And popping when we find a
501
+ # closing tag of our type_name.
502
+ #
503
+ # When the stack is empty, we return the end tag match.
504
+ index = start_index
505
+ stack = []
506
+ stack.push("open")
507
+ loop do
508
+ tag_start_match = tag_start.match(source, index)
509
+ tag_end_match = tag_end.match(source, index)
510
+
511
+ return nil unless tag_end_match
512
+
513
+ # We have found a tag_start and it appeared _before_ the
514
+ # tag_end that we found, thus we push it onto the stack.
515
+ if tag_start_match && tag_start_match.end(0) < tag_end_match.end(0)
516
+ stack.push("open")
517
+ end
518
+
519
+ # We have found a tag_end, therefore we pop
520
+ stack.pop
521
+
522
+ # Nothing left on the stack, we're done.
523
+ break tag_end_match if stack.empty?
524
+
525
+ # We keep looking from the end of the end tag we just found.
526
+ index = tag_end_match.end(0)
527
+ end
528
+ end
529
+
530
+ def block_end_match
531
+ @block_end_match ||= find_block_delimiter(block_start_end_index)
532
+ end
290
533
  end
291
534
  end