theme-check 0.3.1 → 0.6.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -3
  3. data/CHANGELOG.md +61 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/Gemfile +5 -3
  6. data/README.md +11 -4
  7. data/RELEASING.md +2 -2
  8. data/config/default.yml +14 -0
  9. data/data/shopify_liquid/tags.yml +27 -0
  10. data/data/shopify_translation_keys.yml +850 -0
  11. data/docs/checks/CHECK_DOCS_TEMPLATE.md +47 -0
  12. data/docs/checks/asset_size_css.md +52 -0
  13. data/docs/checks/asset_size_javascript.md +79 -0
  14. data/docs/checks/convert_include_to_render.md +48 -0
  15. data/docs/checks/default_locale.md +46 -0
  16. data/docs/checks/deprecated_filter.md +46 -0
  17. data/docs/checks/img_width_and_height.md +79 -0
  18. data/docs/checks/liquid_tag.md +65 -0
  19. data/docs/checks/matching_schema_translations.md +93 -0
  20. data/docs/checks/matching_translations.md +72 -0
  21. data/docs/checks/missing_enable_comment.md +50 -0
  22. data/docs/checks/missing_required_template_files.md +26 -0
  23. data/docs/checks/missing_template.md +40 -0
  24. data/docs/checks/nested_snippet.md +69 -0
  25. data/docs/checks/parser_blocking_javascript.md +97 -0
  26. data/docs/checks/required_directories.md +25 -0
  27. data/docs/checks/required_layout_theme_object.md +28 -0
  28. data/docs/checks/space_inside_braces.md +63 -0
  29. data/docs/checks/syntax_error.md +49 -0
  30. data/docs/checks/template_length.md +50 -0
  31. data/docs/checks/translation_key_exists.md +63 -0
  32. data/docs/checks/undefined_object.md +53 -0
  33. data/docs/checks/unknown_filter.md +45 -0
  34. data/docs/checks/unused_assign.md +47 -0
  35. data/docs/checks/unused_snippet.md +32 -0
  36. data/docs/checks/valid_html_translation.md +53 -0
  37. data/docs/checks/valid_json.md +60 -0
  38. data/docs/checks/valid_schema.md +50 -0
  39. data/lib/theme_check.rb +3 -0
  40. data/lib/theme_check/asset_file.rb +34 -0
  41. data/lib/theme_check/check.rb +19 -9
  42. data/lib/theme_check/checks/asset_size_css.rb +89 -0
  43. data/lib/theme_check/checks/asset_size_javascript.rb +68 -0
  44. data/lib/theme_check/checks/convert_include_to_render.rb +1 -1
  45. data/lib/theme_check/checks/default_locale.rb +1 -0
  46. data/lib/theme_check/checks/deprecated_filter.rb +1 -1
  47. data/lib/theme_check/checks/img_width_and_height.rb +74 -0
  48. data/lib/theme_check/checks/liquid_tag.rb +3 -3
  49. data/lib/theme_check/checks/matching_schema_translations.rb +1 -0
  50. data/lib/theme_check/checks/matching_translations.rb +1 -0
  51. data/lib/theme_check/checks/missing_enable_comment.rb +1 -0
  52. data/lib/theme_check/checks/missing_required_template_files.rb +1 -2
  53. data/lib/theme_check/checks/missing_template.rb +1 -0
  54. data/lib/theme_check/checks/nested_snippet.rb +1 -0
  55. data/lib/theme_check/checks/parser_blocking_javascript.rb +7 -14
  56. data/lib/theme_check/checks/required_directories.rb +1 -1
  57. data/lib/theme_check/checks/required_layout_theme_object.rb +1 -1
  58. data/lib/theme_check/checks/space_inside_braces.rb +1 -0
  59. data/lib/theme_check/checks/syntax_error.rb +1 -0
  60. data/lib/theme_check/checks/template_length.rb +1 -0
  61. data/lib/theme_check/checks/translation_key_exists.rb +17 -1
  62. data/lib/theme_check/checks/undefined_object.rb +29 -10
  63. data/lib/theme_check/checks/unknown_filter.rb +1 -0
  64. data/lib/theme_check/checks/unused_assign.rb +5 -3
  65. data/lib/theme_check/checks/unused_snippet.rb +1 -0
  66. data/lib/theme_check/checks/valid_html_translation.rb +1 -0
  67. data/lib/theme_check/checks/valid_json.rb +1 -0
  68. data/lib/theme_check/checks/valid_schema.rb +1 -0
  69. data/lib/theme_check/cli.rb +39 -12
  70. data/lib/theme_check/config.rb +5 -2
  71. data/lib/theme_check/in_memory_storage.rb +11 -3
  72. data/lib/theme_check/language_server.rb +12 -0
  73. data/lib/theme_check/language_server/completion_engine.rb +38 -0
  74. data/lib/theme_check/language_server/completion_helper.rb +25 -0
  75. data/lib/theme_check/language_server/completion_provider.rb +28 -0
  76. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +51 -0
  77. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +31 -0
  78. data/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb +43 -0
  79. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +31 -0
  80. data/lib/theme_check/language_server/constants.rb +10 -0
  81. data/lib/theme_check/language_server/document_link_engine.rb +47 -0
  82. data/lib/theme_check/language_server/handler.rb +93 -6
  83. data/lib/theme_check/language_server/position_helper.rb +27 -0
  84. data/lib/theme_check/language_server/protocol.rb +41 -0
  85. data/lib/theme_check/language_server/server.rb +8 -2
  86. data/lib/theme_check/language_server/tokens.rb +55 -0
  87. data/lib/theme_check/liquid_check.rb +11 -0
  88. data/lib/theme_check/offense.rb +51 -14
  89. data/lib/theme_check/regex_helpers.rb +15 -0
  90. data/lib/theme_check/remote_asset_file.rb +44 -0
  91. data/lib/theme_check/shopify_liquid.rb +1 -0
  92. data/lib/theme_check/shopify_liquid/deprecated_filter.rb +4 -0
  93. data/lib/theme_check/shopify_liquid/tag.rb +16 -0
  94. data/lib/theme_check/theme.rb +7 -1
  95. data/lib/theme_check/version.rb +1 -1
  96. metadata +52 -2
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ PARTIAL_RENDER = %r{
6
+ \{\%\s*render\s+'(?<partial>[^']*)'|
7
+ \{\%\s*render\s+"(?<partial>[^"]*)"
8
+ }mix
9
+ end
10
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class DocumentLinkEngine
6
+ include PositionHelper
7
+ include RegexHelpers
8
+
9
+ def initialize(storage)
10
+ @storage = storage
11
+ end
12
+
13
+ def document_links(uri)
14
+ buffer = @storage.read(uri)
15
+ matches(buffer, PARTIAL_RENDER).map do |match|
16
+ start_line, start_character = from_index_to_line_column(
17
+ buffer,
18
+ match.begin(:partial),
19
+ )
20
+
21
+ end_line, end_character = from_index_to_line_column(
22
+ buffer,
23
+ match.end(:partial)
24
+ )
25
+
26
+ {
27
+ target: link(match[:partial]),
28
+ range: {
29
+ start: {
30
+ line: start_line,
31
+ character: start_character,
32
+ },
33
+ end: {
34
+ line: end_line,
35
+ character: end_character,
36
+ },
37
+ },
38
+ }
39
+ end
40
+ end
41
+
42
+ def link(partial)
43
+ 'file://' + @storage.path('snippets/' + partial + '.liquid')
44
+ end
45
+ end
46
+ end
47
+ end
@@ -4,9 +4,14 @@ module ThemeCheck
4
4
  module LanguageServer
