theme-check 0.1.0 → 0.3.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CONTRIBUTING.md +2 -0
  4. data/README.md +62 -1
  5. data/RELEASING.md +41 -0
  6. data/Rakefile +38 -4
  7. data/config/default.yml +18 -0
  8. data/data/shopify_liquid/deprecated_filters.yml +10 -0
  9. data/data/shopify_liquid/plus_objects.yml +15 -0
  10. data/dev.yml +2 -0
  11. data/lib/theme_check.rb +5 -0
  12. data/lib/theme_check/analyzer.rb +15 -5
  13. data/lib/theme_check/check.rb +11 -0
  14. data/lib/theme_check/checks.rb +10 -0
  15. data/lib/theme_check/checks/deprecated_filter.rb +22 -0
  16. data/lib/theme_check/checks/missing_enable_comment.rb +31 -0
  17. data/lib/theme_check/checks/missing_required_template_files.rb +12 -12
  18. data/lib/theme_check/checks/parser_blocking_javascript.rb +55 -0
  19. data/lib/theme_check/checks/space_inside_braces.rb +19 -7
  20. data/lib/theme_check/checks/template_length.rb +11 -3
  21. data/lib/theme_check/checks/undefined_object.rb +107 -34
  22. data/lib/theme_check/checks/valid_html_translation.rb +2 -2
  23. data/lib/theme_check/cli.rb +18 -4
  24. data/lib/theme_check/config.rb +97 -44
  25. data/lib/theme_check/corrector.rb +31 -0
  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/handler.rb +38 -13
  31. data/lib/theme_check/language_server/server.rb +2 -2
  32. data/lib/theme_check/liquid_check.rb +2 -2
  33. data/lib/theme_check/node.rb +13 -0
  34. data/lib/theme_check/offense.rb +25 -9
  35. data/lib/theme_check/packager.rb +51 -0
  36. data/lib/theme_check/printer.rb +13 -4
  37. data/lib/theme_check/shopify_liquid.rb +1 -0
  38. data/lib/theme_check/shopify_liquid/deprecated_filter.rb +28 -0
  39. data/lib/theme_check/shopify_liquid/object.rb +6 -0
  40. data/lib/theme_check/storage.rb +25 -0
  41. data/lib/theme_check/template.rb +32 -10
  42. data/lib/theme_check/theme.rb +14 -9
  43. data/lib/theme_check/version.rb +1 -1
  44. data/lib/theme_check/visitor.rb +14 -3
  45. data/packaging/homebrew/theme_check.base.rb +98 -0
  46. metadata +21 -7
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class Corrector
5
+ def initialize(template:)
6
+ @template = template
7
+ end
8
+
9
+ def insert_after(node, content)
10
+ line = @template.full_line(node.line_number)
11
+ line.insert(node.range[1] + 1, content)
12
+ end
13
+
14
+ def insert_before(node, content)
15
+ line = @template.full_line(node.line_number)
16
+ line.insert(node.range[0], content)
17
+ end
18
+
19
+ def replace(node, content)
20
+ line = @template.full_line(node.line_number)
21
+ line[node.range[0]..node.range[1]] = content
22
+ node.markup = content
23
+ end
24
+
25
+ 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)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class DisabledChecks
5
+ DISABLE_START = 'theme-check-disable'
6
+ DISABLE_END = 'theme-check-enable'
7
+ DISABLE_PREFIX_PATTERN = /#{DISABLE_START}|#{DISABLE_END}/
8
+
9
+ ACTION_DISABLE_CHECKS = :disable
10
+ ACTION_ENABLE_CHECKS = :enable
11
+ ACTION_UNRELATED_COMMENT = :unrelated
12
+
13
+ def initialize
14
+ @disabled = []
15
+ @all_disabled = false
16
+ @full_document_disabled = false
17
+ end
18
+
19
+ def update(node)
20
+ text = comment_text(node)
21
+
22
+ 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
28
+ end
29
+ elsif stop_disabling?(text)
30
+ checks = checks_from_text(text)
31
+ @disabled = checks.empty? ? [] : @disabled - checks
32
+
33
+ @all_disabled = false
34
+ end
35
+ end
36
+
37
+ # Whether any checks are currently disabled
38
+ def any?
39
+ !@disabled.empty? || @all_disabled
40
+ end
41
+
42
+ # Whether all checks should be disabled
43
+ def all_disabled?
44
+ @all_disabled
45
+ end
46
+
47
+ # Get a list of all the individual disabled checks
48
+ def all
49
+ @disabled
50
+ end
51
+
52
+ # If the first line of the document is a theme-check-disable comment
53
+ def full_document_disabled?
54
+ @full_document_disabled
55
+ end
56
+
57
+ private
58
+
59
+ def comment_text(node)
60
+ node.value.nodelist.join
61
+ end
62
+
63
+ def start_disabling?(text)
64
+ text.strip.starts_with?(DISABLE_START)
65
+ end
66
+
67
+ def stop_disabling?(text)
68
+ text.strip.starts_with?(DISABLE_END)
69
+ end
70
+
71
+ # Return a list of checks from a theme-check-disable comment
72
+ # Returns [] if all checks are meant to be disabled
73
+ def checks_from_text(text)
74
+ text.gsub(DISABLE_PREFIX_PATTERN, '').strip.split(',').map(&:strip)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ require "pathname"
3
+
4
+ module ThemeCheck
5
+ class FileSystemStorage < Storage
6
+ attr_reader :root
7
+
8
+ def initialize(root, ignored_patterns: [])
9
+ @root = Pathname.new(root)
10
+ @ignored_patterns = ignored_patterns
11
+ @files = {}
12
+ end
13
+
14
+ def path(relative_path)
15
+ @root.join(relative_path)
16
+ end
17
+
18
+ def read(relative_path)
19
+ file(relative_path).read
20
+ end
21
+
22
+ def write(relative_path, content)
23
+ file(relative_path).write(content)
24
+ end
25
+
26
+ def files
27
+ @file_array ||= glob("**/*")
28
+ .map { |path| path.relative_path_from(@root).to_s }
29
+ end
30
+
31
+ def directories
32
+ @directories ||= glob('*')
33
+ .select { |f| File.directory?(f) }
34
+ .map { |f| f.relative_path_from(@root).to_s }
35
+ end
36
+
37
+ private
38
+
39
+ def glob(pattern)
40
+ @root.glob(pattern).reject do |path|
41
+ relative_path = path.relative_path_from(@root)
42
+ @ignored_patterns.any? { |ignored| relative_path.fnmatch?(ignored) }
43
+ end
44
+ end
45
+
46
+ def file(name)
47
+ return @files[name] if @files[name]
48
+ @files[name] = root.join(name)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An in-memory storage is not written to disk. The reasons why you'd
4
+ # want to do that are your own. The idea is to not write to disk
5
+ # something that doesn't need to be there. If you have your template
6
+ # as a big hash already, leave it like that and save yourself some IO.
7
+ module ThemeCheck
8
+ class InMemoryStorage < Storage
9
+ def initialize(files)
10
+ @files = files
11
+ end
12
+
13
+ def path(name)
14
+ name
15
+ end
16
+
17
+ def read(name)
18
+ @files[name]
19
+ end
20
+
21
+ def write(name, content)
22
+ @files[name] = content
23
+ end
24
+
25
+ def files
26
+ @values ||= @files.keys
27
+ end
28
+
29
+ def directories
30
+ @directories ||= @files
31
+ .keys
32
+ .flat_map { |relative_path| Pathname.new(relative_path).ascend.to_a }
33
+ .map(&:to_s)
34
+ .uniq
35
+ end
36
+ end
37
+ end
@@ -4,22 +4,20 @@ require "pathname"
4
4
 
