theme-check 0.2.2 → 0.4.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/CHANGELOG.md +40 -0
  4. data/CONTRIBUTING.md +2 -0
  5. data/README.md +45 -4
  6. data/RELEASING.md +41 -0
  7. data/Rakefile +24 -4
  8. data/config/default.yml +16 -0
  9. data/data/shopify_liquid/plus_objects.yml +15 -0
  10. data/data/shopify_liquid/tags.yml +26 -0
  11. data/dev.yml +2 -0
  12. data/lib/theme_check.rb +5 -0
  13. data/lib/theme_check/analyzer.rb +0 -6
  14. data/lib/theme_check/check.rb +11 -0
  15. data/lib/theme_check/checks.rb +10 -0
  16. data/lib/theme_check/checks/missing_enable_comment.rb +31 -0
  17. data/lib/theme_check/checks/parser_blocking_javascript.rb +55 -0
  18. data/lib/theme_check/checks/space_inside_braces.rb +1 -0
  19. data/lib/theme_check/checks/template_length.rb +11 -3
  20. data/lib/theme_check/checks/undefined_object.rb +27 -6
  21. data/lib/theme_check/checks/unused_assign.rb +4 -3
  22. data/lib/theme_check/checks/valid_html_translation.rb +2 -2
  23. data/lib/theme_check/cli.rb +31 -7
  24. data/lib/theme_check/config.rb +95 -43
  25. data/lib/theme_check/corrector.rb +0 -4
  26. data/lib/theme_check/disabled_checks.rb +77 -0
  27. data/lib/theme_check/file_system_storage.rb +51 -0
  28. data/lib/theme_check/in_memory_storage.rb +37 -0
  29. data/lib/theme_check/json_file.rb +12 -10
  30. data/lib/theme_check/language_server.rb +10 -0
  31. data/lib/theme_check/language_server/completion_engine.rb +38 -0
  32. data/lib/theme_check/language_server/completion_helper.rb +35 -0
  33. data/lib/theme_check/language_server/completion_provider.rb +23 -0
  34. data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +47 -0
  35. data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +31 -0
  36. data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +31 -0
  37. data/lib/theme_check/language_server/handler.rb +74 -13
  38. data/lib/theme_check/language_server/position_helper.rb +27 -0
  39. data/lib/theme_check/language_server/protocol.rb +41 -0
  40. data/lib/theme_check/language_server/server.rb +2 -2
  41. data/lib/theme_check/language_server/tokens.rb +55 -0
  42. data/lib/theme_check/offense.rb +51 -14
  43. data/lib/theme_check/shopify_liquid.rb +1 -0
  44. data/lib/theme_check/shopify_liquid/object.rb +6 -0
  45. data/lib/theme_check/shopify_liquid/tag.rb +16 -0
  46. data/lib/theme_check/storage.rb +25 -0
  47. data/lib/theme_check/template.rb +26 -21
  48. data/lib/theme_check/theme.rb +14 -9
  49. data/lib/theme_check/version.rb +1 -1
  50. data/lib/theme_check/visitor.rb +14 -3
  51. data/packaging/homebrew/theme_check.base.rb +10 -6
  52. metadata +22 -2
@@ -9,27 +9,23 @@ module ThemeCheck
9
9
  def insert_after(node, content)
10
10
  line = @template.full_line(node.line_number)
11
11
  line.insert(node.range[1] + 1, content)
12
- @template.update!
13
12
  end
14
13
 
15
14
  def insert_before(node, content)
16
15
  line = @template.full_line(node.line_number)
17
16
  line.insert(node.range[0], content)
18
- @template.update!
19
17
  end
20
18
 
21
19
  def replace(node, content)
22
20
  line = @template.full_line(node.line_number)
23
21
  line[node.range[0]..node.range[1]] = content
24
22
  node.markup = content
25
- @template.update!
26
23
  end
27
24
 
28
25
  def wrap(node, insert_before, insert_after)
