theme-check 0.2.2 → 0.4.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/CHANGELOG.md +40 -0
  4. data/CONTRIBUTING.md +2 -0
  5. data/README.md +45 -4
  6. data/RELEASING.md +41 -0
  7. data/Rakefile +24 -4
  8. data/config/default.yml +16 -0
  9. data/data/shopify_liquid/plus_objects.yml +15 -0
  10. data/data/shopify_liquid/tags.yml +26 -0
  11. data/dev.yml +2 -0
  12. data/lib/theme_check.rb +5 -0
  13. data/lib/theme_check/analyzer.rb +0 -6
  14. data/lib/theme_check/check.rb +11 -0
  15. data/lib/theme_check/checks.rb +10 -0
  16. data/lib/theme_check/checks/missing_enable_comment.rb +31 -0
  17. data/lib/theme_check/checks/parser_blocking_javascript.rb +55 -0
  18. data/lib/theme_check/checks/space_inside_braces.rb +1 -0
  19. data/lib/theme_check/checks/template_length.rb +11 -3
  20. data/lib/theme_check/checks/undefined_object.rb +27 -6
  21. data/lib/theme_check/checks/unused_assign.rb +4 -3
  22. data/lib/theme_check/checks/valid_html_translation.rb +2 -2
  23. data/lib/theme_check/cli.rb +31 -7
  24. data/lib/theme_check/config.rb +95 -43
  25. data/lib/theme_check/corrector.rb +0 -4
  26. data/lib/theme_check/disabled_checks.rb +77 -0
  27. data/lib/theme_check/file_system_storage.rb +51 -0
  28. data/lib/theme_check/in_memory_storage.rb +37 -0
  29. data/lib/theme_check/json_file.rb +12 -10
  30. data/lib/theme_check/language_server.rb +10 -0
  31. data/lib/theme_check/language_server/completion_engine.rb +38 -0
  32. data/lib/theme_check/language_server/completion_helper.rb +35 -0
  33. data/lib/theme_check/language_server/completion_provider.rb +23 -0
  34. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +47 -0
  35. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +31 -0
  36. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +31 -0
  37. data/lib/theme_check/language_server/handler.rb +74 -13
  38. data/lib/theme_check/language_server/position_helper.rb +27 -0
  39. data/lib/theme_check/language_server/protocol.rb +41 -0
  40. data/lib/theme_check/language_server/server.rb +2 -2
  41. data/lib/theme_check/language_server/tokens.rb +55 -0
  42. data/lib/theme_check/offense.rb +51 -14
  43. data/lib/theme_check/shopify_liquid.rb +1 -0
  44. data/lib/theme_check/shopify_liquid/object.rb +6 -0
  45. data/lib/theme_check/shopify_liquid/tag.rb +16 -0
  46. data/lib/theme_check/storage.rb +25 -0
  47. data/lib/theme_check/template.rb +26 -21
  48. data/lib/theme_check/theme.rb +14 -9
  49. data/lib/theme_check/version.rb +1 -1
  50. data/lib/theme_check/visitor.rb +14 -3
  51. data/packaging/homebrew/theme_check.base.rb +10 -6
  52. metadata +22 -2
@@ -11,6 +11,7 @@ module ThemeCheck
11
11
 
12
12
  def on_node(node)
13
13
  return unless node.markup
14
+ return if :assign == node.type_name
14
15
 
15
16
  outside_of_strings(node.markup) do |chunk|
16
17
  chunk.scan(/([,:]) +/) do |_match|
@@ -4,12 +4,20 @@ module ThemeCheck
4
4
  severity :suggestion
5
5
  category :liquid
6
6
 
7
- def initialize(max_length: 200)
7
+ def initialize(max_length: 200, exclude_schema: true)
8
8
  @max_length = max_length
9
+ @exclude_schema = exclude_schema
10
+ @excluded_lines = 0
9
11
  end
10
12
 
