theme-check 0.7.3 → 0.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +4 -0
  3. data/.rubocop.yml +1 -1
  4. data/CHANGELOG.md +43 -0
  5. data/CONTRIBUTING.md +2 -1
  6. data/README.md +4 -1
  7. data/RELEASING.md +5 -3
  8. data/config/default.yml +38 -1
  9. data/data/shopify_liquid/tags.yml +3 -0
  10. data/data/shopify_translation_keys.yml +1 -0
  11. data/dev.yml +1 -1
  12. data/docs/checks/content_for_header_modification.md +42 -0
  13. data/docs/checks/nested_snippet.md +1 -1
  14. data/docs/checks/parser_blocking_script_tag.md +53 -0
  15. data/docs/checks/space_inside_braces.md +28 -0
  16. data/exe/theme-check-language-server +1 -2
  17. data/lib/theme_check.rb +13 -1
  18. data/lib/theme_check/analyzer.rb +79 -13
  19. data/lib/theme_check/bug.rb +20 -0
  20. data/lib/theme_check/check.rb +36 -7
  21. data/lib/theme_check/checks.rb +47 -8
  22. data/lib/theme_check/checks/content_for_header_modification.rb +41 -0
  23. data/lib/theme_check/checks/img_width_and_height.rb +18 -49
  24. data/lib/theme_check/checks/missing_enable_comment.rb +4 -4
  25. data/lib/theme_check/checks/missing_template.rb +1 -0
  26. data/lib/theme_check/checks/nested_snippet.rb +1 -1
  27. data/lib/theme_check/checks/parser_blocking_javascript.rb +6 -38
  28. data/lib/theme_check/checks/parser_blocking_script_tag.rb +20 -0
  29. data/lib/theme_check/checks/space_inside_braces.rb +8 -2
  30. data/lib/theme_check/checks/template_length.rb +3 -0
  31. data/lib/theme_check/checks/valid_html_translation.rb +1 -0
  32. data/lib/theme_check/cli.rb +1 -1
  33. data/lib/theme_check/config.rb +8 -2
  34. data/lib/theme_check/disabled_check.rb +41 -0
  35. data/lib/theme_check/disabled_checks.rb +33 -29
  36. data/lib/theme_check/exceptions.rb +32 -0
  37. data/lib/theme_check/html_check.rb +7 -0
  38. data/lib/theme_check/html_node.rb +52 -0
  39. data/lib/theme_check/html_visitor.rb +36 -0
  40. data/lib/theme_check/json_file.rb +13 -1
  41. data/lib/theme_check/language_server.rb +2 -1
  42. data/lib/theme_check/language_server/completion_engine.rb +1 -1
  43. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +1 -0
  44. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +10 -8
  45. data/lib/theme_check/language_server/constants.rb +5 -1
  46. data/lib/theme_check/language_server/diagnostics_tracker.rb +64 -0
  47. data/lib/theme_check/language_server/document_link_engine.rb +2 -2
  48. data/lib/theme_check/language_server/handler.rb +63 -50
  49. data/lib/theme_check/language_server/server.rb +1 -1
  50. data/lib/theme_check/language_server/variable_lookup_finder.rb +295 -0
  51. data/lib/theme_check/liquid_check.rb +0 -4
  52. data/lib/theme_check/node.rb +12 -0
  53. data/lib/theme_check/offense.rb +30 -46
  54. data/lib/theme_check/parsing_helpers.rb +1 -1
  55. data/lib/theme_check/position.rb +77 -0
  56. data/lib/theme_check/position_helper.rb +37 -0
  57. data/lib/theme_check/remote_asset_file.rb +3 -0
  58. data/lib/theme_check/shopify_liquid/tag.rb +13 -0
  59. data/lib/theme_check/template.rb +8 -0
  60. data/lib/theme_check/theme.rb +7 -2
  61. data/lib/theme_check/version.rb +1 -1
  62. data/lib/theme_check/visitor.rb +4 -14
  63. data/theme-check.gemspec +2 -0
  64. metadata +18 -5
  65. data/lib/theme_check/language_server/position_helper.rb +0 -27
