theme-check 0.1.0

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