theme-check 1.4.0 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +14 -6
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +42 -0
  5. data/CONTRIBUTING.md +58 -0
  6. data/Gemfile +3 -0
  7. data/config/default.yml +3 -0
  8. data/docs/checks/deprecated_global_app_block_type.md +65 -0
  9. data/docs/flamegraph.svg +18488 -0
  10. data/lib/theme_check/analyzer.rb +5 -0
  11. data/lib/theme_check/asset_file.rb +13 -2
  12. data/lib/theme_check/check.rb +1 -1
  13. data/lib/theme_check/checks/asset_size_css.rb +15 -0
  14. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +18 -1
  15. data/lib/theme_check/checks/convert_include_to_render.rb +2 -1
  16. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +57 -0
  17. data/lib/theme_check/checks/liquid_tag.rb +1 -1
  18. data/lib/theme_check/checks/missing_required_template_files.rb +21 -7
  19. data/lib/theme_check/checks/pagination_size.rb +33 -14
  20. data/lib/theme_check/checks/required_directories.rb +3 -1
  21. data/lib/theme_check/checks/space_inside_braces.rb +47 -24
  22. data/lib/theme_check/checks/translation_key_exists.rb +3 -1
  23. data/lib/theme_check/checks/unused_snippet.rb +3 -1
  24. data/lib/theme_check/cli.rb +32 -6
  25. data/lib/theme_check/corrector.rb +23 -10
  26. data/lib/theme_check/file_system_storage.rb +13 -2
  27. data/lib/theme_check/html_node.rb +4 -4
  28. data/lib/theme_check/html_visitor.rb +20 -8
  29. data/lib/theme_check/in_memory_storage.rb +8 -0
  30. data/lib/theme_check/json_file.rb +9 -4
  31. data/lib/theme_check/json_printer.rb +6 -1
  32. data/lib/theme_check/language_server/document_link_provider.rb +2 -1
  33. data/lib/theme_check/language_server/handler.rb +16 -11
  34. data/lib/theme_check/language_server/server.rb +11 -13
  35. data/lib/theme_check/language_server/uri_helper.rb +37 -0
  36. data/lib/theme_check/language_server.rb +1 -0
  37. data/lib/theme_check/node.rb +118 -11
  38. data/lib/theme_check/offense.rb +26 -0
  39. data/lib/theme_check/position.rb +27 -16
  40. data/lib/theme_check/position_helper.rb +13 -15
  41. data/lib/theme_check/printer.rb +9 -5
  42. data/lib/theme_check/regex_helpers.rb +1 -15
  43. data/lib/theme_check/remote_asset_file.rb +4 -0
  44. data/lib/theme_check/template.rb +5 -19
  45. data/lib/theme_check/template_rewriter.rb +57 -0
  46. data/lib/theme_check/theme_file.rb +18 -1
  47. data/lib/theme_check/version.rb +1 -1
  48. data/lib/theme_check.rb +1 -0
  49. data/theme-check.gemspec +1 -0
  50. metadata +21 -2
@@ -7,25 +7,20 @@ module ThemeCheck
7
7
  end
8
8
 
9
9
  def insert_after(node, content)
10
- line = @template.full_line(node.line_number)
11
- line.insert(node.range[1] + 1, content)
10
+ @template.rewriter.insert_after(node, content)
12
11
  end
13
12
 
14
13
  def insert_before(node, content)
15
- line = @template.full_line(node.line_number)
16
- line.insert(node.range[0], content)
14
+ @template.rewriter.insert_before(node, content)
17
15
  end
18
16
 
19
17
  def replace(node, content)
20
- line = @template.full_line(node.line_number)
21
- line[node.range[0]..node.range[1]] = content
18
+ @template.rewriter.replace(node, content)
22
19
  node.markup = content
23
20
  end
24
21
 
25
22
  def wrap(node, insert_before, insert_after)
26
- line = @template.full_line(node.line_number)
27
- line.insert(node.range[0], insert_before)
28
- line.insert(node.range[1] + 1 + insert_before.length, insert_after)
23
+ @template.rewriter.wrap(node, insert_before, insert_after)
29
24
  end
