theme-check 0.3.2 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +10 -3
  3. data/.rubocop.yml +12 -3
  4. data/CHANGELOG.md +42 -0
  5. data/CONTRIBUTING.md +5 -2
  6. data/Gemfile +5 -3
  7. data/LICENSE.md +2 -0
  8. data/README.md +12 -4
  9. data/RELEASING.md +10 -3
  10. data/Rakefile +6 -0
  11. data/config/default.yml +16 -0
  12. data/data/shopify_liquid/tags.yml +27 -0
  13. data/data/shopify_translation_keys.yml +850 -0
  14. data/docs/checks/CHECK_DOCS_TEMPLATE.md +47 -0
  15. data/docs/checks/asset_size_css.md +52 -0
  16. data/docs/checks/asset_size_javascript.md +79 -0
  17. data/docs/checks/convert_include_to_render.md +48 -0
  18. data/docs/checks/default_locale.md +46 -0
  19. data/docs/checks/deprecated_filter.md +46 -0
  20. data/docs/checks/img_width_and_height.md +79 -0
  21. data/docs/checks/liquid_tag.md +65 -0
  22. data/docs/checks/matching_schema_translations.md +93 -0
  23. data/docs/checks/matching_translations.md +72 -0
  24. data/docs/checks/missing_enable_comment.md +50 -0
  25. data/docs/checks/missing_required_template_files.md +26 -0
  26. data/docs/checks/missing_template.md +40 -0
  27. data/docs/checks/nested_snippet.md +69 -0
  28. data/docs/checks/parser_blocking_javascript.md +97 -0
  29. data/docs/checks/remote_asset.md +82 -0
  30. data/docs/checks/required_directories.md +25 -0
  31. data/docs/checks/required_layout_theme_object.md +28 -0
  32. data/docs/checks/space_inside_braces.md +63 -0
  33. data/docs/checks/syntax_error.md +49 -0
  34. data/docs/checks/template_length.md +50 -0
  35. data/docs/checks/translation_key_exists.md +63 -0
  36. data/docs/checks/undefined_object.md +53 -0
  37. data/docs/checks/unknown_filter.md +45 -0
  38. data/docs/checks/unused_assign.md +47 -0
  39. data/docs/checks/unused_snippet.md +32 -0
  40. data/docs/checks/valid_html_translation.md +53 -0
  41. data/docs/checks/valid_json.md +60 -0
  42. data/docs/checks/valid_schema.md +50 -0
  43. data/lib/theme_check.rb +4 -0
  44. data/lib/theme_check/asset_file.rb +34 -0
  45. data/lib/theme_check/check.rb +20 -10
  46. data/lib/theme_check/checks/asset_size_css.rb +89 -0
  47. data/lib/theme_check/checks/asset_size_javascript.rb +68 -0
  48. data/lib/theme_check/checks/convert_include_to_render.rb +1 -1
  49. data/lib/theme_check/checks/default_locale.rb +1 -0
  50. data/lib/theme_check/checks/deprecated_filter.rb +1 -1
  51. data/lib/theme_check/checks/img_width_and_height.rb +74 -0
  52. data/lib/theme_check/checks/liquid_tag.rb +3 -3
  53. data/lib/theme_check/checks/matching_schema_translations.rb +1 -0
  54. data/lib/theme_check/checks/matching_translations.rb +2 -1
  55. data/lib/theme_check/checks/missing_enable_comment.rb +1 -0
  56. data/lib/theme_check/checks/missing_required_template_files.rb +1 -2
  57. data/lib/theme_check/checks/missing_template.rb +1 -0
  58. data/lib/theme_check/checks/nested_snippet.rb +1 -0
  59. data/lib/theme_check/checks/parser_blocking_javascript.rb +8 -15
  60. data/lib/theme_check/checks/remote_asset.rb +98 -0
  61. data/lib/theme_check/checks/required_directories.rb +1 -1
  62. data/lib/theme_check/checks/required_layout_theme_object.rb +1 -1
  63. data/lib/theme_check/checks/space_inside_braces.rb +1 -0
  64. data/lib/theme_check/checks/syntax_error.rb +1 -0
  65. data/lib/theme_check/checks/template_length.rb +1 -0
  66. data/lib/theme_check/checks/translation_key_exists.rb +14 -1
  67. data/lib/theme_check/checks/undefined_object.rb +16 -7
  68. data/lib/theme_check/checks/unknown_filter.rb +1 -0
  69. data/lib/theme_check/checks/unused_assign.rb +5 -3
  70. data/lib/theme_check/checks/unused_snippet.rb +1 -0
  71. data/lib/theme_check/checks/valid_html_translation.rb +2 -1
  72. data/lib/theme_check/checks/valid_json.rb +1 -0
  73. data/lib/theme_check/checks/valid_schema.rb +1 -0
  74. data/lib/theme_check/cli.rb +49 -13
  75. data/lib/theme_check/config.rb +5 -2
  76. data/lib/theme_check/disabled_checks.rb +2 -2
  77. data/lib/theme_check/in_memory_storage.rb +13 -8
  78. data/lib/theme_check/language_server.rb +12 -0
  79. data/lib/theme_check/language_server/completion_engine.rb +38 -0
  80. data/lib/theme_check/language_server/completion_helper.rb +25 -0
  81. data/lib/theme_check/language_server/completion_provider.rb +28 -0
  82. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +51 -0
  83. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +31 -0
  84. data/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb +43 -0
  85. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +31 -0
  86. data/lib/theme_check/language_server/constants.rb +10 -0
  87. data/lib/theme_check/language_server/document_link_engine.rb +48 -0
  88. data/lib/theme_check/language_server/handler.rb +105 -10
  89. data/lib/theme_check/language_server/position_helper.rb +27 -0
  90. data/lib/theme_check/language_server/protocol.rb +41 -0
  91. data/lib/theme_check/language_server/server.rb +9 -4
  92. data/lib/theme_check/language_server/tokens.rb +55 -0
  93. data/lib/theme_check/liquid_check.rb +11 -0
  94. data/lib/theme_check/node.rb +1 -2
  95. data/lib/theme_check/offense.rb +52 -15
  96. data/lib/theme_check/regex_helpers.rb +15 -0
  97. data/lib/theme_check/releaser.rb +39 -0
  98. data/lib/theme_check/remote_asset_file.rb +44 -0
  99. data/lib/theme_check/shopify_liquid.rb +1 -0
  100. data/lib/theme_check/shopify_liquid/deprecated_filter.rb +10 -8
  101. data/lib/theme_check/shopify_liquid/filter.rb +3 -5
  102. data/lib/theme_check/shopify_liquid/object.rb +2 -6
  103. data/lib/theme_check/shopify_liquid/tag.rb +14 -0
  104. data/lib/theme_check/storage.rb +3 -3
  105. data/lib/theme_check/string_helpers.rb +47 -0
  106. data/lib/theme_check/tags.rb +1 -2
  107. data/lib/theme_check/theme.rb +7 -1
  108. data/lib/theme_check/version.rb +1 -1
  109. data/theme-check.gemspec +1 -2
  110. metadata +57 -18
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class CompletionProvider
6
+ include CompletionHelper
7
+ include RegexHelpers
8
+
9
+ class << self
10
+ def all
11
+ @all ||= []
12
+ end
13
+
14
+ def inherited(subclass)
15
+ all << subclass
16
+ end
17
+ end
18
+
19
+ def initialize(storage = InMemoryStorage.new)
20
+ @storage = storage
21
+ end
22
+
23
+ def completions(content, cursor)
24
+ raise NotImplementedError
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class FilterCompletionProvider < CompletionProvider
6
+ NAMED_FILTER = /#{Liquid::FilterSeparator}\s*(\w+)/o
7
+
8
+ def completions(content, cursor)
9
+ return [] unless can_complete?(content, cursor)
10
+ available_labels
11
+ .select { |w| w.start_with?(partial(content, cursor)) }
12
+ .map { |filter| filter_to_completion(filter) }
13
+ end
14
+
15
+ def can_complete?(content, cursor)
16
+ content.match?(Liquid::FilterSeparator) && (
17
+ cursor_on_start_content?(content, cursor, Liquid::FilterSeparator) ||
18
+ cursor_on_filter?(content, cursor)
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def available_labels
25
+ @labels ||= ShopifyLiquid::Filter.labels - ShopifyLiquid::DeprecatedFilter.labels
26
+ end
27
+
28
+ def cursor_on_filter?(content, cursor)
29
+ return false unless content.match?(NAMED_FILTER)
30
+ matches(content, NAMED_FILTER).any? do |match|
31
+ match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
32
+ end
33
+ end
34
+
35
+ def partial(content, cursor)
36
+ return '' unless content.match?(NAMED_FILTER)
37
+ partial_match = matches(content, NAMED_FILTER).find do |match|
38
+ match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
39
+ end
40
+ partial_match[1]
41
+ end
42
+
43
+ def filter_to_completion(filter)
44
+ {
45
+ label: filter,
46
+ kind: CompletionItemKinds::FUNCTION,
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class ObjectCompletionProvider < CompletionProvider
6
+ def completions(content, cursor)
7
+ return [] unless can_complete?(content, cursor)
8
+ partial = first_word(content) || ''
9
+ ShopifyLiquid::Object.labels
10
+ .select { |w| w.start_with?(partial) }
11
+ .map { |object| object_to_completion(object) }
12
+ end
13
+
14
+ def can_complete?(content, cursor)
15
+ content.match?(Liquid::VariableStart) && (
16
+ cursor_on_first_word?(content, cursor) ||
17
+ cursor_on_start_content?(content, cursor, Liquid::VariableStart)
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def object_to_completion(object)
24
+ {
25
+ label: object,
26
+ kind: CompletionItemKinds::VARIABLE,
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class RenderSnippetCompletionProvider < CompletionProvider
6
+ def completions(content, cursor)
7
+ return [] unless cursor_on_quoted_argument?(content, cursor)
8
+ partial = snippet(content) || ''
9
+ snippets
10
+ .select { |x| x.start_with?(partial) }
11
+ .map { |x| snippet_to_completion(x) }
12
+ end
13
+
14
+ private
15
+
16
+ def cursor_on_quoted_argument?(content, cursor)
17
+ match = content.match(PARTIAL_RENDER)
18
+ return false if match.nil?
19
+ match.begin(:partial) <= cursor && cursor <= match.end(:partial)
20
+ end
21
+
22
+ def snippet(content)
23
+ match = content.match(PARTIAL_RENDER)
24
+ return if match.nil?
25
+ match[:partial]
26
+ end
27
+
28
+ def snippets
29
+ @storage
30
+ .files
31
+ .select { |x| x.include?('snippets/') }
32
+ end
33
+
34
+ def snippet_to_completion(file)
35
+ {
36
+ label: File.basename(file, '.liquid'),
37
+ kind: CompletionItemKinds::SNIPPET,
38
+ detail: file,
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -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.start_with?(partial) }
11
+ .map { |tag| tag_to_completion(tag) }
12
+ end
13
+
14
+ def can_complete?(content, cursor)
15
+ content.start_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
@@ -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,48 @@
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(relative_path)
14
+ buffer = @storage.read(relative_path)
15
+ return [] unless buffer
16
+ matches(buffer, PARTIAL_RENDER).map do |match|
17
+ start_line, start_character = from_index_to_line_column(
18
+ buffer,
19
+ match.begin(:partial),
20
+ )
21
+
22
+ end_line, end_character = from_index_to_line_column(
23
+ buffer,
24
+ match.end(:partial)
25
+ )
26
+
27
+ {
28
+ target: link(match[:partial]),
29
+ range: {
30
+ start: {
31
+ line: start_line,
32
+ character: start_character,
33
+ },
34
+ end: {
35
+ line: end_line,
36
+ character: end_character,
37
+ },
38
+ },
39
+ }
40
+ end
41
+ end
42
+
43
+ def link(partial)
44
+ "file://#{@storage.path('snippets/' + partial + '.liquid')}"
45
+ end
46
+ end
47
+ end
48
+ 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,17 +39,88 @@ 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
+ relative_path = relative_path_from_text_document_uri(params)
46
+ @storage.write(relative_path, content_changes_text(params))
47
+ end
48
+
49
+ def on_text_document_did_close(_id, params)
50
+ relative_path = relative_path_from_text_document_uri(params)
51
+ @storage.write(relative_path, "")
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
+ relative_path = relative_path_from_text_document_uri(params)
56
+ @storage.write(relative_path, text_document_text(params))
57
+ analyze_and_send_offenses(text_document_uri(params))
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
+ relative_path = relative_path_from_text_document_uri(params)
66
+ send_response(
67
+ id: id,
68
+ result: document_links(relative_path)
69
+ )
70
+ end
71
+
72
+ def on_text_document_completion(id, params)
73
+ relative_path = relative_path_from_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(relative_path, 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
 
42
- def analyze_and_send_offenses(file_path)
43
- root = ThemeCheck::Config.find(file_path) || @root_path
44
- config = ThemeCheck::Config.from_path(root)
84
+ def in_memory_storage(root)
85
+ config = config_for_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, config.root)
99
+ end
100
+
101
+ def text_document_uri(params)
102
+ params.dig('textDocument', 'uri').sub('file://', '')
103
+ end
104
+
105
+ def relative_path_from_text_document_uri(params)
106
+ @storage.relative_path(text_document_uri(params))
107
+ end
108
+
109
+ def text_document_text(params)
110
+ params.dig('textDocument', 'text')
111
+ end
112
+
113
+ def content_changes_text(params)
114
+ params.dig('contentChanges', 0, 'text')
115
+ end
116
+
117
+ def config_for_path(path)
118
+ root = ThemeCheck::Config.find(path) || @root_path
119
+ ThemeCheck::Config.from_path(root)
120
+ end
121
+
122
+ def analyze_and_send_offenses(absolute_path)
123
+ config = config_for_path(absolute_path)
45
124
  storage = ThemeCheck::FileSystemStorage.new(
46
125
  config.root,
47
126
  ignored_patterns: config.ignored_patterns
@@ -60,6 +139,14 @@ module ThemeCheck
60
139
  analyzer.offenses
61
140
  end
62
141
 
142
+ def completions(relative_path, line, col)
143
+ @completion_engine.completions(relative_path, line, col)
144
+ end
145
+
146
+ def document_links(relative_path)
147
+ @document_link_engine.document_links(relative_path)
148
+ end
149
+
63
150
  def send_diagnostics(offenses)
64
151
  reported_files = Set.new
65
152
 
@@ -83,19 +170,27 @@ module ThemeCheck
83
170
  send_response(
84
171
  method: 'textDocument/publishDiagnostics',
85
172
  params: {
86
- uri: "file:#{path}",
173
+ uri: "file://#{path}",
87
174
  diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
88
175
  },
89
176
  )
90
177
  end
91
178
 
92
179
  def offense_to_diagnostic(offense)
93
- {
180
+ diagnostic = {
181
+ code: offense.code_name,
182
+ message: offense.message,
94
183
  range: range(offense),
95
184
  severity: severity(offense),
96
- code: offense.code_name,
97
185
  source: "theme-check",
98
- message: offense.message,
186
+ }
187
+ diagnostic["codeDescription"] = code_description(offense) unless offense.doc.nil?
188
+ diagnostic
189
+ end
190
+
191
+ def code_description(offense)
192
+ {
193
+ href: offense.doc,
99
194
  }
100
195
  end
101
196
 
@@ -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