5
5
  class Handler
6
6
  CAPABILITIES = {
7
+ completionProvider: {
8
+ triggerCharacters: ['.', '{{ ', '{% '],
9
+ context: true,
10
+ },
11
+ documentLinkProvider: true,
7
12
  textDocumentSync: {
8
13
  openClose: true,
9
- change: false,
14
+ change: TextDocumentSyncKind::FULL,
10
15
  willSave: false,
11
16
  save: true,
12
17
  },
@@ -19,6 +24,9 @@ module ThemeCheck
19
24
 
20
25
  def on_initialize(id, params)
21
26
  @root_path = params["rootPath"]
27
+ @storage = in_memory_storage(@root_path)
28
+ @completion_engine = CompletionEngine.new(@storage)
29
+ @document_link_engine = DocumentLinkEngine.new(@storage)
22
30
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
23
31
  send_response(
24
32
  id: id,
@@ -31,14 +39,77 @@ module ThemeCheck
31
39
  def on_exit(_id, _params)
32
40
  close!
33
41
  end
42
+ alias_method :on_shutdown, :on_exit
43
+
44
+ def on_text_document_did_change(_id, params)
45
+ uri = text_document_uri(params)
46
+ @storage.write(uri, content_changes_text(params))
47
+ end
48
+
49
+ def on_text_document_did_close(_id, params)
50
+ uri = text_document_uri(params)
51
+ @storage.write(uri, nil)
52
+ end
34
53
 
35
54
  def on_text_document_did_open(_id, params)
36
- analyze_and_send_offenses(params.dig('textDocument', 'uri').sub('file://', ''))
55
+ uri = text_document_uri(params)
56
+ @storage.write(uri, text_document_text(params))
57
+ analyze_and_send_offenses(uri)
58
+ end
59
+
60
+ def on_text_document_did_save(_id, params)
61
+ analyze_and_send_offenses(text_document_uri(params))
62
+ end
63
+
64
+ def on_text_document_document_link(id, params)
65
+ uri = text_document_uri(params)
66
+ send_response(
67
+ id: id,
68
+ result: document_links(uri)
69
+ )
70
+ end
71
+
72
+ def on_text_document_completion(id, params)
73
+ uri = text_document_uri(params)
74
+ line = params.dig('position', 'line')
75
+ col = params.dig('position', 'character')
76
+ send_response(
77
+ id: id,
78
+ result: completions(uri, line, col)
79
+ )
37
80
  end
38
- alias_method :on_text_document_did_save, :on_text_document_did_open
39
81
 
40
82
  private
41
83
 
84
+ def in_memory_storage(root)
85
+ config = ThemeCheck::Config.from_path(root)
86
+
87
+ # Make a real FS to get the files from the snippets folder
88
+ fs = ThemeCheck::FileSystemStorage.new(
89
+ config.root,
90
+ ignored_patterns: config.ignored_patterns
91
+ )
92
+
93
+ # Turn that into a hash of empty buffers
94
+ files = fs.files
95
+ .map { |fn| [fn, ""] }
96
+ .to_h
97
+
98
+ InMemoryStorage.new(files, root)
99
+ end
100
+
101
+ def text_document_uri(params)
102
+ params.dig('textDocument', 'uri').sub('file://', '')
103
+ end
104
+
105
+ def text_document_text(params)
106
+ params.dig('textDocument', 'text')
107
+ end
108
+
109
+ def content_changes_text(params)
110
+ params.dig('contentChanges', 0, 'text')
111
+ end
112
+
42
113
  def analyze_and_send_offenses(file_path)
43
114
  root = ThemeCheck::Config.find(file_path) || @root_path
44
115
  config = ThemeCheck::Config.from_path(root)
@@ -60,6 +131,14 @@ module ThemeCheck
60
131
  analyzer.offenses
61
132
  end
62
133
 
134
+ def completions(uri, line, col)
135
+ @completion_engine.completions(uri, line, col)
136
+ end
137
+
138
+ def document_links(uri)
139
+ @document_link_engine.document_links(uri)
140
+ end
141
+
63
142
  def send_diagnostics(offenses)
64
143
  reported_files = Set.new
65
144
 
@@ -90,12 +169,20 @@ module ThemeCheck
90
169
  end
91
170
 
92
171
  def offense_to_diagnostic(offense)
93
- {
172
+ diagnostic = {
173
+ code: offense.code_name,
174
+ message: offense.message,
94
175
  range: range(offense),
95
176
  severity: severity(offense),
96
- code: offense.code_name,
97
177
  source: "theme-check",
98
- message: offense.message,
178
+ }
179
+ diagnostic["codeDescription"] = code_description(offense) unless offense.doc.nil?
180
+ diagnostic
181
+ end
182
+
183
+ def code_description(offense)
184
+ {
185
+ href: offense.doc,
99
186
  }
100
187
  end
101
188
 
@@ -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
@@ -6,15 +6,18 @@ require 'active_support/core_ext/string/inflections'
6
6
  module ThemeCheck
7
7
  module LanguageServer
8
8
  class DoneStreaming < StandardError; end
9
+
9
10
  class IncompatibleStream < StandardError; end
10
11
 
11
12
  class Server
12
13
  attr_reader :handler
14
+ attr_reader :should_raise_errors
13
15
 
14
16
  def initialize(
15
17
  in_stream: STDIN,
16
18
  out_stream: STDOUT,
17
- err_stream: $DEBUG ? File.open('/tmp/lsp.log', 'a') : STDERR
19
+ err_stream: STDERR,
20
+ should_raise_errors: false
18
21
  )
19
22
  validate!([in_stream, out_stream, err_stream])
20
23
 
@@ -25,6 +28,8 @@ module ThemeCheck
25
28
 
26
29
  @out.sync = true # do not buffer
27
30
  @err.sync = true # do not buffer
31
+
32
+ @should_raise_errors = should_raise_errors
28
33
  end
29
34
 
30
35
  def listen
@@ -37,6 +42,7 @@ module ThemeCheck
37
42
  return 0
38
43
 
39
44
  rescue Exception => e # rubocop:disable Lint/RescueException
45
+ raise e if should_raise_errors
40
46
  log(e)
41
47
  log(e.backtrace)
42
48
  return 1
@@ -45,7 +51,7 @@ module ThemeCheck
45
51
 
46
52
  def send_response(response)
47
53
  response_body = JSON.dump(response)
48
- log(response_body) if $DEBUG
54
+ log(JSON.pretty_generate(response)) if $DEBUG
49
55
 
50
56
  @out.write("Content-Length: #{response_body.size}\r\n")
51
57
  @out.write("\r\n")
@@ -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
@@ -6,6 +6,17 @@ module ThemeCheck
6
6
  extend ChecksTracking
7
7
  include ParsingHelpers
8
8
 
9
+ TAG = /#{Liquid::TagStart}.*?#{Liquid::TagEnd}/om
10
+ VARIABLE = /#{Liquid::VariableStart}.*?#{Liquid::VariableEnd}/om
11
+ START_OR_END_QUOTE = /(^['"])|(['"]$)/
12
+ QUOTED_LIQUID_ATTRIBUTE = %r{
13
+ '(?:#{TAG}|#{VARIABLE}|[^'])*'| # any combination of tag/variable or non straight quote inside straight quotes
14
+ "(?:#{TAG}|#{VARIABLE}|[^"])*" # any combination of tag/variable or non double quotes inside double quotes
15
+ }omix
16
+ ATTR = /[a-z0-9-]+/i
17
+ HTML_ATTRIBUTE = /#{ATTR}(?:=#{QUOTED_LIQUID_ATTRIBUTE})?/omix
18
+ HTML_ATTRIBUTES = /(?:#{HTML_ATTRIBUTE}|\s)*/omix
19
+
9
20
  def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil, &block)
10
21
  offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number, correction: block)
11
22
  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