30
25
 
31
26
  def create(theme, relative_path, content)
@@ -34,7 +29,25 @@ module ThemeCheck
34
29
 
35
30
  def create_default_locale_json(theme)
36
31
  theme.default_locale_json = JsonFile.new("locales/#{theme.default_locale}.default.json", theme.storage)
37
- theme.default_locale_json.update_contents('{}')
32
+ theme.default_locale_json.update_contents({})
33
+ end
34
+
35
+ def remove(theme, relative_path)
36
+ theme.storage.remove(relative_path)
37
+ end
38
+
39
+ def mkdir(theme, relative_path)
40
+ theme.storage.mkdir(relative_path)
41
+ end
42
+
43
+ def add_default_translation_key(file, key, value)
44
+ hash = file.content
45
+ key.reduce(hash) do |pointer, token|
46
+ return pointer[token] = value if token == key.last
47
+ pointer[token] = {} unless pointer.key?(token)
48
+ pointer[token]
49
+ end
50
+ file.update_contents(hash)
38
51
  end
39
52
  end
40
53
  end
@@ -16,14 +16,25 @@ module ThemeCheck
16
16
  end
17
17
 
18
18
  def read(relative_path)
19
- file(relative_path).read
19
+ file(relative_path).read(mode: 'rb', encoding: 'UTF-8')
20
20
  end
21
21
 
22
22
  def write(relative_path, content)
23
23
  reset_memoizers unless file_exists?(relative_path)
24
24
 
25
25
  file(relative_path).dirname.mkpath unless file(relative_path).dirname.directory?
26
- file(relative_path).write(content)
26
+ file(relative_path).write(content, mode: 'w+b', encoding: 'UTF-8')
27
+ end
28
+
29
+ def remove(relative_path)
30
+ file(relative_path).delete
31
+ reset_memoizers
32
+ end
33
+
34
+ def mkdir(relative_path)
35
+ reset_memoizers unless file_exists?(relative_path)
36
+
37
+ file(relative_path).mkpath unless file(relative_path).directory?
27
38
  end
28
39
 
29
40
  def files
@@ -67,10 +67,10 @@ module ThemeCheck
67
67
  private
68
68
 
69
69
  def replace_placeholders(string)
70
- # Replace all {%#{i}####%} with the actual content.
71
- string.gsub(LIQUID_TAG) do |match|
72
- key = /\d+/.match(match)[0]
73
- @placeholder_values[key.to_i]
70
+ # Replace all {i}####≬ with the actual content.
71
+ string.gsub(HTML_LIQUID_PLACEHOLDER) do |match|
72
+ key = /[0-9a-z]+/.match(match)[0]
73
+ @placeholder_values[key.to_i(36)]
74
74
  end
75
75
  end
76
76
  end
@@ -9,12 +9,11 @@ module ThemeCheck
9
9
 
10
10
  def initialize(checks)
11
11
  @checks = checks
12
- @placeholder_values = []
13
12
  end
14
13
 
15
14
  def visit_template(template)
16
- doc = parse(template)
17
- visit(HtmlNode.new(doc, template, @placeholder_values))
15
+ doc, placeholder_values = parse(template)
16
+ visit(HtmlNode.new(doc, template, placeholder_values))
18
17
  rescue ArgumentError => e
19
18
  call_checks(:on_parse_error, e, template)
20
19
  end
@@ -22,19 +21,32 @@ module ThemeCheck
22
21
  private
23
22
 
24
23
  def parse(template)
24
+ placeholder_values = []
25
25
  parseable_source = +template.source.clone
26
26
 
27
- # Replace all liquid tags with {%#{i}######%} to prevent the HTML
27
+ # Replace all non-empty liquid tags with {i}######≬ to prevent the HTML
28
28
  # parser from freaking out. We transparently replace those placeholders in
29
29
  # HtmlNode.
