theme-check 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/probots.yml +3 -0
- data/.github/workflows/theme-check.yml +28 -0
- data/.gitignore +13 -0
- data/.rubocop.yml +18 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +132 -0
- data/Gemfile +26 -0
- data/Guardfile +7 -0
- data/LICENSE.md +8 -0
- data/README.md +71 -0
- data/Rakefile +14 -0
- data/bin/liquid-server +4 -0
- data/config/default.yml +63 -0
- data/data/shopify_liquid/filters.yml +174 -0
- data/data/shopify_liquid/objects.yml +81 -0
- data/dev.yml +23 -0
- data/docs/preview.png +0 -0
- data/exe/theme-check +6 -0
- data/exe/theme-check-language-server +12 -0
- data/lib/theme_check.rb +25 -0
- data/lib/theme_check/analyzer.rb +43 -0
- data/lib/theme_check/check.rb +92 -0
- data/lib/theme_check/checks.rb +12 -0
- data/lib/theme_check/checks/convert_include_to_render.rb +13 -0
- data/lib/theme_check/checks/default_locale.rb +12 -0
- data/lib/theme_check/checks/liquid_tag.rb +48 -0
- data/lib/theme_check/checks/matching_schema_translations.rb +73 -0
- data/lib/theme_check/checks/matching_translations.rb +29 -0
- data/lib/theme_check/checks/missing_required_template_files.rb +29 -0
- data/lib/theme_check/checks/missing_template.rb +25 -0
- data/lib/theme_check/checks/nested_snippet.rb +46 -0
- data/lib/theme_check/checks/required_directories.rb +24 -0
- data/lib/theme_check/checks/required_layout_theme_object.rb +40 -0
- data/lib/theme_check/checks/space_inside_braces.rb +58 -0
- data/lib/theme_check/checks/syntax_error.rb +29 -0
- data/lib/theme_check/checks/template_length.rb +18 -0
- data/lib/theme_check/checks/translation_key_exists.rb +35 -0
- data/lib/theme_check/checks/undefined_object.rb +86 -0
- data/lib/theme_check/checks/unknown_filter.rb +25 -0
- data/lib/theme_check/checks/unused_assign.rb +54 -0
- data/lib/theme_check/checks/unused_snippet.rb +34 -0
- data/lib/theme_check/checks/valid_html_translation.rb +43 -0
- data/lib/theme_check/checks/valid_json.rb +14 -0
- data/lib/theme_check/checks/valid_schema.rb +13 -0
- data/lib/theme_check/checks_tracking.rb +8 -0
- data/lib/theme_check/cli.rb +78 -0
- data/lib/theme_check/config.rb +108 -0
- data/lib/theme_check/json_check.rb +11 -0
- data/lib/theme_check/json_file.rb +47 -0
- data/lib/theme_check/json_helpers.rb +9 -0
- data/lib/theme_check/language_server.rb +11 -0
- data/lib/theme_check/language_server/handler.rb +117 -0
- data/lib/theme_check/language_server/server.rb +140 -0
- data/lib/theme_check/liquid_check.rb +13 -0
- data/lib/theme_check/locale_diff.rb +69 -0
- data/lib/theme_check/node.rb +117 -0
- data/lib/theme_check/offense.rb +104 -0
- data/lib/theme_check/parsing_helpers.rb +17 -0
- data/lib/theme_check/printer.rb +74 -0
- data/lib/theme_check/shopify_liquid.rb +3 -0
- data/lib/theme_check/shopify_liquid/filter.rb +18 -0
- data/lib/theme_check/shopify_liquid/object.rb +16 -0
- data/lib/theme_check/tags.rb +146 -0
- data/lib/theme_check/template.rb +73 -0
- data/lib/theme_check/theme.rb +60 -0
- data/lib/theme_check/version.rb +4 -0
- data/lib/theme_check/visitor.rb +37 -0
- data/theme-check.gemspec +28 -0
- 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,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
|