29
26
  line = @template.full_line(node.line_number)
30
27
  line.insert(node.range[0], insert_before)
31
28
  line.insert(node.range[1] + 1 + insert_before.length, insert_after)
32
- @template.update!
33
29
  end
34
30
  end
35
31
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class DisabledChecks
5
+ DISABLE_START = 'theme-check-disable'
6
+ DISABLE_END = 'theme-check-enable'
7
+ DISABLE_PREFIX_PATTERN = /#{DISABLE_START}|#{DISABLE_END}/
8
+
9
+ ACTION_DISABLE_CHECKS = :disable
10
+ ACTION_ENABLE_CHECKS = :enable
11
+ ACTION_UNRELATED_COMMENT = :unrelated
12
+
13
+ def initialize
14
+ @disabled = []
15
+ @all_disabled = false
16
+ @full_document_disabled = false
17
+ end
18
+
19
+ def update(node)
20
+ text = comment_text(node)
21
+
22
+ if start_disabling?(text)
23
+ @disabled = checks_from_text(text)
24
+ @all_disabled = @disabled.empty?
25
+
26
+ if node&.line_number == 1
27
+ @full_document_disabled = true
28
+ end
29
+ elsif stop_disabling?(text)
30
+ checks = checks_from_text(text)
31
+ @disabled = checks.empty? ? [] : @disabled - checks
32
+
33
+ @all_disabled = false
34
+ end
35
+ end
36
+
37
+ # Whether any checks are currently disabled
38
+ def any?
39
+ !@disabled.empty? || @all_disabled
40
+ end
41
+
42
+ # Whether all checks should be disabled
43
+ def all_disabled?
44
+ @all_disabled
45
+ end
46
+
47
+ # Get a list of all the individual disabled checks
48
+ def all
49
+ @disabled
50
+ end
51
+
52
+ # If the first line of the document is a theme-check-disable comment
53
+ def full_document_disabled?
54
+ @full_document_disabled
55
+ end
56
+
57
+ private
58
+
59
+ def comment_text(node)
60
+ node.value.nodelist.join
61
+ end
62
+
63
+ def start_disabling?(text)
64
+ text.strip.starts_with?(DISABLE_START)
65
+ end
66
+
67
+ def stop_disabling?(text)
68
+ text.strip.starts_with?(DISABLE_END)
69
+ end
70
+
71
+ # Return a list of checks from a theme-check-disable comment
72
+ # Returns [] if all checks are meant to be disabled
73
+ def checks_from_text(text)
74
+ text.gsub(DISABLE_PREFIX_PATTERN, '').strip.split(',').map(&:strip)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ require "pathname"
3
+
4
+ module ThemeCheck
5
+ class FileSystemStorage < Storage
6
+ attr_reader :root
7
+
8
+ def initialize(root, ignored_patterns: [])
9
+ @root = Pathname.new(root)
10
+ @ignored_patterns = ignored_patterns
11
+ @files = {}
12
+ end
13
+
14
+ def path(relative_path)
15
+ @root.join(relative_path)
16
+ end
17
+
18
+ def read(relative_path)
19
+ file(relative_path).read
20
+ end
21
+
22
+ def write(relative_path, content)
23
+ file(relative_path).write(content)
24
+ end
25
+
26
+ def files
27
+ @file_array ||= glob("**/*")
28
+ .map { |path| path.relative_path_from(@root).to_s }
29
+ end
30
+
31
+ def directories
32
+ @directories ||= glob('*')
33
+ .select { |f| File.directory?(f) }
34
+ .map { |f| f.relative_path_from(@root).to_s }
35
+ end
36
+
37
+ private
38
+
39
+ def glob(pattern)
40
+ @root.glob(pattern).reject do |path|
41
+ relative_path = path.relative_path_from(@root)
42
+ @ignored_patterns.any? { |ignored| relative_path.fnmatch?(ignored) }
43
+ end
44
+ end
45
+
46
+ def file(name)
47
+ return @files[name] if @files[name]
48
+ @files[name] = root.join(name)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An in-memory storage is not written to disk. The reasons why you'd
4
+ # want to do that are your own. The idea is to not write to disk
5
+ # something that doesn't need to be there. If you have your template
6
+ # as a big hash already, leave it like that and save yourself some IO.
7
+ module ThemeCheck
8
+ class InMemoryStorage < Storage
9
+ def initialize(files = {})
10
+ @files = files
11
+ end
12
+
13
+ def path(name)
14
+ name
15
+ end
16
+
17
+ def read(name)
18
+ @files[name]
19
+ end
20
+
21
+ def write(name, content)
22
+ @files[name] = content
23
+ end
24
+
25
+ def files
26
+ @values ||= @files.keys
27
+ end
28
+
29
+ def directories
30
+ @directories ||= @files
31
+ .keys
32
+ .flat_map { |relative_path| Pathname.new(relative_path).ascend.to_a }
33
+ .map(&:to_s)
34
+ .uniq
35
+ end
36
+ end
37
+ end
@@ -4,22 +4,20 @@ require "pathname"
4
4
 