30
+ #
31
+ # We're using base36 to prevent index bleeding on 36^3 tags.
32
+ # `{{x}}` -> `≬#{i}≬` would properly be transformed for 46656 tags in a single file.
33
+ # Should be enough.
34
+ #
35
+ # The base10 alternative would have overflowed at 1000 (`{{x}}` -> `≬1000≬`) which seemed more likely.
36
+ #
37
+ # Didn't go with base64 because of the `=` character that would have messed with HTML parsing.
30
38
  matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
31
39
  value = m[0]
32
- @placeholder_values.push(value)
33
- key = (@placeholder_values.size - 1).to_s
34
- parseable_source[m.begin(0)...m.end(0)] = "{%#{key.ljust(m.end(0) - m.begin(0) - 4, '#')}%}"
40
+ next unless value.size > 4 # skip empty tags/variables {%%} and {{}}
41
+ placeholder_values.push(value)
42
+ key = (placeholder_values.size - 1).to_s(36)
43
+ parseable_source[m.begin(0)...m.end(0)] = "≬#{key.ljust(m.end(0) - m.begin(0) - 2, '#')}≬"
35
44
  end
36
45
 
37
- Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400)
46
+ [
47
+ Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400),
48
+ placeholder_values,
49
+ ]
38
50
  end
39
51
 
40
52
  def visit(node)
@@ -23,6 +23,14 @@ module ThemeCheck
23
23
  @files[relative_path] = content
24
24
  end
25
25
 
26
+ def remove(relative_path)
27
+ @files.delete(relative_path)
28
+ end
29
+
30
+ def mkdir(relative_path)
31
+ @files[relative_path] = nil
32
+ end
33
+
26
34
  def files
27
35
  @files.keys
28
36
  end
@@ -20,14 +20,19 @@ module ThemeCheck
20
20
  @parser_error
21
21
  end
22
22
 
23
- def update_contents(new_content = '{}')
23
+ def update_contents(new_content = {})
24
+ raise ArgumentError if new_content.is_a?(String)
24
25
  @content = new_content
25
26
  end
26
27
 
27
28
  def write
28
- if source != @content
29
- @storage.write(@relative_path, content)
30
- @source = content
29
+ pretty = JSON.pretty_generate(@content)
30
+ if source.rstrip != pretty.rstrip
31
+ # Most editors add a trailing \n at the end of files. Here we
32
+ # try to maintain the convention.
33
+ eof = source.end_with?("\n") ? "\n" : ""
34
+ @storage.write(@relative_path, pretty.gsub("\n", @eol) + eof)
35
+ @source = pretty
31
36
  end
32
37
  end
33
38
 
@@ -3,9 +3,13 @@ require 'json'
3
3
 
4
4
  module ThemeCheck
5
5
  class JsonPrinter
6
+ def initialize(out_stream = STDOUT)
7
+ @out = out_stream
8
+ end
9
+
6
10
  def print(offenses)
7
11
  json = offenses_by_path(offenses)
8
- puts JSON.dump(json)
12
+ @out.puts JSON.dump(json)
9
13
  end
10
14
 
11
15
  def offenses_by_path(offenses)
@@ -21,6 +25,7 @@ module ThemeCheck
21
25
  styleCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:style] },
22
26
  }
23
27
  end
28
+ .sort_by { |o| o[:path] }
24
29
  end
25
30
  end
26
31
  end
@@ -5,6 +5,7 @@ module ThemeCheck
5
5
  class DocumentLinkProvider
6
6
  include RegexHelpers
7
7
  include PositionHelper
8
+ include URIHelper
8
9
 
9
10
  class << self
10
11
  attr_accessor :partial_regexp, :destination_directory, :destination_postfix
@@ -63,7 +64,7 @@ module ThemeCheck
63
64
  end
64
65
 
65
66
  def file_link(partial)
66
- "file://#{@storage.path(destination_directory + '/' + partial + destination_postfix)}"
67
+ file_uri(@storage.path(destination_directory + '/' + partial + destination_postfix))
67
68
  end
68
69
  end
69
70
  end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "benchmark"
3
- require "uri"
4
- require "cgi"
5
4
 
6
5
  module ThemeCheck