11
- def on_document(node)
12
- lines = node.template.source.count("\n")
13
+ def on_schema(node)
14
+ if @exclude_schema
15
+ @excluded_lines += node.value.nodelist.join.count("\n")
16
+ end
17
+ end
18
+
19
+ def after_document(node)
20
+ lines = node.template.source.count("\n") - @excluded_lines
13
21
  if lines > @max_length
14
22
  add_offense("Template has too many lines [#{lines}/#{@max_length}]", template: node.template)
15
23
  end
@@ -49,23 +49,28 @@ module ThemeCheck
49
49
  end
50
50
  end
51
51
 
52
- def initialize
52
+ def initialize(exclude_snippets: false)
53
+ @exclude_snippets = exclude_snippets
53
54
  @files = {}
54
55
  end
55
56
 
56
57
  def on_document(node)
58
+ return if ignore?(node)
57
59
  @files[node.template.name] = TemplateInfo.new
58
60
  end
59
61
 
60
62
  def on_assign(node)
63
+ return if ignore?(node)
61
64
  @files[node.template.name].all_assigns[node.value.to] = node
62
65
  end
63
66
 
64
67
  def on_capture(node)
68
+ return if ignore?(node)
65
69
  @files[node.template.name].all_captures[node.value.instance_variable_get('@to')] = node
66
70
  end
67
71
 
68
72
  def on_for(node)
73
+ return if ignore?(node)
69
74
  @files[node.template.name].all_forloops[node.value.variable_name] = node
70
75
  end
71
76
 
@@ -75,6 +80,7 @@ module ThemeCheck
75
80
  end
76
81
 
77
82
  def on_render(node)
83
+ return if ignore?(node)
78
84
  return unless node.value.template_name_expr.is_a?(String)
79
85
 
80
86
  snippet_name = "snippets/#{node.value.template_name_expr}"
@@ -85,6 +91,7 @@ module ThemeCheck
85
91
  end
86
92
 
87
93
  def on_variable_lookup(node)
