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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +40 -0
- data/CONTRIBUTING.md +2 -0
- data/README.md +45 -4
- data/RELEASING.md +41 -0
- data/Rakefile +24 -4
- data/config/default.yml +16 -0
- data/data/shopify_liquid/plus_objects.yml +15 -0
- data/data/shopify_liquid/tags.yml +26 -0
- data/dev.yml +2 -0
- data/lib/theme_check.rb +5 -0
- data/lib/theme_check/analyzer.rb +0 -6
- data/lib/theme_check/check.rb +11 -0
- data/lib/theme_check/checks.rb +10 -0
- data/lib/theme_check/checks/missing_enable_comment.rb +31 -0
- data/lib/theme_check/checks/parser_blocking_javascript.rb +55 -0
- data/lib/theme_check/checks/space_inside_braces.rb +1 -0
- data/lib/theme_check/checks/template_length.rb +11 -3
- data/lib/theme_check/checks/undefined_object.rb +27 -6
- data/lib/theme_check/checks/unused_assign.rb +4 -3
- data/lib/theme_check/checks/valid_html_translation.rb +2 -2
- data/lib/theme_check/cli.rb +31 -7
- data/lib/theme_check/config.rb +95 -43
- data/lib/theme_check/corrector.rb +0 -4
- data/lib/theme_check/disabled_checks.rb +77 -0
- data/lib/theme_check/file_system_storage.rb +51 -0
- data/lib/theme_check/in_memory_storage.rb +37 -0
- data/lib/theme_check/json_file.rb +12 -10
- data/lib/theme_check/language_server.rb +10 -0
- data/lib/theme_check/language_server/completion_engine.rb +38 -0
- data/lib/theme_check/language_server/completion_helper.rb +35 -0
- data/lib/theme_check/language_server/completion_provider.rb +23 -0
- data/lib/theme_check/language_server/completion_providers/filter_completion_provider.rb +47 -0
- data/lib/theme_check/language_server/completion_providers/object_completion_provider.rb +31 -0
- data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +31 -0
- data/lib/theme_check/language_server/handler.rb +74 -13
- data/lib/theme_check/language_server/position_helper.rb +27 -0
- data/lib/theme_check/language_server/protocol.rb +41 -0
- data/lib/theme_check/language_server/server.rb +2 -2
- data/lib/theme_check/language_server/tokens.rb +55 -0
- data/lib/theme_check/offense.rb +51 -14
- data/lib/theme_check/shopify_liquid.rb +1 -0
- data/lib/theme_check/shopify_liquid/object.rb +6 -0
- data/lib/theme_check/shopify_liquid/tag.rb +16 -0
- data/lib/theme_check/storage.rb +25 -0
- data/lib/theme_check/template.rb +26 -21
- data/lib/theme_check/theme.rb +14 -9
- data/lib/theme_check/version.rb +1 -1
- data/lib/theme_check/visitor.rb +14 -3
- data/packaging/homebrew/theme_check.base.rb +10 -6
- metadata +22 -2
@@ -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:
|
13
|
+
change: TextDocumentSyncKind::FULL,
|
10
14
|
willSave: false,
|
11
15
|
save: true,
|
12
16
|
},
|
@@ -14,6 +18,9 @@ module ThemeCheck
|
|
14
18
|
|
15
19
|
def initialize(server)
|
16
20
|
@server = server
|
21
|
+
@previously_reported_files = Set.new
|
22
|
+
@storage = InMemoryStorage.new
|
23
|
+
@completion_engine = CompletionEngine.new(@storage)
|
17
24
|
end
|
18
25
|
|
19
26
|
def on_initialize(id, params)
|
@@ -30,39 +37,93 @@ module ThemeCheck
|
|
30
37
|
def on_exit(_id, _params)
|
31
38
|
close!
|
32
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
|
33
51
|
|
34
52
|
def on_text_document_did_open(_id, params)
|
35
|
-
|
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
|
+
)
|
36
70
|
end
|
37
|
-
alias_method :on_text_document_did_save, :on_text_document_did_open
|
38
71
|
|
39
72
|
private
|
40
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
|
+
|
41
86
|
def analyze_and_send_offenses(file_path)
|
42
87
|
root = ThemeCheck::Config.find(file_path) || @root_path
|
43
88
|
config = ThemeCheck::Config.from_path(root)
|
44
|
-
|
45
|
-
|
89
|
+
storage = ThemeCheck::FileSystemStorage.new(
|
90
|
+
config.root,
|
91
|
+
ignored_patterns: config.ignored_patterns
|
92
|
+
)
|
93
|
+
theme = ThemeCheck::Theme.new(storage)
|
46
94
|
|
95
|
+
offenses = analyze(theme, config)
|
96
|
+
log("Found #{theme.all.size} templates, and #{offenses.size} offenses")
|
97
|
+
send_diagnostics(offenses)
|
98
|
+
end
|
99
|
+
|
100
|
+
def analyze(theme, config)
|
101
|
+
analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
|
47
102
|
log("Checking #{config.root}")
|
48
103
|
analyzer.analyze_theme
|
49
|
-
|
50
|
-
send_diagnostics(analyzer.offenses, theme.all)
|
104
|
+
analyzer.offenses
|
51
105
|
end
|
52
106
|
|
53
|
-
def
|
54
|
-
|
107
|
+
def completions(uri, line, col)
|
108
|
+
@completion_engine.completions(uri, line, col)
|
109
|
+
end
|
110
|
+
|
111
|
+
def send_diagnostics(offenses)
|
112
|
+
reported_files = Set.new
|
55
113
|
|
56
114
|
offenses.group_by(&:template).each do |template, template_offenses|
|
57
115
|
next unless template
|
58
116
|
send_diagnostic(template.path, template_offenses)
|
59
|
-
|
117
|
+
reported_files << template.path
|
60
118
|
end
|
61
119
|
|
62
|
-
# Publish diagnostics with empty array if
|
63
|
-
|
64
|
-
|
120
|
+
# Publish diagnostics with empty array if all issues on a previously reported template
|
121
|
+
# have been solved.
|
122
|
+
(@previously_reported_files - reported_files).each do |path|
|
123
|
+
send_diagnostic(path, [])
|
65
124
|
end
|
125
|
+
|
126
|
+
@previously_reported_files = reported_files
|
66
127
|
end
|
67
128
|
|
68
129
|
def send_diagnostic(path, offenses)
|
@@ -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
|
@@ -9,6 +9,8 @@ module ThemeCheck
|
|
9
9
|
class IncompatibleStream < StandardError; end
|
10
10
|
|
11
11
|
class Server
|
12
|
+
attr_reader :handler
|
13
|
+
|
12
14
|
def initialize(
|
13
15
|
in_stream: STDIN,
|
14
16
|
out_stream: STDOUT,
|
@@ -87,8 +89,6 @@ module ThemeCheck
|
|
87
89
|
|
88
90
|
if @handler.respond_to?(method_name)
|
89
91
|
@handler.send(method_name, id, params)
|
90
|
-
else
|
91
|
-
log("Handler does not respond to #{method_name}")
|
92
92
|
end
|
93
93
|
end
|
94
94
|
|
@@ -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
|
data/lib/theme_check/offense.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
module ThemeCheck
|
3
|
+
Position = Struct.new(:line, :column)
|
4
|
+
|
3
5
|
class Offense
|
4
6
|
MAX_SOURCE_EXCERPT_SIZE = 120
|
5
7
|
|
@@ -35,6 +37,9 @@ module ThemeCheck
|
|
35
37
|
elsif @node
|
36
38
|
@node.line_number
|
37
39
|
end
|
40
|
+
|
41
|
+
@start_position = nil
|
42
|
+
@end_position = nil
|
38
43
|
end
|
39
44
|
|
40
45
|
def source_excerpt
|
@@ -50,27 +55,19 @@ module ThemeCheck
|
|
50
55
|
end
|
51
56
|
|
52
57
|
def start_line
|
53
|
-
|
54
|
-
line_number - 1
|
58
|
+
start_position.line
|
55
59
|
end
|
56
60
|
|
57
|
-
def
|
58
|
-
|
59
|
-
start_line + markup.count("\n")
|
60
|
-
else
|
61
|
-
start_line
|
62
|
-
end
|
61
|
+
def start_column
|
62
|
+
start_position.column
|
63
63
|
end
|
64
64
|
|
65
|
-
def
|
66
|
-
|
67
|
-
template.full_line(start_line + 1).index(markup.split("\n", 2).first)
|
65
|
+
def end_line
|
66
|
+
end_position.line
|
68
67
|
end
|
69
68
|
|
70
69
|
def end_column
|
71
|
-
|
72
|
-
markup_end = markup.split("\n").last
|
73
|
-
template.full_line(end_line + 1).index(markup_end) + markup_end.size
|
70
|
+
end_position.column
|
74
71
|
end
|
75
72
|
|
76
73
|
def code_name
|
@@ -116,5 +113,45 @@ module ThemeCheck
|
|
116
113
|
message
|
117
114
|
end
|
118
115
|
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def full_line(line)
|
120
|
+
# Liquid::Template is 1-indexed.
|
121
|
+
template.full_line(line + 1)
|
122
|
+
end
|
123
|
+
|
124
|
+
def lines_of_content
|
125
|
+
@lines ||= markup.lines.map { |x| x.sub(/\n$/, '') }
|
126
|
+
end
|
127
|
+
|
128
|
+
# 0-indexed, inclusive
|
129
|
+
def start_position
|
130
|
+
return @start_position if @start_position
|
131
|
+
return @start_position = Position.new(0, 0) unless line_number && markup
|
132
|
+
|
133
|
+
position = Position.new
|
134
|
+
position.line = line_number - 1
|
135
|
+
position.column = full_line(position.line).index(lines_of_content.first) || 0
|
136
|
+
|
137
|
+
@start_position = position
|
138
|
+
end
|
139
|
+
|
140
|
+
# 0-indexed, exclusive. It's the line + col that are exclusive.
|
141
|
+
# This is why it doesn't make sense to calculate them separately.
|
142
|
+
def end_position
|
143
|
+
return @end_position if @end_position
|
144
|
+
return @end_position = Position.new(0, 0) unless line_number && markup
|
145
|
+
|
146
|
+
position = Position.new
|
147
|
+
position.line = start_line + lines_of_content.size - 1
|
148
|
+
position.column = if start_line == position.line
|
149
|
+
start_column + markup.size
|
150
|
+
else
|
151
|
+
lines_of_content.last.size
|
152
|
+
end
|
153
|
+
|
154
|
+
@end_position = position
|
155
|
+
end
|
119
156
|
end
|
120
157
|
end
|
@@ -11,6 +11,12 @@ module ThemeCheck
|
|
11
11
|
YAML.load(File.read("#{__dir__}/../../../data/shopify_liquid/objects.yml"))
|
12
12
|
end
|
13
13
|
end
|
14
|
+
|
15
|
+
def plus_labels
|
16
|
+
@plus_labels ||= begin
|
17
|
+
YAML.load(File.read("#{__dir__}/../../../data/shopify_liquid/plus_objects.yml"))
|
18
|
+
end
|
19
|
+
end
|
14
20
|
end
|
15
21
|
end
|
16
22
|
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'yaml'
|
3
|
+
|
4
|
+
module ThemeCheck
|
5
|
+
module ShopifyLiquid
|
6
|
+
module Tag
|
7
|
+
extend self
|
8
|
+
|
9
|
+
def labels
|
10
|
+
@tags ||= begin
|
11
|
+
YAML.load(File.read("#{__dir__}/../../../data/shopify_liquid/tags.yml"))
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
class Storage
|
5
|
+
def read(relative_path)
|
6
|
+
raise NotImplementedError
|
7
|
+
end
|
8
|
+
|
9
|
+
def write(relative_path, content)
|
10
|
+
raise NotImplementedError
|
11
|
+
end
|
12
|
+
|
13
|
+
def path(relative_path)
|
14
|
+
raise NotImplementedError
|
15
|
+
end
|
16
|
+
|
17
|
+
def files
|
18
|
+
raise NotImplementedError
|
19
|
+
end
|
20
|
+
|
21
|
+
def directories
|
22
|
+
raise NotImplementedError
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|