theme-check 0.1.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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/.github/probots.yml +3 -0
  3. data/.github/workflows/theme-check.yml +28 -0
  4. data/.gitignore +13 -0
  5. data/.rubocop.yml +18 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/CONTRIBUTING.md +132 -0
  8. data/Gemfile +26 -0
  9. data/Guardfile +7 -0
  10. data/LICENSE.md +8 -0
  11. data/README.md +71 -0
  12. data/Rakefile +14 -0
  13. data/bin/liquid-server +4 -0
  14. data/config/default.yml +63 -0
  15. data/data/shopify_liquid/filters.yml +174 -0
  16. data/data/shopify_liquid/objects.yml +81 -0
  17. data/dev.yml +23 -0
  18. data/docs/preview.png +0 -0
  19. data/exe/theme-check +6 -0
  20. data/exe/theme-check-language-server +12 -0
  21. data/lib/theme_check.rb +25 -0
  22. data/lib/theme_check/analyzer.rb +43 -0
  23. data/lib/theme_check/check.rb +92 -0
  24. data/lib/theme_check/checks.rb +12 -0
  25. data/lib/theme_check/checks/convert_include_to_render.rb +13 -0
  26. data/lib/theme_check/checks/default_locale.rb +12 -0
  27. data/lib/theme_check/checks/liquid_tag.rb +48 -0
  28. data/lib/theme_check/checks/matching_schema_translations.rb +73 -0
  29. data/lib/theme_check/checks/matching_translations.rb +29 -0
  30. data/lib/theme_check/checks/missing_required_template_files.rb +29 -0
  31. data/lib/theme_check/checks/missing_template.rb +25 -0
  32. data/lib/theme_check/checks/nested_snippet.rb +46 -0
  33. data/lib/theme_check/checks/required_directories.rb +24 -0
  34. data/lib/theme_check/checks/required_layout_theme_object.rb +40 -0
  35. data/lib/theme_check/checks/space_inside_braces.rb +58 -0
  36. data/lib/theme_check/checks/syntax_error.rb +29 -0
  37. data/lib/theme_check/checks/template_length.rb +18 -0
  38. data/lib/theme_check/checks/translation_key_exists.rb +35 -0
  39. data/lib/theme_check/checks/undefined_object.rb +86 -0
  40. data/lib/theme_check/checks/unknown_filter.rb +25 -0
  41. data/lib/theme_check/checks/unused_assign.rb +54 -0
  42. data/lib/theme_check/checks/unused_snippet.rb +34 -0
  43. data/lib/theme_check/checks/valid_html_translation.rb +43 -0
  44. data/lib/theme_check/checks/valid_json.rb +14 -0
  45. data/lib/theme_check/checks/valid_schema.rb +13 -0
  46. data/lib/theme_check/checks_tracking.rb +8 -0
  47. data/lib/theme_check/cli.rb +78 -0
  48. data/lib/theme_check/config.rb +108 -0
  49. data/lib/theme_check/json_check.rb +11 -0
  50. data/lib/theme_check/json_file.rb +47 -0
  51. data/lib/theme_check/json_helpers.rb +9 -0
  52. data/lib/theme_check/language_server.rb +11 -0
  53. data/lib/theme_check/language_server/handler.rb +117 -0
  54. data/lib/theme_check/language_server/server.rb +140 -0
  55. data/lib/theme_check/liquid_check.rb +13 -0
  56. data/lib/theme_check/locale_diff.rb +69 -0
  57. data/lib/theme_check/node.rb +117 -0
  58. data/lib/theme_check/offense.rb +104 -0
  59. data/lib/theme_check/parsing_helpers.rb +17 -0
  60. data/lib/theme_check/printer.rb +74 -0
  61. data/lib/theme_check/shopify_liquid.rb +3 -0
  62. data/lib/theme_check/shopify_liquid/filter.rb +18 -0
  63. data/lib/theme_check/shopify_liquid/object.rb +16 -0
  64. data/lib/theme_check/tags.rb +146 -0
  65. data/lib/theme_check/template.rb +73 -0
  66. data/lib/theme_check/theme.rb +60 -0
  67. data/lib/theme_check/version.rb +4 -0
  68. data/lib/theme_check/visitor.rb +37 -0
  69. data/theme-check.gemspec +28 -0
  70. metadata +156 -0
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class JsonCheck < Check
5
+ extend ChecksTracking
6
+
7
+ def add_offense(message, markup: nil, line_number: nil, template: nil)
8
+ offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, template: template)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "pathname"
4
+
5
+ module ThemeCheck
6
+ class JsonFile
7
+ attr_reader :path
8
+
9
+ def initialize(path, root)
10
+ @path = Pathname(path)
11
+ @root = Pathname(root)
12
+ @loaded = false
13
+ @content = nil
14
+ @parser_error = nil
15
+ end
16
+
17
+ def relative_path
18
+ @path.relative_path_from(@root)
19
+ end
20
+
21
+ def name
22
+ relative_path.sub_ext('').to_s
23
+ end
24
+
25
+ def content
26
+ load!
27
+ @content
28
+ end
29
+
30
+ def parse_error
31
+ load!
32
+ @parser_error
33
+ end
34
+
35
+ private
36
+
37
+ def load!
38
+ return if @loaded
39
+
40
+ @content = JSON.parse(File.read(@path))
41
+ rescue JSON::ParserError => e
42
+ @parser_error = e
43
+ ensure
44
+ @loaded = true
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ module JsonHelpers
4
+ def format_json_parse_error(error)
5
+ message = error.message[/\d+: (.+)$/, 1] || 'Invalid syntax'
6
+ "#{message} in JSON"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ require_relative "language_server/handler"
3
+ require_relative "language_server/server"
4
+
5
+ module ThemeCheck
6
+ module LanguageServer
7
+ def self.start
8
+ Server.new.listen
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class Handler
6
+ CAPABILITIES = {
7
+ textDocumentSync: {
8
+ openClose: true,
9
+ change: false,
10
+ willSave: false,
11
+ save: true,
12
+ },
13
+ }
14
+
15
+ def initialize(server)
16
+ @server = server
17
+ end
18
+
19
+ def on_initialize(id, params)
20
+ @root_path = params["rootPath"]
21
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
22
+ send_response(
23
+ id: id,
24
+ result: {
25
+ capabilities: CAPABILITIES,
26
+ }
27
+ )
28
+ end
29
+
30
+ def on_exit(_id, _params)
31
+ close!
32
+ end
33
+
34
+ def on_text_document_did_open(_id, params)
35
+ analyze_and_send_offenses(params.dig('textDocument', 'uri').sub('file://', ''))
36
+ end
37
+ alias_method :on_text_document_did_save, :on_text_document_did_open
38
+
39
+ private
40
+
41
+ def analyze_and_send_offenses(file_path)
42
+ root = ThemeCheck::Config.find(file_path) || @root_path
43
+ config = ThemeCheck::Config.from_path(root)
44
+ theme = ThemeCheck::Theme.new(config.root)
45
+ analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
46
+
47
+ log("Checking #{config.root}")
48
+ analyzer.analyze_theme
49
+ log("Found #{theme.all.size} templates, and #{analyzer.offenses.size} offenses")
50
+ send_offenses(analyzer.offenses)
51
+ end
52
+
53
+ def send_offenses(offenses)
54
+ offenses.group_by(&:template).each do |template, template_offenses|
55
+ next unless template
56
+ # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
57
+ send_response(
58
+ method: 'textDocument/publishDiagnostics',
59
+ params: {
60
+ uri: "file:#{template.path}",
61
+ diagnostics: template_offenses.map { |offense| offense_to_diagnostic(offense) },
62
+ },
63
+ )
64
+ end
65
+ end
66
+
67
+ def offense_to_diagnostic(offense)
68
+ {
69
+ range: range(offense),
70
+ severity: severity(offense),
71
+ code: offense.code_name,
72
+ source: "theme-check",
73
+ message: offense.message,
74
+ }
75
+ end
76
+
77
+ def severity(offense)
78
+ case offense.severity
79
+ when :error
80
+ 1
81
+ when :suggestion
82
+ 2
83
+ when :style
84
+ 3
85
+ else
86
+ 4
87
+ end
88
+ end
89
+
90
+ def range(offense)
91
+ {
92
+ start: {
93
+ line: offense.start_line,
94
+ character: offense.start_column,
95
+ },
96
+ end: {
97
+ line: offense.end_line,
98
+ character: offense.end_column,
99
+ },
100
+ }
101
+ end
102
+
103
+ def send_response(message)
104
+ message[:jsonrpc] = '2.0'
105
+ @server.send_response(message)
106
+ end
107
+
108
+ def log(message)
109
+ @server.log(message)
110
+ end
111
+
112
+ def close!
113
+ raise DoneStreaming
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'stringio'
4
+ require 'active_support/core_ext/string/inflections'
5
+
6
+ module ThemeCheck
7
+ module LanguageServer
8
+ class DoneStreaming < StandardError; end
9
+ class IncompatibleStream < StandardError; end
10
+
11
+ class Server
12
+ def initialize(
13
+ in_stream: STDIN,
14
+ out_stream: STDOUT,
15
+ err_stream: $DEBUG ? File.open('/tmp/lsp.log', 'a') : STDERR
16
+ )
17
+ validate!([in_stream, out_stream, err_stream])
18
+
19
+ @handler = Handler.new(self)
20
+ @in = in_stream
21
+ @out = out_stream
22
+ @err = err_stream
23
+
24
+ @out.sync = true # do not buffer
25
+ @err.sync = true # do not buffer
26
+ end
27
+
28
+ def listen
29
+ loop do
30
+ process_request
31
+
32
+ # support ctrl+c and stuff
33
+ rescue SignalException, DoneStreaming
34
+ cleanup
35
+ return 0
36
+
37
+ rescue Exception => e # rubocop:disable Lint/RescueException
38
+ log(e)
39
+ log(e.backtrace)
40
+ return 1
41
+ end
42
+ end
43
+
44
+ def send_response(response)
45
+ response_body = JSON.dump(response)
46
+ log(response_body) if $DEBUG
47
+
48
+ @out.write("Content-Length: #{response_body.size}\r\n")
49
+ @out.write("\r\n")
50
+ @out.write(response_body)
51
+ @out.flush
52
+ end
53
+
54
+ def log(message)
55
+ @err.puts(message)
56
+ @err.flush
57
+ end
58
+
59
+ private
60
+
61
+ def supported_io_classes
62
+ [IO, StringIO]
63
+ end
64
+
65
+ def validate!(streams = [])
66
+ streams.each do |stream|
67
+ unless supported_io_classes.find { |klass| stream.is_a?(klass) }
68
+ raise IncompatibleStream, incompatible_stream_message
69
+ end
70
+ end
71
+ end
72
+
73
+ def incompatible_stream_message
74
+ 'if provided, in_stream, out_stream, and err_stream must be a kind of '\
75
+ "one of the following: #{supported_io_classes.join(', ')}"
76
+ end
77
+
78
+ def process_request
79
+ request_body = read_new_content
80
+ request_json = JSON.parse(request_body)
81
+ log(JSON.pretty_generate(request_json)) if $DEBUG
82
+
83
+ id = request_json['id']
84
+ method_name = request_json['method']
85
+ params = request_json['params']
86
+ method_name = "on_#{to_snake_case(method_name)}"
87
+
88
+ if @handler.respond_to?(method_name)
89
+ @handler.send(method_name, id, params)
90
+ else
91
+ log("Handler does not respond to #{method_name}")
92
+ end
93
+ end
94
+
95
+ def to_snake_case(method_name)
96
+ method_name.gsub(/[^\w]/, '_').underscore
97
+ end
98
+
99
+ def initial_line
100
+ # Scanning for lines that fit the protocol.
101
+ while true
102
+ initial_line = @in.gets
103
+ # gets returning nil means the stream was closed.
104
+ raise DoneStreaming if initial_line.nil?
105
+
106
+ if initial_line.match(/Content-Length: (\d+)/)
107
+ break
108
+ end
109
+ end
110
+ initial_line
111
+ end
112
+
113
+ def read_new_content
114
+ length = initial_line.match(/Content-Length: (\d+)/)[1].to_i
115
+ content = ''
116
+ while content.length < length + 2
117
+ begin
118
+ # Why + 2? Because \r\n
119
+ content += @in.read(length + 2)
120
+ rescue => e
121
+ log(e)
122
+ log(e.backtrace)
123
+ # We have almost certainly been disconnected from the server
124
+ cleanup
125
+ raise DoneStreaming
126
+ end
127
+ end
128
+
129
+ content
130
+ end
131
+
132
+ def cleanup
133
+ @err.close
134
+ @out.close
135
+ rescue
136
+ # I did my best
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ require_relative "parsing_helpers"
3
+
4
+ module ThemeCheck
5
+ class LiquidCheck < Check
6
+ extend ChecksTracking
7
+ include ParsingHelpers
8
+
9
+ def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil)
10
+ offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class LocaleDiff
4
+ PLURALIZATION_KEYS = Set.new(["zero", "one", "two", "few", "many", "other"])
5
+
6
+ attr_reader :extra_keys, :missing_keys
7
+
8
+ def initialize(default, other)
9
+ @default = default
10
+ @other = other
11
+ @extra_keys = []
12
+ @missing_keys = []
13
+
14
+ visit_object(@default, @other, [])
15
+ end
16
+
17
+ def add_as_offenses(check, key_prefix: [], node: nil, template: nil)
18
+ if extra_keys.any?
19
+ add_keys_offense(check, "Extra translation keys", extra_keys,
20
+ key_prefix: key_prefix, node: node, template: template)
21
+ end
22
+
23
+ if missing_keys.any?
24
+ add_keys_offense(check, "Missing translation keys", missing_keys,
25
+ key_prefix: key_prefix, node: node, template: template)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def add_keys_offense(check, cause, keys, key_prefix:, node: nil, template: nil)
32
+ message = "#{cause}: #{format_keys(key_prefix, keys)}"
33
+ if node
34
+ check.add_offense(message, node: node)
35
+ else
36
+ check.add_offense(message, template: template)
37
+ end
38
+ end
39
+
40
+ def format_keys(key_prefix, keys)
41
+ keys.map { |path| (key_prefix + path).join(".") }.join(", ")
42
+ end
43
+
44
+ def visit_object(default, other, path)
45
+ default = {} unless default.is_a?(Hash)
46
+ other = {} unless other.is_a?(Hash)
47
+ return if pluralization?(default) && pluralization?(other)
48
+
49
+ @extra_keys += (other.keys - default.keys).map { |key| path + [key] }
50
+
51
+ default.each do |key, default_value|
52
+ translated_value = other[key]
53
+ new_path = path + [key]
54
+
55
+ if translated_value.nil?
56
+ @missing_keys << new_path
57
+ else
58
+ visit_object(default_value, translated_value, new_path)
59
+ end
60
+ end
61
+ end
62
+
63
+ def pluralization?(hash)
64
+ hash.all? do |key, value|
65
+ PLURALIZATION_KEYS.include?(key) && !value.is_a?(Hash)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+ require 'active_support/core_ext/string/inflections'
3
+
4
+ module ThemeCheck
5
+ # A node from the Liquid AST, the result of parsing a template.
6
+ class Node
7
+ attr_reader :value, :parent, :template
8
+
9
+ def initialize(value, parent, template)
10
+ raise ArgumentError, "Expected a Liquid AST Node" if value.is_a?(Node)
11
+ @value = value
12
+ @parent = parent
13
+ @template = template
14
+ end
15
+
16
+ # The original source code of the node. Doesn't contain wrapping braces.
17
+ def markup
18
+ if tag?
19
+ @value.raw
20
+ elsif @value.instance_variable_defined?(:@markup)
21
+ @value.instance_variable_get(:@markup)
22
+ end
23
+ end
24
+
25
+ # Array of children nodes.
26
+ def children
27
+ @children ||= begin
28
+ nodes =
29
+ if comment?
30
+ []
31
+ elsif defined?(@value.class::ParseTreeVisitor)
32
+ @value.class::ParseTreeVisitor.new(@value, {}).children
33
+ elsif @value.respond_to?(:nodelist)
34
+ Array(@value.nodelist)
35
+ else
36
+ []
37
+ end
38
+ # Work around a bug in Liquid::Variable::ParseTreeVisitor that doesn't return
39
+ # the args in a hash as children nodes.
40
+ nodes = nodes.flat_map do |node|
41
+ case node
42
+ when Hash
43
+ node.values
44
+ else
45
+ node
46
+ end
47
+ end
48
+ nodes.map { |node| Node.new(node, self, @template) }
49
+ end
50
+ end
51
+
52
+ # Literals are hard-coded values in the template.
53
+ def literal?
54
+ @value.is_a?(String) || @value.is_a?(Integer)
55
+ end
56
+
57
+ # A {% tag %} node?
58
+ def tag?
59
+ @value.is_a?(Liquid::Tag)
60
+ end
61
+
62
+ # A {% comment %} block node?
63
+ def comment?
64
+ @value.is_a?(Liquid::Comment)
65
+ end
66
+
67
+ # Top level node of every template.
68
+ def document?
69
+ @value.is_a?(Liquid::Document)
70
+ end
71
+ alias_method :root?, :document?
72
+
73
+ # A {% tag %}...{% endtag %} node?
74
+ def block_tag?
75
+ @value.is_a?(Liquid::Block)
76
+ end
77
+
78
+ # The body of blocks
79
+ def block_body?
80
+ @value.is_a?(Liquid::BlockBody)
81
+ end
82
+
83
+ # A block of type of node?
84
+ def block?
85
+ block_tag? || block_body? || document?
86
+ end
87
+
88
+ # Most nodes have a line number, but it's not guaranteed.
89
+ def line_number
90
+ @value.line_number if @value.respond_to?(:line_number)
91
+ end
92
+
93
+ # The `:under_score_name` of this type of node. Used to dispatch to the `on_<type_name>`
94
+ # and `after_<type_name>` check methods.
95
+ def type_name
96
+ @type_name ||= @value.class.name.demodulize.underscore.to_sym
97
+ end
98
+
99
+ # Is this node inside a `{% liquid ... %}` block?
100
+ def inside_liquid_tag?
101
+ if line_number
102
+ template.excerpt(line_number).start_with?("{%")
103
+ else
104
+ false
105
+ end
106
+ end
107
+
108
+ # Is this node inside a `{%- ... -%}`
109
+ def whitespace_trimmed?
110
+ if line_number
111
+ template.excerpt(line_number).start_with?("{%-")
112
+ else
113
+ false
114
+ end
115
+ end
116
+ end
117
+ end