theme-check 0.2.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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