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
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class TagCompletionProvider < CompletionProvider
6
+ def completions(content, cursor)
7
+ return [] unless can_complete?(content, cursor)
8
+ partial = first_word(content) || ''
9
+ ShopifyLiquid::Tag.labels
10
+ .select { |w| w.starts_with?(partial) }
11
+ .map { |tag| tag_to_completion(tag) }
12
+ end
13
+
14
+ def can_complete?(content, cursor)
15
+ content.starts_with?(Liquid::TagStart) && (
16
+ cursor_on_first_word?(content, cursor) ||
17
+ cursor_on_start_content?(content, cursor, Liquid::TagStart)
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def tag_to_completion(tag)
24
+ {
25
+ label: tag,
26
+ kind: CompletionItemKinds::KEYWORD,
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -4,9 +4,13 @@ module ThemeCheck
4
4
  module LanguageServer
5
5
  class Handler
6
6
  CAPABILITIES = {
7
+ completionProvider: {
8
+ triggerCharacters: ['.', '{{ ', '{% '],
9
+ context: true,
10
+ },
7
11
  textDocumentSync: {
8
12
  openClose: true,
9
- change: false,
13
+ change: TextDocumentSyncKind::FULL,
10
14
  willSave: false,
11
15
  save: true,
12
16
  },
@@ -14,6 +18,9 @@ module ThemeCheck
14
18
 
15
19
  def initialize(server)
16
20
  @server = server
21
+ @previously_reported_files = Set.new
22
+ @storage = InMemoryStorage.new
23
+ @completion_engine = CompletionEngine.new(@storage)
17
24
  end
18
25
 
19
26
  def on_initialize(id, params)
@@ -30,39 +37,93 @@ module ThemeCheck
30
37
  def on_exit(_id, _params)
31
38
  close!
32
39
  end
40
+ alias_method :on_shutdown, :on_exit
41
+
42
+ def on_text_document_did_change(_id, params)
43
+ uri = text_document_uri(params)
44
+ @storage.write(uri, content_changes_text(params))
45
+ end
46
+
47
+ def on_text_document_did_close(_id, params)
48
+ uri = text_document_uri(params)
49
+ @storage.write(uri, nil)
50
+ end
33
51
 
34
52
  def on_text_document_did_open(_id, params)
35
- analyze_and_send_offenses(params.dig('textDocument', 'uri').sub('file://', ''))
53
+ uri = text_document_uri(params)
54
+ @storage.write(uri, text_document_text(params))
55
+ analyze_and_send_offenses(uri)
56
+ end
57
+
58
+ def on_text_document_did_save(_id, params)
59
+ analyze_and_send_offenses(text_document_uri(params))
60
+ end
61
+
62
+ def on_text_document_completion(id, params)
63
+ uri = text_document_uri(params)
64
+ line = params.dig('position', 'line')
65
+ col = params.dig('position', 'character')
66
+ send_response(
67
+ id: id,
68
+ result: completions(uri, line, col)
69
+ )
36
70
  end
37
- alias_method :on_text_document_did_save, :on_text_document_did_open
38
71
 
39
72
  private
40
73
 
74
+ def text_document_uri(params)
75
+ params.dig('textDocument', 'uri').sub('file://', '')
76
+ end
77
+
78
+ def text_document_text(params)
79
+ params.dig('textDocument', 'text')
80
+ end
81
+
82
+ def content_changes_text(params)
83
+ params.dig('contentChanges', 0, 'text')
84
+ end
85
+
41
86
  def analyze_and_send_offenses(file_path)
42
87
  root = ThemeCheck::Config.find(file_path) || @root_path
43
88
  config = ThemeCheck::Config.from_path(root)
44
- theme = ThemeCheck::Theme.new(config.root)
45
- analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
89
+ storage = ThemeCheck::FileSystemStorage.new(
90
+ config.root,
91
+ ignored_patterns: config.ignored_patterns
92
+ )
93
+ theme = ThemeCheck::Theme.new(storage)
46
94
 
95
+ offenses = analyze(theme, config)
96
+ log("Found #{theme.all.size} templates, and #{offenses.size} offenses")
97
+ send_diagnostics(offenses)
98
+ end
99
+
100
+ def analyze(theme, config)
101
+ analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
47
102
  log("Checking #{config.root}")
48
103
  analyzer.analyze_theme
49
- log("Found #{theme.all.size} templates, and #{analyzer.offenses.size} offenses")
50
- send_diagnostics(analyzer.offenses, theme.all)
104
+ analyzer.offenses
51
105
  end
52
106
 
53
- def send_diagnostics(offenses, templates)
54
- contains_offenses = []
107
+ def completions(uri, line, col)
108
+ @completion_engine.completions(uri, line, col)
109
+ end
110
+
111
+ def send_diagnostics(offenses)
112
+ reported_files = Set.new
55
113
 
56
114
  offenses.group_by(&:template).each do |template, template_offenses|
57
115
  next unless template
58
116
  send_diagnostic(template.path, template_offenses)
59
- contains_offenses.push(template.path)
117
+ reported_files << template.path
60
118
  end
61
119
 
62
- # Publish diagnostics with empty array if template does not contain error
63
- templates.select { |t| !contains_offenses.include?(t.path) }.each do |template|
64
- send_diagnostic(template.path, [])
120
+ # Publish diagnostics with empty array if all issues on a previously reported template
121
+ # have been solved.
122
+ (@previously_reported_files - reported_files).each do |path|
123
+ send_diagnostic(path, [])
65
124
  end
125
+
126
+ @previously_reported_files = reported_files
66
127
  end
67
128
 
68
129
  def send_diagnostic(path, offenses)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ # Note: Everything is 0-indexed here.
3
+
4
+ module ThemeCheck
5
+ module LanguageServer
6
+ module PositionHelper
7
+ def from_line_column_to_index(content, row, col)
8
+ i = 0
9
+ result = 0
10
+ lines = content.lines
11
+ while i < row
12
+ result += lines[i].size
13
+ i += 1
14
+ end
15
+ result += col
16
+ result
17
+ end
18
+
19
+ def from_index_to_line_column(content, index)
20
+ lines = content[0..index].lines
21
+ row = lines.size - 1
22
+ col = lines.last.size - 1
23
+ [row, col]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ # Here we define the Language Server Protocol Constants we're using.
3
+ # For complete docs, see the following:
4
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-current
5
+ module ThemeCheck
6
+ module LanguageServer
7
+ module CompletionItemKinds
8
+ TEXT = 1
9
+ METHOD = 2
10
+ FUNCTION = 3
11
+ CONSTRUCTOR = 4
12
+ FIELD = 5
13
+ VARIABLE = 6
14
+ CLASS = 7
15
+ INTERFACE = 8
16
+ MODULE = 9
17
+ PROPERTY = 10
18
+ UNIT = 11
19
+ VALUE = 12
20
+ ENUM = 13
21
+ KEYWORD = 14
22
+ SNIPPET = 15
23
+ COLOR = 16
24
+ FILE = 17
25
+ REFERENCE = 18
26
+ FOLDER = 19
27
+ ENUM_MEMBER = 20
28
+ CONSTANT = 21
29
+ STRUCT = 22
30
+ EVENT = 23
31
+ OPERATOR = 24
32
+ TYPE_PARAMETER = 25
33
+ end
34
+
35
+ module TextDocumentSyncKind
36
+ NONE = 0
37
+ FULL = 1
38
+ INCREMENTAL = 2
39
+ end
40
+ end
41
+ end
@@ -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
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ Token = Struct.new(
5
+ :content,
6
+ :start, # inclusive
7
+ :end, # exclusive
8
+ )
9
+
10
+ TAG_START = Liquid::TagStart
11
+ TAG_END = Liquid::TagEnd
12
+ VARIABLE_START = Liquid::VariableStart
13
+ VARIABLE_END = Liquid::VariableEnd
14
+ SPLITTER = %r{
15
+ (?=(?:#{TAG_START}|#{VARIABLE_START}))| # positive lookahead on tag/variable start
16
+ (?<=(?:#{TAG_END}|#{VARIABLE_END})) # positive lookbehind on tag/variable end
17
+ }xom
18
+
19
+ # Implemented as an Enumerable so we stop iterating on the find once
20
+ # we have what we want. Kind of a perf thing.
21
+ class Tokens
22
+ include Enumerable
23
+
24
+ def initialize(buffer)
25
+ @buffer = buffer
26
+ end
27
+
28
+ def each(&block)
29
+ return to_enum(:each) unless block_given?
30
+
31
+ chunks = @buffer.split(SPLITTER)
32
+ chunks.shift if chunks[0]&.empty?
33
+
34
+ prev = Token.new('', 0, 0)
35
+ curr = Token.new('', 0, 0)
36
+
37
+ while (content = chunks.shift)
38
+
39
+ curr.start = prev.end
40
+ curr.end = curr.start + content.size
41
+
42
+ block.call(Token.new(
43
+ content,
44
+ curr.start,
45
+ curr.end,
46
+ ))
47
+
48
+ # recycling structs
49
+ tmp = prev
50
+ prev = curr
51
+ curr = tmp
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
+ Position = Struct.new(:line, :column)
4
+
3
5
  class Offense
4
6
  MAX_SOURCE_EXCERPT_SIZE = 120
5
7
 
@@ -35,6 +37,9 @@ module ThemeCheck
35
37
  elsif @node
36
38
  @node.line_number
37
39
  end
40
+
41
+ @start_position = nil
42
+ @end_position = nil
38
43
  end
39
44
 
40
45
  def source_excerpt
@@ -50,27 +55,19 @@ module ThemeCheck
50
55
  end
51
56
 
52
57
  def start_line
53
- return 0 unless line_number
54
- line_number - 1
58
+ start_position.line
55
59
  end
56
60
 
57
- def end_line
58
- if markup
59
- start_line + markup.count("\n")
60
- else
61
- start_line
62
- end
61
+ def start_column
62
+ start_position.column
63
63
  end
64
64
 
65
- def start_column
66
- return 0 unless line_number && markup
67
- template.full_line(start_line + 1).index(markup.split("\n", 2).first)
65
+ def end_line
66
+ end_position.line
68
67
  end
69
68
 
70
69
  def end_column
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
70
+ end_position.column
74
71
  end
75
72
 
76
73
  def code_name
@@ -116,5 +113,45 @@ module ThemeCheck
116
113
  message
117
114
  end
118
115
  end
116
+
117
+ private
118
+
119
+ def full_line(line)
120
+ # Liquid::Template is 1-indexed.
121
+ template.full_line(line + 1)
122
+ end
123
+
124
+ def lines_of_content
125
+ @lines ||= markup.lines.map { |x| x.sub(/\n$/, '') }
126
+ end
127
+
128
+ # 0-indexed, inclusive
129
+ def start_position
130
+ return @start_position if @start_position
131
+ return @start_position = Position.new(0, 0) unless line_number && markup
132
+
133
+ position = Position.new
134
+ position.line = line_number - 1
135
+ position.column = full_line(position.line).index(lines_of_content.first) || 0
136
+
137
+ @start_position = position
138
+ end
139
+
140
+ # 0-indexed, exclusive. It's the line + col that are exclusive.
141
+ # This is why it doesn't make sense to calculate them separately.
142
+ def end_position
143
+ return @end_position if @end_position
144
+ return @end_position = Position.new(0, 0) unless line_number && markup
145
+
146
+ position = Position.new
147
+ position.line = start_line + lines_of_content.size - 1
148
+ position.column = if start_line == position.line
149
+ start_column + markup.size
150
+ else
151
+ lines_of_content.last.size
152
+ end
153
+
154
+ @end_position = position
155
+ end
119
156
  end
120
157
  end
@@ -2,3 +2,4 @@
2
2
  require_relative 'shopify_liquid/deprecated_filter'
3
3
  require_relative 'shopify_liquid/filter'
4
4
  require_relative 'shopify_liquid/object'
5
+ require_relative 'shopify_liquid/tag'
@@ -11,6 +11,12 @@ module ThemeCheck
11
11
  YAML.load(File.read("#{__dir__}/../../../data/shopify_liquid/objects.yml"))
12
12
  end
13
13
  end
14
+
15
+ def plus_labels
16
+ @plus_labels ||= begin
17
+ YAML.load(File.read("#{__dir__}/../../../data/shopify_liquid/plus_objects.yml"))
18
+ end
19
+ end
14
20
  end
15
21
  end
16
22
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ require 'yaml'
3
+
4
+ module ThemeCheck
5
+ module ShopifyLiquid
6
+ module Tag
7
+ extend self
8
+
9
+ def labels
10
+ @tags ||= begin
11
+ YAML.load(File.read("#{__dir__}/../../../data/shopify_liquid/tags.yml"))
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class Storage
5
+ def read(relative_path)
6
+ raise NotImplementedError
7
+ end
8
+
9
+ def write(relative_path, content)
10
+ raise NotImplementedError
11
+ end
12
+
13
+ def path(relative_path)
14
+ raise NotImplementedError
15
+ end
16
+
17
+ def files
18
+ raise NotImplementedError
19
+ end
20
+
21
+ def directories
22
+ raise NotImplementedError
23
+ end
24
+ end
25
+ end