@@ -17,13 +17,13 @@ module ThemeCheck
17
17
  end
18
18
 
19
19
  def after_document(node)
20
- return if @disabled_checks.full_document_disabled?
21
- return unless @disabled_checks.any?
20
+ checks_missing_end_index = @disabled_checks.checks_missing_end_index
21
+ return if checks_missing_end_index.empty?
22
22
 
23
- message = if @disabled_checks.all_disabled?
23
+ message = if checks_missing_end_index.any? { |name| name == :all }
24
24
  "All checks were"
25
25
  else
26
- @disabled_checks.all.join(', ') + " " + (@disabled_checks.all.size == 1 ? "was" : "were")
26
+ checks_missing_end_index.join(', ') + " " + (checks_missing_end_index.size == 1 ? "was" : "were")
27
27
  end
28
28
 
29
29
  add_offense("#{message} disabled but not re-enabled with theme-check-enable", node: node)
@@ -5,6 +5,7 @@ module ThemeCheck
5
5
  severity :suggestion
6
6
  category :liquid
7
7
  doc docs_url(__FILE__)
8
+ single_file false
8
9
 
9
10
  def on_include(node)
10
11
  template = node.value.template_name_expr
@@ -20,7 +20,7 @@ module ThemeCheck
20
20
  end
21
21
  end
22
22
 
23
- def initialize(max_nesting_level: 2)
23
+ def initialize(max_nesting_level: 3)
24
24
  @max_nesting_level = max_nesting_level
25
25
  @templates = {}
26
26
  end
@@ -1,48 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
3
  # Reports errors when trying to use parser-blocking script tags
4
- class ParserBlockingJavaScript < LiquidCheck
5
- include RegexHelpers
4
+ class ParserBlockingJavaScript < HtmlCheck
6
5
  severity :error
7
- categories :liquid, :performance
6
+ categories :html, :performance
8
7
  doc docs_url(__FILE__)
9
8
 
10
- PARSER_BLOCKING_SCRIPT_TAG = %r{
11
- <script # Find the start of a script tag
12
- (?=[^>]+?src=) # Make sure src= is in the script with a lookahead
13
- (?:(?!defer|async|type=["']module['"]).)*? # Find tags that don't have defer|async|type="module"
14
- /?>
15
- }xim
16
- SCRIPT_TAG_FILTER = /\{\{[^}]+script_tag\s+\}\}/
9
+ def on_script(node)
10
+ return unless node.attributes["src"]
11
+ return if node.attributes["defer"] || node.attributes["async"] || node.attributes["type"]&.value == "module"
17
12
 
18
- def on_document(node)
19
- @source = node.template.source
20
- @node = node
21
- record_offenses
22
- end
23
-
24
- private
25
-
26
- def record_offenses
27
- record_offenses_from_regex(
28
- message: "Missing async or defer attribute on script tag",
29
- regex: PARSER_BLOCKING_SCRIPT_TAG,
30
- )
31
- record_offenses_from_regex(
32
- message: "The script_tag filter is parser-blocking. Use a script tag with the async or defer attribute for better performance",
33
- regex: SCRIPT_TAG_FILTER,
34
- )
35
- end
36
-
37
- def record_offenses_from_regex(regex: nil, message: nil)
38
- matches(@source, regex).each do |match|
39
- add_offense(
40
- message,
41
- node: @node,
42
- markup: match[0],
43
- line_number: @source[0...match.begin(0)].count("\n") + 1
44
- )
45
- end
13
+ add_offense("Missing async or defer attribute on script tag", node: node)
46
14
  end
47
15
  end
