theme-check 0.3.2 → 0.7.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 (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