5
5
  module ThemeCheck
6
6
  class JsonFile
7
- attr_reader :path
8
-
9
- def initialize(path, root)
10
- @path = Pathname(path)
11
- @root = Pathname(root)
7
+ def initialize(relative_path, storage)
8
+ @relative_path = relative_path
9
+ @storage = storage
12
10
  @loaded = false
13
11
  @content = nil
14
12
  @parser_error = nil
15
13
  end
16
14
 
17
- def relative_path
18
- @path.relative_path_from(@root)
15
+ def path
16
+ @storage.path(@relative_path)
19
17
  end
20
18
 
21
- def name
22
- relative_path.sub_ext('').to_s
19
+ def relative_path
20
+ @relative_pathname ||= Pathname.new(@relative_path)
23
21
  end
24
22
 
25
23
  def content
@@ -32,12 +30,16 @@ module ThemeCheck
32
30
  @parser_error
33
31
  end
34
32
 
33
+ def name
34
+ relative_path.sub_ext('').to_s
35
+ end
36
+
35
37
  private
36
38
 
37
39
  def load!
38
40
  return if @loaded
39
41
 
40
- @content = JSON.parse(File.read(@path))
42
+ @content = JSON.parse(@storage.read(@relative_path))
41
43
  rescue JSON::ParserError => e
42
44
  @parser_error = e
43
45
  ensure
@@ -1,6 +1,16 @@
1
1
  # frozen_string_literal: true
2
+ require_relative "language_server/protocol"
2
3
  require_relative "language_server/handler"
3
4
  require_relative "language_server/server"
5
+ require_relative "language_server/tokens"
6
+ require_relative "language_server/position_helper"
7
+ require_relative "language_server/completion_helper"
8
+ require_relative "language_server/completion_provider"
9
+ require_relative "language_server/completion_engine"
10
+
11
+ Dir[__dir__ + "/language_server/completion_providers/*.rb"].each do |file|
12
+ require file
13
+ end
4
14
 
5
15
  module ThemeCheck
6
16
  module LanguageServer
@@ -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,35 @@
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
+
24
+ def matches(s, re)
25
+ start_at = 0
26
+ matches = []
27
+ while (m = s.match(re, start_at))
28
+ matches.push(m)
29
+ start_at = m.end(0)
30
+ end
31
+ matches
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class CompletionProvider
6
+ include CompletionHelper
7
+
8
+ class << self
9
+ def all
10
+ @all ||= []
11
+ end
12
+
13
+ def inherited(subclass)
14
+ all << subclass
15
+ end
16
+ end
17
+
18
+ def completions(content, cursor)
19
+ raise NotImplementedError
20
+ end
21
+ end
22
+ end
23
+ 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