theme-check 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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