94
+ return if ignore?(node)
88
95
  @files[node.template.name].add_variable_lookup(
89
96
  name: node.value.name,
90
97
  node: node,
@@ -95,26 +102,39 @@ module ThemeCheck
95
102
  all_global_objects = ThemeCheck::ShopifyLiquid::Object.labels
96
103
  all_global_objects.freeze
97
104
 
105
+ shopify_plus_objects = ThemeCheck::ShopifyLiquid::Object.plus_labels
106
+ shopify_plus_objects.freeze
107
+
98
108
  each_template do |(name, info)|
99
109
  if 'templates/customers/reset_password' == name
100
110
  # NOTE: `email` is exceptionally exposed as a theme object in
101
111
  # the customers' reset password template
102
112
  check_object(info, all_global_objects + ['email'])
113
+ elsif 'layout/checkout' == name
114
+ # NOTE: Shopify Plus has exceptionally exposed objects in
115
+ # the checkout template
116
+ # https://shopify.dev/docs/themes/theme-templates/checkout-liquid#optional-objects
117
+ check_object(info, all_global_objects + shopify_plus_objects)
103
118
  else
104
119
  check_object(info, all_global_objects)
105
120
  end
106
121
  end
107
122
  end
108
123
 
124
+ private
125
+
126
+ def ignore?(node)
127
+ @exclude_snippets && node.template.snippet?
128
+ end
129
+
109
130
  def each_template
110
131
  @files.each do |(name, info)|
111
132
  next if name.starts_with?('snippets/')
112
133
  yield [name, info]
113
134
  end
114
135
  end
115
- private :each_template
116
136
 
117
- def check_object(info, all_global_objects, render_node = nil)
137
+ def check_object(info, all_global_objects, render_node = nil, visited_snippets = Set.new)
118
138
  check_undefined(info, all_global_objects, render_node)
119
139
 
120
140
  info.each_snippet do |(snippet_name, node)|
@@ -123,10 +143,12 @@ module ThemeCheck
123
143
 
124
144
  snippet_variables = node.value.attributes.keys +
125
145
  Array[node.value.instance_variable_get("@alias_name")]
126
- check_object(snippet_info, all_global_objects + snippet_variables, node)
146
+ unless visited_snippets.include?(snippet_name)
147
+ visited_snippets << snippet_name
148
+ check_object(snippet_info, all_global_objects + snippet_variables, node, visited_snippets)
149
+ end
127
150
  end
128
151
  end
129
- private :check_object
130
152
 
131
153
  def check_undefined(info, all_global_objects, render_node)
132
154
  all_variables = info.all_variables
@@ -146,6 +168,5 @@ module ThemeCheck
146
168
  end
147
169
  end
148
170
  end
149
- private :check_undefined
150
171
  end
151
172
  end
@@ -6,12 +6,13 @@ module ThemeCheck
6
6
  category :liquid
7
7
 
8
8
  class TemplateInfo < Struct.new(:used_assigns, :assign_nodes, :includes)
9
- def collect_used_assigns(templates)
9
+ def collect_used_assigns(templates, visited = Set.new)
10
10
  collected = used_assigns
11
11
  # Check recursively inside included snippets for use
12
12
  includes.each do |name|
13
- if templates[name]
14
- collected += templates[name].collect_used_assigns(templates)
13
+ if templates[name] && !visited.include?(name)
14
+ visited << name
15
+ collected += templates[name].collect_used_assigns(templates, visited)
15
16
  end
16
17
  end
17
18
  collected
@@ -20,7 +20,7 @@ module ThemeCheck
20
20
  keys[-1].end_with?('_html') || pluralized_key.end_with?('_html')
21
21
  end
22
22
 
23
- def parse_and_add_offence(key, value)
23
+ def parse_and_add_offense(key, value)
24
24
  return unless value.is_a?(String)
25
25
 
26
26
  html = Nokogiri::HTML5.fragment(value, max_errors: -1)
@@ -36,7 +36,7 @@ module ThemeCheck
36
36
  visit_nested(v, keys + [k])
37
37
  end
38
38
  elsif html_key?(keys)
39
- parse_and_add_offence(keys.join('.'), value)
39
+ parse_and_add_offense(keys.join('.'), value)
40
40
  end
41
41
  end
42
42
  end
@@ -11,7 +11,9 @@ module ThemeCheck
11
11
  -x, [--exclude-category] # Exclude this category of checks
12
12
  -l, [--list] # List enabled checks
13
13
  -a, [--auto-correct] # Automatically fix offenses
14
+ --init # Generate a .theme-check.yml file in the current directory
14
15
  -h, [--help] # Show this. Hi!
16
+ -v, [--version] # Print Theme Check version
15
17
 
16
18
  Description:
17
19
  Theme Check helps you follow Shopify Themes & Liquid best practices by analyzing the
@@ -21,7 +23,7 @@ module ThemeCheck
21
23
  END
22
24
 
23
25
  def run(argv)
24
- path = "."
26
+ @path = "."
25
27
 
26
28
  command = :check
27
29
  only_categories = []
@@ -33,6 +35,8 @@ module ThemeCheck
33
35
  case arg
34
36
  when "--help", "-h"
35
37
  raise Abort, USAGE
38
+ when "--version", "-v"
39
+ command = :version
36
40
  when "--category", "-c"
37
41
  only_categories << args.shift.to_sym
38
42
  when "--exclude-category", "-x"
@@ -41,15 +45,19 @@ module ThemeCheck
41
45
  command = :list
42
46
  when "--auto-correct", "-a"
43
47
  auto_correct = true
48
+ when "--init"
49
+ command = :init
44
50
  else
45
- path = arg
51
+ @path = arg
46
52
  end
47
53
  end
48
54
 
49
- @config = ThemeCheck::Config.from_path(path)
50
- @config.only_categories = only_categories
51
- @config.exclude_categories = exclude_categories
52
- @config.auto_correct = auto_correct
55
+ unless [:version, :init].include?(command)
56
+ @config = ThemeCheck::Config.from_path(@path)
57
+ @config.only_categories = only_categories
58
+ @config.exclude_categories = exclude_categories
59
+ @config.auto_correct = auto_correct
60
+ end
53
61
 
54
62
  send(command)
55
63
  end
@@ -68,9 +76,25 @@ module ThemeCheck
68
76
  puts @config.enabled_checks
69
77
  end
70
78
 
79
+ def version
80
+ puts ThemeCheck::VERSION
81
+ end
82
+
83
+ def init
84
+ dotfile_path = ThemeCheck::Config.find(@path)
85
+ if dotfile_path.nil?
86
+ File.write(File.join(@path, ThemeCheck::Config::DOTFILE), File.read(ThemeCheck::Config::DEFAULT_CONFIG))
87
+
88
+ puts "Writing new #{ThemeCheck::Config::DOTFILE} to #{@path}"
89
+ else
90
+ raise Abort, "#{ThemeCheck::Config::DOTFILE} already exists at #{@path}."
91
+ end
92
+ end
93
+
71
94
  def check
72
95
  puts "Checking #{@config.root} ..."
73
- theme = ThemeCheck::Theme.new(@config.root)
96
+ storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
97
+ theme = ThemeCheck::Theme.new(storage)
74
98
  if theme.all.empty?
75
99
  raise Abort, "No templates found.\n#{USAGE}"
76
100
  end
@@ -4,6 +4,7 @@ module ThemeCheck
4
4
  class Config
5
5
  DOTFILE = '.theme-check.yml'
6
6
  DEFAULT_CONFIG = "#{__dir__}/../../config/default.yml"
7
+ BOOLEAN = [true, false]
7
8
 
8
9
  attr_reader :root
9
10
  attr_accessor :only_categories, :exclude_categories, :auto_correct
@@ -11,13 +12,21 @@ module ThemeCheck
11
12
  class << self
12
13
  def from_path(path)
13
14
  if (filename = find(path))
14
- new(filename.dirname, load_file(filename))
15
+ new(root: filename.dirname, configuration: load_file(filename))
15
16
  else
16
17
  # No configuration file
17
- new(path)
18
+ new(root: path)
18
19
  end
19
20
  end
20
21
 
22
+ def from_string(config)
23
+ new(configuration: YAML.load(config), should_resolve_requires: false)
24
+ end
25
+
26
+ def from_hash(config)
27
+ new(configuration: config, should_resolve_requires: false)
28
+ end
29
+
21
30
  def find(root, needle = DOTFILE)
22
31
  Pathname.new(root).descend.reverse_each do |path|
23
32
  pathname = path.join(needle)
@@ -29,81 +38,124 @@ module ThemeCheck
29
38
  def load_file(absolute_path)
30
39
  YAML.load_file(absolute_path)
31
40
  end
41
+
42
+ def default
43
+ @default ||= load_file(DEFAULT_CONFIG)
44
+ end
32
45
  end
33
46
 
34
- def initialize(root, configuration = nil)
35
- @configuration = configuration || {}
36
- @checks = @configuration.dup
37
- @root = Pathname.new(root)
38
- if @checks.key?("root")
39
- @root = @root.join(@checks.delete("root"))
47
+ def initialize(root: nil, configuration: nil, should_resolve_requires: true)
48
+ @configuration = if configuration
49
+ validate_configuration(configuration)
50
+ else
51
+ {}
52
+ end
53
+ merge_with_default_configuration!(@configuration)
54
+
55
+ @root = if root && @configuration.key?("root")
56
+ Pathname.new(root).join(@configuration["root"])
57
+ elsif root
58
+ Pathname.new(root)
40
59
  end
60
+
41
61
  @only_categories = []
42
62
  @exclude_categories = []
43
63
  @auto_correct = false
44
- resolve_requires
64
+
65
+ resolve_requires if @root && should_resolve_requires
66
+ end
67
+
68
+ def [](name)
69
+ @configuration[name]
45
70
  end
46
71
 
47
72
  def to_h
48
73
  @configuration
49
74
  end
50
75
 
51
- def enabled_checks
52
- checks = []
53
-
54
- default_configuration.merge(@checks).each do |check_name, properties|
55
- if @checks[check_name] && !default_configuration[check_name].nil?
56
- valid_properties = valid_check_configuration(check_name)
57
- properties = properties.merge(valid_properties)
58
- end
76
+ def check_configurations
77
+ @check_configurations ||= @configuration.select { |name, _| check_name?(name) }
78
+ end
59
79
 
60
- next if properties.delete('enabled') == false
80
+ def enabled_checks
81
+ @enabled_checks ||= check_configurations.map do |check_name, options|
82
+ next unless options["enabled"]
61
83
 
62
- options = properties.transform_keys(&:to_sym)
63
84
  check_class = ThemeCheck.const_get(check_name)
85
+
64
86
  next if exclude_categories.include?(check_class.category)
65
87
  next if only_categories.any? && !only_categories.include?(check_class.category)
66
88
 
67
- check = check_class.new(**options)
68
- check.options = options
69
- checks << check
70
- end
89
+ options_for_check = options.transform_keys(&:to_sym)
90
+ options_for_check.delete(:enabled)
91
+ check = check_class.new(**options_for_check)
92
+ check.options = options_for_check
93
+ check
94
+ end.compact
95
+ end
71
96
 
72
- checks
97
+ def ignored_patterns
98
+ self["ignore"] || []
73
99
  end
74
100
 
75
101
  private
76
102
 
77
- def default_configuration
78
- @default_configuration ||= Config.load_file(DEFAULT_CONFIG)
103
+ def check_name?(name)
104
+ name.start_with?(/[A-Z]/)
79
105
  end
80
106
 
81
- def resolve_requires
82
- if @checks.key?("require")
83
- @checks.delete("require").tap do |paths|
84
- paths.each do |path|
85
- if path.start_with?('.')
86
- require(File.join(@root, path))
87
- end
107
+ def validate_configuration(configuration, default_configuration = self.class.default, parent_keys = [])
108
+ valid_configuration = {}
109
+
110
+ configuration.each do |key, value|
111
+ # No validation possible unless we have a default to compare to
112
+ unless default_configuration
113
+ valid_configuration[key] = value
114
+ next
115
+ end
116
+
117
+ default = default_configuration[key]
118
+ keys = parent_keys + [key]
119
+ name = keys.join(".")
120
+
121
+ if check_name?(key)
122
+ if value.is_a?(Hash)
123
+ valid_configuration[key] = validate_configuration(value, default, keys)
124
+ else
125
+ warn("bad configuration type for #{name}: expected a Hash, got #{value.inspect}")
88
126
  end
127
+ elsif default.nil?
128
+ warn("unknown configuration: #{name}")
129
+ elsif BOOLEAN.include?(default) && !BOOLEAN.include?(value)
130
+ warn("bad configuration type for #{name}: expected true or false, got #{value.inspect}")
131
+ elsif !BOOLEAN.include?(default) && default.class != value.class
132
+ warn("bad configuration type for #{name}: expected a #{default.class}, got #{value.inspect}")
133
+ else
134
+ valid_configuration[key] = value
89
135
  end
90
136
  end
91
- end
92
137
 
93
- def valid_check_configuration(check_name)
94
- default_properties = default_configuration[check_name]
138
+ valid_configuration
139
+ end
95
140
 
96
- valid = {}
141
+ def merge_with_default_configuration!(configuration, default_configuration = self.class.default)
142
+ default_configuration.each do |key, default|
143
+ value = configuration[key]
97
144
 
98
- @checks[check_name].each do |property, value|
99
- if !default_properties.key?(property)
100
- warn("#{check_name} does not support #{property} parameter.")
101
- else
102
- valid[property] = value
145
+ case value
146
+ when Hash
147
+ merge_with_default_configuration!(value, default)
148
+ when nil
149
+ configuration[key] = default
103
150
  end
104
151
  end
152
+ configuration
153
+ end
105
154
 
106
- valid
155
+ def resolve_requires
156
+ self["require"]&.each do |path|
157
+ require(File.join(@root, path))
158
+ end
107
159
  end
108
160
  end
109
161
  end