theme-check 0.3.0 → 0.5.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -0
  3. data/CHANGELOG.md +50 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/README.md +9 -4
  6. data/RELEASING.md +2 -2
  7. data/config/default.yml +7 -0
  8. data/data/shopify_liquid/tags.yml +27 -0
  9. data/docs/checks/CHECK_DOCS_TEMPLATE.md +47 -0
  10. data/docs/checks/asset_size_javascript.md +79 -0
  11. data/docs/checks/convert_include_to_render.md +48 -0
  12. data/docs/checks/default_locale.md +46 -0
  13. data/docs/checks/deprecated_filter.md +46 -0
  14. data/docs/checks/liquid_tag.md +65 -0
  15. data/docs/checks/matching_schema_translations.md +93 -0
  16. data/docs/checks/matching_translations.md +72 -0
  17. data/docs/checks/missing_enable_comment.md +50 -0
  18. data/docs/checks/missing_required_template_files.md +26 -0
  19. data/docs/checks/missing_template.md +40 -0
  20. data/docs/checks/nested_snippet.md +69 -0
  21. data/docs/checks/parser_blocking_javascript.md +97 -0
  22. data/docs/checks/required_directories.md +25 -0
  23. data/docs/checks/required_layout_theme_object.md +28 -0
  24. data/docs/checks/space_inside_braces.md +63 -0
  25. data/docs/checks/syntax_error.md +49 -0
  26. data/docs/checks/template_length.md +50 -0
  27. data/docs/checks/translation_key_exists.md +63 -0
  28. data/docs/checks/undefined_object.md +53 -0
  29. data/docs/checks/unknown_filter.md +45 -0
  30. data/docs/checks/unused_assign.md +47 -0
  31. data/docs/checks/unused_snippet.md +32 -0
  32. data/docs/checks/valid_html_translation.md +53 -0
  33. data/docs/checks/valid_json.md +60 -0
  34. data/docs/checks/valid_schema.md +50 -0
  35. data/lib/theme_check.rb +4 -0
  36. data/lib/theme_check/asset_file.rb +34 -0
  37. data/lib/theme_check/check.rb +19 -9
  38. data/lib/theme_check/checks/asset_size_javascript.rb +74 -0
  39. data/lib/theme_check/checks/convert_include_to_render.rb +1 -1
  40. data/lib/theme_check/checks/default_locale.rb +1 -0
  41. data/lib/theme_check/checks/deprecated_filter.rb +1 -1
  42. data/lib/theme_check/checks/liquid_tag.rb +3 -3
  43. data/lib/theme_check/checks/matching_schema_translations.rb +1 -0
  44. data/lib/theme_check/checks/matching_translations.rb +1 -0
  45. data/lib/theme_check/checks/missing_enable_comment.rb +1 -0
  46. data/lib/theme_check/checks/missing_required_template_files.rb +1 -2
  47. data/lib/theme_check/checks/missing_template.rb +1 -0
  48. data/lib/theme_check/checks/nested_snippet.rb +1 -0
  49. data/lib/theme_check/checks/parser_blocking_javascript.rb +2 -1
  50. data/lib/theme_check/checks/required_directories.rb +1 -1
  51. data/lib/theme_check/checks/required_layout_theme_object.rb +1 -1
  52. data/lib/theme_check/checks/space_inside_braces.rb +1 -0
  53. data/lib/theme_check/checks/syntax_error.rb +1 -0
  54. data/lib/theme_check/checks/template_length.rb +1 -0
  55. data/lib/theme_check/checks/translation_key_exists.rb +1 -0
  56. data/lib/theme_check/checks/undefined_object.rb +29 -10
  57. data/lib/theme_check/checks/unknown_filter.rb +1 -0
  58. data/lib/theme_check/checks/unused_assign.rb +5 -3
  59. data/lib/theme_check/checks/unused_snippet.rb +1 -0
  60. data/lib/theme_check/checks/valid_html_translation.rb +1 -0
  61. data/lib/theme_check/checks/valid_json.rb +1 -0
  62. data/lib/theme_check/checks/valid_schema.rb +1 -0
  63. data/lib/theme_check/cli.rb +22 -6
  64. data/lib/theme_check/config.rb +2 -2
  65. data/lib/theme_check/in_memory_storage.rb +1 -1
  66. data/lib/theme_check/language_server.rb +10 -0
  67. data/lib/theme_check/language_server/completion_engine.rb +38 -0
  68. data/lib/theme_check/language_server/completion_helper.rb +25 -0
  69. data/lib/theme_check/language_server/completion_provider.rb +24 -0
  70. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +47 -0
  71. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +31 -0
  72. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +31 -0
  73. data/lib/theme_check/language_server/handler.rb +62 -6
  74. data/lib/theme_check/language_server/position_helper.rb +27 -0
  75. data/lib/theme_check/language_server/protocol.rb +41 -0
  76. data/lib/theme_check/language_server/server.rb +6 -1
  77. data/lib/theme_check/language_server/tokens.rb +55 -0
  78. data/lib/theme_check/offense.rb +51 -14
  79. data/lib/theme_check/regex_helpers.rb +15 -0
  80. data/lib/theme_check/remote_asset_file.rb +44 -0
  81. data/lib/theme_check/shopify_liquid.rb +1 -0
  82. data/lib/theme_check/shopify_liquid/tag.rb +16 -0
  83. data/lib/theme_check/theme.rb +7 -1
  84. data/lib/theme_check/version.rb +1 -1
  85. metadata +44 -2
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class CompletionEngine
6
+ include PositionHelper
7
+
8
+ def initialize(storage)
9
+ @storage = storage
10
+ @providers = CompletionProvider.all.map(&:new)
11
+ end
12
+
13
+ def completions(name, line, col)
14
+ buffer = @storage.read(name)
15
+ cursor = from_line_column_to_index(buffer, line, col)
16
+ token = find_token(buffer, cursor)
17
+ return [] if token.nil?
18
+
19
+ @providers.flat_map do |p|
20
+ p.completions(
21
+ token.content,
22
+ cursor - token.start
23
+ )
24
+ end
25
+ end
26
+
27
+ def find_token(buffer, cursor)
28
+ Tokens.new(buffer).find do |token|
29
+ # Here we include the next character and exclude the first
30
+ # one becase when we want to autocomplete inside a token
31
+ # and at most 1 outside it since the cursor could be placed
32
+ # at the end of the token.
33
+ token.start < cursor && cursor <= token.end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ module CompletionHelper
6
+ WORD = /\w+/
7
+
8
+ def cursor_on_start_content?(content, cursor, regex)
9
+ content.slice(0, cursor).match?(/#{regex}(?:\s|\n)*$/m)
10
+ end
11
+
12
+ def cursor_on_first_word?(content, cursor)
13
+ word = content.match(WORD)
14
+ return false if word.nil?
15
+ word_start = word.begin(0)
16
+ word_end = word.end(0)
17
+ word_start <= cursor && cursor <= word_end
18
+ end
19
+
20
+ def first_word(content)
21
+ return content.match(WORD)[0] if content.match?(WORD)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,24 @@
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 completions(content, cursor)
20
+ raise NotImplementedError
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,47 @@
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
+ ShopifyLiquid::Filter.labels
11
+ .select { |w| w.starts_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 cursor_on_filter?(content, cursor)
25
+ return false unless content.match?(NAMED_FILTER)
26
+ matches(content, NAMED_FILTER).any? do |match|
27
+ match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
28
+ end
29
+ end
30
+
31
+ def partial(content, cursor)
32
+ return '' unless content.match?(NAMED_FILTER)
33
+ partial_match = matches(content, NAMED_FILTER).find do |match|
34
+ match.begin(1) <= cursor && cursor < match.end(1) + 1 # including next character
35
+ end
36
+ partial_match[1]
37
+ end
38
+
39
+ def filter_to_completion(filter)
40
+ {
41
+ label: filter,
42
+ kind: CompletionItemKinds::FUNCTION,
43
+ }
44
+ end
45
+ end
46
+ end
47
+ 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.starts_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,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
  },
@@ -15,6 +19,8 @@ module ThemeCheck
15
19
  def initialize(server)
16
20
  @server = server
17
21
  @previously_reported_files = Set.new
22
+ @storage = InMemoryStorage.new
23
+ @completion_engine = CompletionEngine.new(@storage)
18
24
  end
19
25
 
20
26
  def on_initialize(id, params)
@@ -31,14 +37,52 @@ module ThemeCheck
31
37
  def on_exit(_id, _params)
32
38
  close!
33
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
34
51
 
35
52
  def on_text_document_did_open(_id, params)
36
- 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
+ )
37
70
  end