5
5
  module ThemeCheck
6
6
  class JsonFile
7
- attr_reader :path
8
-
9
- def initialize(path, root)
10
- @path = Pathname(path)
11
- @root = Pathname(root)
7
+ def initialize(relative_path, storage)
8
+ @relative_path = relative_path
9
+ @storage = storage
12
10
  @loaded = false
13
11
  @content = nil
14
12
  @parser_error = nil
15
13
  end
16
14
 
17
- def relative_path
18
- @path.relative_path_from(@root)
15
+ def path
16
+ @storage.path(@relative_path)
19
17
  end
20
18
 
21
- def name
22
- relative_path.sub_ext('').to_s
19
+ def relative_path
20
+ @relative_pathname ||= Pathname.new(@relative_path)
23
21
  end
24
22
 
25
23
  def content
@@ -32,12 +30,16 @@ module ThemeCheck
32
30
  @parser_error
33
31
  end
34
32
 
33
+ def name
34
+ relative_path.sub_ext('').to_s
35
+ end
36
+
35
37
  private
36
38
 
37
39
  def load!
38
40
  return if @loaded
39
41
 
40
- @content = JSON.parse(File.read(@path))
42
+ @content = JSON.parse(@storage.read(@relative_path))
41
43
  rescue JSON::ParserError => e