48
16
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports errors when trying to use parser-blocking script tags
4
+ class ParserBlockingScriptTag < LiquidCheck
5
+ severity :error
6
+ categories :liquid, :performance
7
+ doc docs_url(__FILE__)
8
+
9
+ def on_variable(node)
10
+ used_filters = node.value.filters.map { |name, *_rest| name }
11
+ if used_filters.include?("script_tag")
12
+ add_offense(
13
+ "The script_tag filter is parser-blocking. Use a script tag with the async or defer " \
14
+ "attribute for better performance",
15
+ node: node
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
@@ -15,12 +15,18 @@ module ThemeCheck
15
15
  return if :assign == node.type_name
16
16
 
17
17
  outside_of_strings(node.markup) do |chunk|
18
- chunk.scan(/([,:]) +/) do |_match|
18
+ chunk.scan(/([,:|]|==|<>|<=|>=|<|>|!=) +/) do |_match|
19
19
  add_offense("Too many spaces after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
20
20
  end
21
- chunk.scan(/([,:])\S/) do |_match|
21
+ chunk.scan(/([,:|]|==|<>|<=|>=|<\b|>\b|!=)(\S|\z)/) do |_match|
22
22
  add_offense("Space missing after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
23
23
  end
24
+ chunk.scan(/ (\||==|<>|<=|>=|<|>|!=)+/) do |_match|
25
+ add_offense("Too many spaces before '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
26
+ end
27
+ chunk.scan(/(\A|\S)(?<match>\||==|<>|<=|>=|<|\b>|!=)/) do |_match|
28
+ add_offense("Space missing before '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
29
+ end
24
30
  end
25
31
  end
26
32
 
@@ -8,6 +8,9 @@ module ThemeCheck
8
8
  def initialize(max_length: 200, exclude_schema: true)
9
9
  @max_length = max_length
10
10
  @exclude_schema = exclude_schema
11
+ end
12
+
13
+ def on_document(_node)
11
14
  @excluded_lines = 0
12
15
  end
13
16
 
@@ -5,6 +5,7 @@ require 'nokogumbo'
5
5
  module ThemeCheck
6
6
  class ValidHTMLTranslation < JsonCheck
7
7
  severity :suggestion
8
+ category :translation
8
9
  doc docs_url(__FILE__)
9
10
 
10
11
  def on_file(file)
@@ -17,7 +17,7 @@ module ThemeCheck
17
17
  end
18
18
 
19
19
  def option_parser(parser = OptionParser.new, help: true)
20
- return @option_parser if @option_parser
20
+ return @option_parser if defined?(@option_parser)
21
21
  @option_parser = parser
22
22
  @option_parser.banner = "Usage: theme-check [options] [/path/to/your/theme]"
23
23
 
@@ -91,7 +91,13 @@ module ThemeCheck
91
91
 
92
92
  options_for_check = options.transform_keys(&:to_sym)
93
93
  options_for_check.delete(:enabled)
94
- check = check_class.new(**options_for_check)
94
+ ignored_patterns = options_for_check.delete(:ignore) || []
95
+ check = if options_for_check.empty?
96
+ check_class.new
97
+ else
98
+ check_class.new(**options_for_check)
99
+ end
100
+ check.ignored_patterns = ignored_patterns
95
101
  check.options = options_for_check
96
102
  check
97
103
  end.compact
@@ -104,7 +110,7 @@ module ThemeCheck
104
110
  private
105
111
 
106
112
  def check_name?(name)
107
- name.start_with?(/[A-Z]/)
113
+ name.to_s.start_with?(/[A-Z]/)
108
114
  end
109
115
 
110
116
  def validate_configuration(configuration, default_configuration = self.class.default, parent_keys = [])
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class keeps track of checks being turned on and off in ranges.
4
+ # We'll use the node position to figure out if the test is disabled or not.
5
+ module ThemeCheck
6
+ class DisabledCheck
7
+ attr_reader :name, :template, :ranges
8
+ attr_accessor :first_line
9
+
10
+ def initialize(template, name)
11
+ @template = template
12
+ @name = name
13
+ @ranges = []
14
+ @first_line = false
15
+ end
16
+
17
+ def start_index=(index)
18
+ return unless ranges.empty? || !last.end.nil?
19
+ @ranges << (index..)
20
+ end
21
+
22
+ def end_index=(index)
23
+ return if ranges.empty? || !last.end.nil?
24
+ @ranges << (@ranges.pop.begin..index)
25
+ end
26
+
27
+ def disabled?(index)
28
+ index == 0 && first_line ||
29
+ ranges.any? { |range| range.cover?(index) }
30
+ end
31
+
32
+ def last
33
+ ranges.last
34
+ end
35
+
36
+ def missing_end_index?
37
+ return false if first_line && ranges.size == 1
38
+ last&.end.nil?
39
+ end
40
+ end
41
+ end
@@ -8,50 +8,52 @@ module ThemeCheck
8
8
 
9
9
  ACTION_DISABLE_CHECKS = :disable
10
10
  ACTION_ENABLE_CHECKS = :enable
11
- ACTION_UNRELATED_COMMENT = :unrelated
12
11
 
13
12
  def initialize
14
- @disabled = []
15
- @all_disabled = false
16
- @full_document_disabled = false
13
+ @disabled_checks = Hash.new do |hash, key|
14
+ template, check_name = key
15
+ hash[key] = DisabledCheck.new(template, check_name)
16
+ end
17
17
  end
18
18
 
19
19
  def update(node)
20
20
  text = comment_text(node)
21
-
22
21
  if start_disabling?(text)
23
- @disabled = checks_from_text(text)
24
- @all_disabled = @disabled.empty?
25
-
26
- if node&.line_number == 1
27
- @full_document_disabled = true
22
+ checks_from_text(text).each do |check_name|
23
+ disabled = @disabled_checks[[node.template, check_name]]
24
+ disabled.start_index = node.start_index
25
+ disabled.first_line = true if node.line_number == 1
28
26
  end
29
27
  elsif stop_disabling?(text)
30
- checks = checks_from_text(text)
31
- @disabled = checks.empty? ? [] : @disabled - checks
32
-
33
- @all_disabled = false
28
+ checks_from_text(text).each do |check_name|
29
+ disabled = @disabled_checks[[node.template, check_name]]
30
+ next unless disabled
31
+ disabled.end_index = node.end_index
32
+ end
34
33
  end
35
34
  end
36
35
 
37
- # Whether any checks are currently disabled
38
- def any?
39
- !@disabled.empty? || @all_disabled
40
- end
36
+ def disabled?(check, template, check_name, index)
37
+ return true if check.ignored_patterns&.any? do |pattern|
38
+ template.relative_path.fnmatch?(pattern)
39
+ end
41
40
 
42
- # Whether all checks should be disabled
43
- def all_disabled?
44
- @all_disabled
41
+ @disabled_checks[[template, :all]]&.disabled?(index) ||
42
+ @disabled_checks[[template, check_name]]&.disabled?(index)
45
43
  end
46
44
 
47
- # Get a list of all the individual disabled checks
48
- def all
49
- @disabled
45
+ def checks_missing_end_index
46
+ @disabled_checks.values
47
+ .select(&:missing_end_index?)
48
+ .map(&:name)
50
49
  end
51
50
 
52
- # If the first line of the document is a theme-check-disable comment
53
- def full_document_disabled?
54
- @full_document_disabled
51
+ def remove_disabled_offenses(checks)
52
+ checks.disableable.each do |check|
53
+ check.offenses.reject! do |offense|
54
+ disabled?(check, offense.template, offense.code_name, offense.start_index)
55
+ end
56
+ end
55
57
  end
56
58
 
57
59
  private
@@ -69,9 +71,11 @@ module ThemeCheck
69
71
  end
70
72
 
71
73
  # Return a list of checks from a theme-check-disable comment
72
- # Returns [] if all checks are meant to be disabled
74
+ # Returns [:all] if all checks are meant to be disabled
73
75
  def checks_from_text(text)
74
- text.gsub(DISABLE_PREFIX_PATTERN, '').strip.split(',').map(&:strip)
76
+ checks = text.gsub(DISABLE_PREFIX_PATTERN, '').strip.split(',').map(&:strip)
77
+ return [:all] if checks.empty?
78
+ checks
75
79
  end
76
80
  end
77
81
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ require "net/http"
3
+
4
+ TIMEOUT_EXCEPTIONS = [
5
+ Net::ReadTimeout,
6
+ Net::OpenTimeout,
7
+ Net::WriteTimeout,
8
+ Errno::ETIMEDOUT,
9
+ Timeout::Error,
10
+ ]
11
+
12
+ CONNECTION_EXCEPTIONS = [
13
+ IOError,
14
+ EOFError,
15
+ SocketError,
16
+ Errno::EINVAL,
17
+ Errno::ECONNRESET,
18
+ Errno::ECONNABORTED,
19
+ Errno::EPIPE,
20
+ Errno::ECONNREFUSED,
21
+ Errno::EAGAIN,
22
+ Errno::EHOSTUNREACH,
23
+ Errno::ENETUNREACH,
24
+ ]
25
+
26
+ NET_HTTP_EXCEPTIONS = [
27
+ Net::HTTPBadResponse,
28
+ Net::HTTPHeaderSyntaxError,
29
+ Net::ProtocolError,
30
+ *TIMEOUT_EXCEPTIONS,
31
+ *CONNECTION_EXCEPTIONS,
32
+ ]
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class HtmlCheck < Check
5
+ extend ChecksTracking
6
+ end
7
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ require "forwardable"
3
+
4
+ module ThemeCheck
5
+ class HtmlNode
6
+ extend Forwardable
7
+ attr_reader :template
8
+
9
+ def_delegators :@value, :content, :attributes
10
+
11
+ def initialize(value, template)
12
+ @value = value
13
+ @template = template
14
+ end
15
+
16
+ def literal?
17
+ @value.name == "text"
18
+ end
19
+
20
+ def children
21
+ @value.children.map { |child| HtmlNode.new(child, template) }
22
+ end
23
+
24
+ def parent
25
+ HtmlNode.new(@value.parent, template)
26
+ end
27
+
28
+ def name
29
+ if @value.name == "#document-fragment"
30
+ "document"
31
+ else
32
+ @value.name
33
+ end
34
+ end
35
+
36
+ def value
37
+ if literal?
38
+ @value.content
39
+ else
40
+ @value
41
+ end
42
+ end
43
+
44
+ def markup
45
+ @value.to_html
46
+ end
47
+
48
+ def line_number
49
+ @value.line
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ require "nokogumbo"
3
+ require "forwardable"
4
+
5
+ module ThemeCheck
6
+ class HtmlVisitor
7
+ attr_reader :checks
8
+
9
+ def initialize(checks)
10
+ @checks = checks
11
+ end
12
+
13
+ def visit_template(template)
14
+ doc = parse(template)
15
+ visit(HtmlNode.new(doc, template))
16
+ end
17
+
18
+ private
19
+
20
+ def parse(template)
21
+ Nokogiri::HTML5.fragment(template.source)
22
+ end
23
+
24
+ def visit(node)
25
+ call_checks(:"on_#{node.name}", node)
26
+ node.children.each { |child| visit(child) }
27
+ unless node.literal?
28
+ call_checks(:"after_#{node.name}", node)
29
+ end
30
+ end
31
+
32
+ def call_checks(method, *args)
33
+ checks.call(method, *args)
34
+ end
35
+ end
36
+ end