38
- alias_method :on_text_document_did_save, :on_text_document_did_open
39
71
 
40
72
  private
41
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
+
42
86
  def analyze_and_send_offenses(file_path)
43
87
  root = ThemeCheck::Config.find(file_path) || @root_path
44
88
  config = ThemeCheck::Config.from_path(root)
@@ -60,6 +104,10 @@ module ThemeCheck
60
104
  analyzer.offenses
61
105
  end
62
106
 
107
+ def completions(uri, line, col)
108
+ @completion_engine.completions(uri, line, col)
109
+ end
110
+
63
111
  def send_diagnostics(offenses)
64
112
  reported_files = Set.new
65
113
 
@@ -90,12 +138,20 @@ module ThemeCheck
90
138
  end
91
139
 
92
140
  def offense_to_diagnostic(offense)
93
- {
141
+ diagnostic = {
142
+ code: offense.code_name,
143
+ message: offense.message,
94
144
  range: range(offense),
95
145
  severity: severity(offense),
96
- code: offense.code_name,
97
146
  source: "theme-check",
98
- message: offense.message,
147
+ }
148
+ diagnostic["codeDescription"] = code_description(offense) unless offense.doc.nil?
149
+ diagnostic
150
+ end
151
+
152
+ def code_description(offense)
153
+ {
154
+ href: offense.doc,
99
155
  }
100
156
  end
101
157
 
@@ -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
@@ -10,11 +10,13 @@ module ThemeCheck
10
10
 
11
11
  class Server
12
12
  attr_reader :handler
13
+ attr_reader :should_raise_errors
13
14
 
14
15
  def initialize(
15
16
  in_stream: STDIN,
16
17
  out_stream: STDOUT,
17
- err_stream: $DEBUG ? File.open('/tmp/lsp.log', 'a') : STDERR
18
+ err_stream: $DEBUG ? File.open('/tmp/lsp.log', 'a') : STDERR,
19
+ should_raise_errors: false
18
20
  )
19
21
  validate!([in_stream, out_stream, err_stream])
20
22
 
@@ -25,6 +27,8 @@ module ThemeCheck
25
27
 
26
28
  @out.sync = true # do not buffer
27
29
  @err.sync = true # do not buffer
30
+
31
+ @should_raise_errors = should_raise_errors
28
32
  end
29
33
 
30
34
  def listen
@@ -37,6 +41,7 @@ module ThemeCheck
37
41
  return 0
38
42
 
39
43
  rescue Exception => e # rubocop:disable Lint/RescueException
44
+ raise e if should_raise_errors
40
45
  log(e)
41
46
  log(e.backtrace)
42
47
  return 1
@@ -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