theme-check 0.3.0 → 0.5.0

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