42
44
  @parser_error = e
43
45
  ensure
@@ -14,6 +14,7 @@ module ThemeCheck
14
14
 
15
15
  def initialize(server)
16
16
  @server = server
17
+ @previously_reported_files = Set.new
17
18
  end
18
19
 
19
20
  def on_initialize(id, params)
@@ -41,27 +42,51 @@ module ThemeCheck
41
42
  def analyze_and_send_offenses(file_path)
42
43
  root = ThemeCheck::Config.find(file_path) || @root_path
43
44
  config = ThemeCheck::Config.from_path(root)
44
- theme = ThemeCheck::Theme.new(config.root)
45
- analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
45
+ storage = ThemeCheck::FileSystemStorage.new(
46
+ config.root,
47
+ ignored_patterns: config.ignored_patterns
48
+ )
49
+ theme = ThemeCheck::Theme.new(storage)
50
+
51
+ offenses = analyze(theme, config)
52
+ log("Found #{theme.all.size} templates, and #{offenses.size} offenses")
53
+ send_diagnostics(offenses)
54
+ end
46
55
 
56
+ def analyze(theme, config)
57
+ analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
47
58
  log("Checking #{config.root}")
48
59
  analyzer.analyze_theme
49
- log("Found #{theme.all.size} templates, and #{analyzer.offenses.size} offenses")
50
- send_offenses(analyzer.offenses)
60
+ analyzer.offenses
51
61
  end
52
62
 
53
- def send_offenses(offenses)
63
+ def send_diagnostics(offenses)
64
+ reported_files = Set.new
65
+
54
66
  offenses.group_by(&:template).each do |template, template_offenses|
55
67
  next unless template
56
- # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
57
- send_response(
58
- method: 'textDocument/publishDiagnostics',
59
- params: {
60
- uri: "file:#{template.path}",
61
- diagnostics: template_offenses.map { |offense| offense_to_diagnostic(offense) },
62
- },
63
- )
68
+ send_diagnostic(template.path, template_offenses)
69
+ reported_files << template.path
70
+ end
71
+
72
+ # Publish diagnostics with empty array if all issues on a previously reported template
73
+ # have been solved.
74
+ (@previously_reported_files - reported_files).each do |path|
75
+ send_diagnostic(path, [])
64
76
  end
77
+
78
+ @previously_reported_files = reported_files
79
+ end
80
+
81
+ def send_diagnostic(path, offenses)
82
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
83
+ send_response(
84
+ method: 'textDocument/publishDiagnostics',
85
+ params: {
86
+ uri: "file:#{path}",
87
+ diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
88
+ },
89
+ )
65
90
  end
66
91
 
67
92
  def offense_to_diagnostic(offense)
@@ -9,6 +9,8 @@ module ThemeCheck
9
9
  class IncompatibleStream < StandardError; end
10
10
 
11
11
  class Server