7
6
  module LanguageServer
8
7
  class Handler
8
+ include URIHelper
9
+
9
10
  CAPABILITIES = {
10
11
  completionProvider: {
11
12
  triggerCharacters: ['.', '{{ ', '{% '],
@@ -26,7 +27,7 @@ module ThemeCheck
26
27
  end
27
28
 
28
29
  def on_initialize(id, params)
29
- @root_path = path_from_uri(params["rootUri"]) || params["rootPath"]
30
+ @root_path = root_path_from_params(params)
30
31
 
31
32
  # Tell the client we don't support anything if there's no rootPath
32
33
  return send_response(id, { capabilities: {} }) if @root_path.nil?
@@ -96,19 +97,23 @@ module ThemeCheck
96
97
  end
97
98
 
98
99
  def text_document_uri(params)
99
- path_from_uri(params.dig('textDocument', 'uri'))
100
- end
101
-
102
- def path_from_uri(uri_string)
103
- return if uri_string.nil?
104
- uri = URI(uri_string)
105
- CGI.unescape(uri.path)
100
+ file_path(params.dig('textDocument', 'uri'))
106
101
  end
107
102
 
108
103
  def relative_path_from_text_document_uri(params)
109
104
  @storage.relative_path(text_document_uri(params))
110
105
  end
111
106
 
107
+ def root_path_from_params(params)
108
+ root_uri = params["rootUri"]
109
+ root_path = params["rootPath"]
110
+ if root_uri
111
+ file_path(root_uri)
112
+ elsif root_path
113
+ root_path
114
+ end
115
+ end
116
+
112
117
  def text_document_text(params)
113
118
  params.dig('textDocument', 'text')
114
119
  end
@@ -174,7 +179,7 @@ module ThemeCheck
174
179
  def send_diagnostic(path, offenses)
175
180
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
176
181
  send_notification('textDocument/publishDiagnostics', {
177
- uri: "file://#{path}",
182
+ uri: file_uri(path),
178
183
  diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
179
184
  })
180
185
  end
@@ -25,6 +25,15 @@ module ThemeCheck
25
25
  @out = out_stream
26
26
  @err = err_stream
27
27
 
28
+ # Because programming is fun,
29
+ #
30
+ # Ruby on Windows turns \n into \r\n. Which means that \r\n
31
+ # gets turned into \r\r\n. Which means that the protocol
32
+ # breaks on windows unless we turn STDOUT into binary mode.
33
+ #
34
+ # Hours wasted: 9.
35
+ @out.binmode
36
+
28
37
  @out.sync = true # do not buffer
29
38
  @err.sync = true # do not buffer
30
39
 
@@ -52,19 +61,8 @@ module ThemeCheck
52
61
  response_body = JSON.dump(response)
53
62
  log(JSON.pretty_generate(response)) if $DEBUG
54
63
 
55
- # Because programming is fun,
56
- #
57
- # Ruby on Windows turns \n into \r\n. Which means that \r\n
58
- # gets turned into \r\r\n. Which means that the protocol
59
- # breaks on windows unless we turn STDOUT into binary mode and
60
- # set the encoding manually (yuk!) or we do this little hack
61
- # here and put \n which gets transformed into \r\n on windows
62
- # only...
63
- #
64
- # Hours wasted: 8.
65
- eol = Gem.win_platform? ? "\n" : "\r\n"
66
- @out.write("Content-Length: #{response_body.bytesize}#{eol}")
67
- @out.write(eol)
64
+ @out.write("Content-Length: #{response_body.bytesize}\r\n")
65
+ @out.write("\r\n")
68
66
  @out.write(response_body)
69
67
  @out.flush
70
68
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require "uri"
5
+ require "cgi"
6
+
7
+ module ThemeCheck
8
+ module LanguageServer
9
+ module URIHelper
10
+ # Will URI.encode a string the same way VS Code would. There are two things
11
+ # to watch out for:
12
+ #
13
+ # 1. VS Code still uses the outdated '%20' for spaces
14
+ # 2. VS Code prefixes Windows paths with / (so /C:/Users/... is expected)
15
+ #
16
+ # Exists because of https://github.com/Shopify/theme-check/issues/360
17
+ def file_uri(absolute_path)
18
+ "file://" + absolute_path
19
+ .to_s
20
+ .split('/')
21
+ .map { |x| CGI.escape(x).gsub('+', '%20') }
22
+ .join('/')
23
+ .sub(%r{^/?}, '/') # Windows paths should be prefixed by /c:
24
+ end
25
+
26
+ def file_path(uri_string)
27
+ return if uri_string.nil?
28
+ uri = URI(uri_string)
29
+ path = CGI.unescape(uri.path)
30
+ # On Windows, VS Code sends the URLs as file:///C:/...
31
+ # /C:/1234 is not a valid path in ruby. So we strip the slash.
32
+ path = path.sub(%r{^/([a-z]:/)}i, '\1')
33
+ path
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "language_server/protocol"
3
3
  require_relative "language_server/constants"
4
+ require_relative "language_server/uri_helper"
4
5
  require_relative "language_server/handler"
5
6
  require_relative "language_server/server"
6
7
  require_relative "language_server/tokens"
@@ -10,12 +10,14 @@ module ThemeCheck
10
10
  @value = value
11
11
  @parent = parent
12
12
  @template = template
13
+ @tag_markup = nil
14
+ @line_number_offset = 0
13
15
  end
14
16
 
15
17
  # The original source code of the node. Doesn't contain wrapping braces.
16
18
  def markup
17
19
  if tag?
18
- @value.raw
20
+ tag_markup
19
21
  elsif @value.instance_variable_defined?(:@markup)
20
22
  @value.instance_variable_get(:@markup)
21
23
  end
@@ -64,6 +66,10 @@ module ThemeCheck
64
66
  @value.is_a?(Liquid::Tag)
65
67
  end
66
68
 
69
+ def variable?
70
+ @value.is_a?(Liquid::Variable)
71
+ end
72
+
67
73
  # A {% comment %} block node?
68
74
  def comment?
69
75
  @value.is_a?(Liquid::Comment)
@@ -92,7 +98,12 @@ module ThemeCheck
92
98
 
93
99
  # Most nodes have a line number, but it's not guaranteed.
94
100
  def line_number
95
- @value.line_number if @value.respond_to?(:line_number)
101
+ if tag? && @value.respond_to?(:line_number)
102
+ markup # initialize the line_number_offset
103
+ @value.line_number - @line_number_offset
104
+ elsif @value.respond_to?(:line_number)
105
+ @value.line_number
106
+ end
96
107
  end
97
108
 
98
109
  # The `:under_score_name` of this type of node. Used to dispatch to the `on_<type_name>`
@@ -101,27 +112,48 @@ module ThemeCheck
101
112
  @type_name ||= StringHelpers.underscore(StringHelpers.demodulize(@value.class.name)).to_sym
102
113
  end
103
114
 
115
+ def source
116
+ template&.source
117
+ end
118
+
119
+ WHITESPACE = /\s/
120
+
104
121
  # Is this node inside a `{% liquid ... %}` block?
105
122
  def inside_liquid_tag?
106
- if line_number
107
- template.excerpt(line_number).start_with?("{%")
123
+ # What we're doing here is starting at the start of the tag and
124
+ # backtrack on all the whitespace until we land on something. If
125
+ # that something is {% or %-, then we can safely assume that
126
+ # we're inside a full tag and not a liquid tag.
127
+ @inside_liquid_tag ||= if tag? && line_number && source
128
+ i = 1
129
+ i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
130
+ first_two_backtracked_characters = source[(start_index - i - 1)..(start_index - i)]
131
+ first_two_backtracked_characters != "{%" && first_two_backtracked_characters != "%-"
108
132
  else
109
133
  false
110
134
  end
111
135
  end
112
136
 
113
- # Is this node inside a `{%- ... -%}`
114
- def whitespace_trimmed?
115
- if line_number
116
- template.excerpt(line_number).start_with?("{%-")
137
+ # Is this node inside a tag or variable that starts by removing whitespace. i.e. {%- or {{-
138
+ def whitespace_trimmed_start?
139
+ @whitespace_trimmed_start ||= if line_number && source && !inside_liquid_tag?
140
+ i = 1
141
+ i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
142
+ source[start_index - i] == "-"
117
143
  else
118
144
  false
119
145
  end
120
146
  end
121
147
 
122
- def range
123
- start = template.full_line(line_number).index(markup)
124
- [start, start + markup.length - 1]
148
+ # Is this node inside a tag or variable ends starts by removing whitespace. i.e. -%} or -}}
149
+ def whitespace_trimmed_end?
150
+ @whitespace_trimmed_end ||= if line_number && source && !inside_liquid_tag?
151
+ i = 0
152
+ i += 1 while source[end_index + i] =~ WHITESPACE && i < source.size
153
+ source[end_index + i] == "-"
154
+ else
155
+ false
156
+ end
125
157
  end
126
158
 
127
159
  def position
@@ -139,5 +171,80 @@ module ThemeCheck
139
171
  def end_index
140
172
  position.end_index
141
173
  end
174
+
175
+ def start_token
176
+ return "" if inside_liquid_tag?
177
+ output = ""
178
+ output += "{{" if variable?
179
+ output += "{%" if tag?
180
+ output += "-" if whitespace_trimmed_start?
181
+ output
182
+ end
183
+
184
+ def end_token
185
+ return "" if inside_liquid_tag?
186
+ output = ""
187
+ output += "-" if whitespace_trimmed_end?
188
+ output += "}}" if variable?
189
+ output += "%}" if tag?
190
+ output
191
+ end
192
+
193
+ private
194
+
195
+ # Here we're hacking around a glorious bug in Liquid that makes it so the
196
+ # line_number and markup of a tag is wrong if there's whitespace
197
+ # between the tag_name and the markup of the tag.
198
+ #
199
+ # {%
200
+ # render
201
+ # 'foo'
202
+ # %}
203
+ #
204
+ # Returns a raw value of "render 'foo'\n".
205
+ # The "\n " between render and 'foo' got replaced by a single space.
206
+ #
207
+ # And the line number is the one of 'foo'\n%}. Yay!
208
+ #
209
+ # This breaks any kind of position logic we have since that string
210
+ # does not exist in the template.
211
+ def tag_markup
212
+ return @value.raw if @value.instance_variable_get('@markup').empty?
213
+ return @tag_markup if @tag_markup
214
+
215
+ l = 1
216
+ scanner = StringScanner.new(source)
217
+ scanner.scan_until(/\n/) while l < @value.line_number && (l += 1)
218
+ start = scanner.charpos
219
+
220
+ tag_markup = @value.instance_variable_get('@markup')
221
+
222
+ # See https://github.com/Shopify/theme-check/pull/423/files#r701936559 for a detailed explanation
223
+ # of why we're doing the check below.
224
+ #
225
+ # TL;DR it's because line_numbers are not enough to accurately
226
+ # determine the position of the raw markup and because that
227
+ # markup could be present on the same line outside of a Tag. e.g.
228
+ #
229
+ # uhoh {% if uhoh %}
230
+ if (match = /#{@value.tag_name} +#{Regexp.escape(tag_markup)}/.match(source, start))
231
+ return @tag_markup = match[0]
232
+ end
233
+
234
+ # find the markup
235
+ markup_start = source.index(tag_markup, start)
236
+ markup_end = markup_start + tag_markup.size
237
+
238
+ # go back until you find the tag_name
239
+ tag_start = markup_start
240
+ tag_start -= 1 while source[tag_start - 1] =~ WHITESPACE
241
+ tag_start -= @value.tag_name.size
242
+
243
+ # keep track of the error in line_number
244
+ @line_number_offset = source[tag_start...markup_start].count("\n")
245
+
246
+ # return the real raw content
247
+ @tag_markup = source[tag_start...markup_end]
248
+ end
142
249
  end
143
250
  end