12
+ attr_reader :handler
13
+
12
14
  def initialize(
13
15
  in_stream: STDIN,
14
16
  out_stream: STDOUT,
@@ -87,8 +89,6 @@ module ThemeCheck
87
89
 
88
90
  if @handler.respond_to?(method_name)
89
91
  @handler.send(method_name, id, params)
90
- else
91
- log("Handler does not respond to #{method_name}")
92
92
  end
93
93
  end
94
94
 
@@ -6,8 +6,8 @@ module ThemeCheck
6
6
  extend ChecksTracking
7
7
  include ParsingHelpers
8
8
 
9
- def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil)
10
- offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number)
9
+ def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil, &block)
10
+ offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number, correction: block)
11
11
  end
12
12
  end
13
13
  end
@@ -22,6 +22,14 @@ module ThemeCheck
22
22
  end
23
23
  end
24
24
 
25
+ def markup=(markup)
26
+ if tag?
27
+ @value.raw = markup
28
+ elsif @value.instance_variable_defined?(:@markup)
29
+ @value.instance_variable_set(:@markup, markup)
30
+ end
31
+ end
32
+
25
33
  # Array of children nodes.
26
34
  def children
27
35
  @children ||= begin
@@ -113,5 +121,10 @@ module ThemeCheck
113
121
  false
114
122
  end
115
123
  end
124
+
125
+ def range
126
+ start = template.full_line(line_number).index(markup)
127
+ [start, start + markup.length - 1]
128
+ end
116
129
  end
117
130
  end
@@ -3,10 +3,11 @@ module ThemeCheck
3
3
  class Offense
4
4
  MAX_SOURCE_EXCERPT_SIZE = 120
5
5
 
6
- attr_reader :check, :message, :template, :node, :markup, :line_number
6
+ attr_reader :check, :message, :template, :node, :markup, :line_number, :correction
7
7
 
8
- def initialize(check:, message: nil, template: nil, node: nil, markup: nil, line_number: nil)
8
+ def initialize(check:, message: nil, template: nil, node: nil, markup: nil, line_number: nil, correction: nil)
9
9
  @check = check
10
+ @correction = correction
10
11
 
11
12
  if message
12
13
  @message = message
@@ -39,7 +40,7 @@ module ThemeCheck
39
40
  def source_excerpt
40
41
  return unless line_number
41
42
  @source_excerpt ||= begin
42
- excerpt = template.excerpt(line_number)
43
+ excerpt = template.source_excerpt(line_number)
43
44
  if excerpt.size > MAX_SOURCE_EXCERPT_SIZE
44
45
  excerpt[0, MAX_SOURCE_EXCERPT_SIZE - 3] + '...'
45
46
  else
@@ -54,18 +55,22 @@ module ThemeCheck
54
55
  end
55
56
 
56
57
  def end_line
57
- return 0 unless line_number
58
- line_number - 1
58
+ if markup
59
+ start_line + markup.count("\n")
60
+ else
61
+ start_line
62
+ end
59
63
  end
60
64
 
61
65
  def start_column
62
- return 0 unless line_number
63
- template.full_line(line_number).index(markup)
66
+ return 0 unless line_number && markup
67
+ template.full_line(start_line + 1).index(markup.split("\n", 2).first)
64
68
  end
65
69
 
66
70
  def end_column
67
- return 0 unless line_number
68
- template.full_line(line_number).index(markup) + markup.size
71
+ return 0 unless line_number && markup
72
+ markup_end = markup.split("\n").last
73
+ template.full_line(end_line + 1).index(markup_end) + markup_end.size
69
74
  end
70
75
 
71
76
  def code_name
@@ -93,6 +98,17 @@ module ThemeCheck
93
98
  tokens.join(":") if tokens.any?
94
99
  end
95
100
 
101
+ def correctable?
102
+ line_number && correction
103
+ end
104
+
105
+ def correct
106
+ if correctable?
107
+ corrector = Corrector.new(template: template)
108
+ correction.call(corrector)
109
+ end
110
+ end
111
+
96
112
  def to_s
97
113
  if template
98
114
  "#{message} at #{location}"