theme-check 1.7.0 → 1.9.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/.gitignore +1 -0
- data/CHANGELOG.md +49 -0
- data/README.md +10 -0
- data/RELEASING.md +13 -0
- data/config/default.yml +5 -0
- data/data/shopify_liquid/deprecated_filters.yml +4 -0
- data/data/shopify_liquid/filters.yml +3 -1
- data/data/shopify_liquid/tags.yml +9 -9
- data/docs/checks/TEMPLATE.md.erb +24 -19
- data/docs/checks/schema_json_format.md +76 -0
- data/docs/language_server/code-action-command-palette.png +0 -0
- data/docs/language_server/code-action-flow.png +0 -0
- data/docs/language_server/code-action-keyboard.png +0 -0
- data/docs/language_server/code-action-light-bulb.png +0 -0
- data/docs/language_server/code-action-problem.png +0 -0
- data/docs/language_server/code-action-quickfix.png +0 -0
- data/docs/language_server/how_to_correct_code_with_code_actions_and_execute_command.md +197 -0
- data/exe/theme-check-language-server +0 -4
- data/lib/theme_check/checks/asset_size_app_block_css.rb +2 -3
- data/lib/theme_check/checks/asset_size_app_block_javascript.rb +2 -3
- data/lib/theme_check/checks/asset_url_filters.rb +2 -0
- data/lib/theme_check/checks/default_locale.rb +1 -1
- data/lib/theme_check/checks/deprecated_filter.rb +79 -4
- data/lib/theme_check/checks/deprecated_global_app_block_type.rb +2 -3
- data/lib/theme_check/checks/matching_schema_translations.rb +14 -9
- data/lib/theme_check/checks/matching_translations.rb +1 -0
- data/lib/theme_check/checks/missing_required_template_files.rb +3 -3
- data/lib/theme_check/checks/missing_template.rb +1 -1
- data/lib/theme_check/checks/pagination_size.rb +2 -3
- data/lib/theme_check/checks/remote_asset.rb +5 -0
- data/lib/theme_check/checks/required_directories.rb +1 -1
- data/lib/theme_check/checks/required_layout_theme_object.rb +9 -4
- data/lib/theme_check/checks/schema_json_format.rb +29 -0
- data/lib/theme_check/checks/space_inside_braces.rb +132 -87
- data/lib/theme_check/checks/translation_key_exists.rb +33 -25
- data/lib/theme_check/checks/unused_assign.rb +3 -2
- data/lib/theme_check/checks/unused_snippet.rb +1 -1
- data/lib/theme_check/checks/valid_html_translation.rb +1 -1
- data/lib/theme_check/checks/valid_schema.rb +2 -2
- data/lib/theme_check/corrector.rb +34 -23
- data/lib/theme_check/exceptions.rb +1 -0
- data/lib/theme_check/file_system_storage.rb +8 -3
- data/lib/theme_check/html_node.rb +99 -6
- data/lib/theme_check/html_visitor.rb +1 -32
- data/lib/theme_check/in_memory_storage.rb +9 -0
- data/lib/theme_check/json_helpers.rb +14 -0
- data/lib/theme_check/language_server/bridge.rb +142 -0
- data/lib/theme_check/language_server/channel.rb +69 -0
- data/lib/theme_check/language_server/client_capabilities.rb +27 -0
- data/lib/theme_check/language_server/code_action_engine.rb +32 -0
- data/lib/theme_check/language_server/code_action_provider.rb +42 -0
- data/lib/theme_check/language_server/code_action_providers/quickfix_code_action_provider.rb +83 -0
- data/lib/theme_check/language_server/code_action_providers/source_fix_all_code_action_provider.rb +40 -0
- data/lib/theme_check/language_server/completion_providers/tag_completion_provider.rb +3 -1
- data/lib/theme_check/language_server/configuration.rb +69 -0
- data/lib/theme_check/language_server/diagnostic.rb +124 -0
- data/lib/theme_check/language_server/diagnostics_engine.rb +80 -0
- data/lib/theme_check/language_server/diagnostics_manager.rb +136 -0
- data/lib/theme_check/language_server/document_change_corrector.rb +267 -0
- data/lib/theme_check/language_server/document_link_provider.rb +6 -6
- data/lib/theme_check/language_server/execute_command_engine.rb +19 -0
- data/lib/theme_check/language_server/execute_command_provider.rb +30 -0
- data/lib/theme_check/language_server/execute_command_providers/correction_execute_command_provider.rb +48 -0
- data/lib/theme_check/language_server/execute_command_providers/run_checks_execute_command_provider.rb +22 -0
- data/lib/theme_check/language_server/handler.rb +92 -217
- data/lib/theme_check/language_server/io_messenger.rb +112 -0
- data/lib/theme_check/language_server/messenger.rb +12 -42
- data/lib/theme_check/language_server/protocol.rb +4 -0
- data/lib/theme_check/language_server/server.rb +54 -110
- data/lib/theme_check/language_server/uri_helper.rb +1 -0
- data/lib/theme_check/language_server/versioned_in_memory_storage.rb +69 -0
- data/lib/theme_check/language_server.rb +28 -6
- data/lib/theme_check/liquid_node.rb +255 -12
- data/lib/theme_check/locale_diff.rb +48 -10
- data/lib/theme_check/node.rb +16 -0
- data/lib/theme_check/offense.rb +27 -23
- data/lib/theme_check/position.rb +4 -4
- data/lib/theme_check/regex_helpers.rb +1 -1
- data/lib/theme_check/schema_helper.rb +70 -0
- data/lib/theme_check/shopify_liquid/system_translations.rb +35 -0
- data/lib/theme_check/shopify_liquid/tag.rb +19 -1
- data/lib/theme_check/shopify_liquid.rb +1 -0
- data/lib/theme_check/storage.rb +4 -0
- data/lib/theme_check/tags.rb +0 -1
- data/lib/theme_check/theme.rb +1 -1
- data/lib/theme_check/theme_file.rb +8 -1
- data/lib/theme_check/theme_file_rewriter.rb +28 -6
- data/lib/theme_check/version.rb +1 -1
- data/lib/theme_check.rb +11 -2
- metadata +31 -3
- data/lib/theme_check/language_server/diagnostics_tracker.rb +0 -66
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
module ThemeCheck
|
|
3
3
|
class ValidSchema < LiquidCheck
|
|
4
|
-
severity :
|
|
4
|
+
severity :error
|
|
5
5
|
category :json
|
|
6
6
|
doc docs_url(__FILE__)
|
|
7
7
|
|
|
8
8
|
def on_schema(node)
|
|
9
|
-
JSON.parse(node.
|
|
9
|
+
JSON.parse(node.inner_markup)
|
|
10
10
|
rescue JSON::ParserError => e
|
|
11
11
|
add_offense(format_json_parse_error(e), node: node)
|
|
12
12
|
end
|
|
@@ -2,52 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
module ThemeCheck
|
|
4
4
|
class Corrector
|
|
5
|
+
include JsonHelpers
|
|
6
|
+
|
|
5
7
|
def initialize(theme_file:)
|
|
6
8
|
@theme_file = theme_file
|
|
7
9
|
end
|
|
8
10
|
|
|
9
|
-
def insert_after(node, content)
|
|
10
|
-
@theme_file.rewriter.insert_after(node, content)
|
|
11
|
+
def insert_after(node, content, character_range = nil)
|
|
12
|
+
@theme_file.rewriter.insert_after(node, content, character_range)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def insert_before(node, content, character_range = nil)
|
|
16
|
+
@theme_file.rewriter.insert_before(node, content, character_range)
|
|
11
17
|
end
|
|
12
18
|
|
|
13
|
-
def
|
|
14
|
-
@theme_file.rewriter.
|
|
19
|
+
def remove(node)
|
|
20
|
+
@theme_file.rewriter.remove(node)
|
|
15
21
|
end
|
|
16
22
|
|
|
17
|
-
def replace(node, content)
|
|
18
|
-
@theme_file.rewriter.replace(node, content)
|
|
23
|
+
def replace(node, content, character_range = nil)
|
|
24
|
+
@theme_file.rewriter.replace(node, content, character_range)
|
|
19
25
|
node.markup = content
|
|
20
26
|
end
|
|
21
27
|
|
|
28
|
+
def replace_inner_markup(node, content)
|
|
29
|
+
@theme_file.rewriter.replace_inner_markup(node, content)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def replace_inner_json(node, json, **pretty_json_opts)
|
|
33
|
+
replace_inner_markup(node, pretty_json(json, **pretty_json_opts))
|
|
34
|
+
end
|
|
35
|
+
|
|
22
36
|
def wrap(node, insert_before, insert_after)
|
|
23
37
|
@theme_file.rewriter.wrap(node, insert_before, insert_after)
|
|
24
38
|
end
|
|
25
39
|
|
|
26
|
-
def
|
|
27
|
-
|
|
40
|
+
def create_file(storage, relative_path, content)
|
|
41
|
+
storage.write(relative_path, content)
|
|
28
42
|
end
|
|
29
43
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
theme.default_locale_json.update_contents({})
|
|
44
|
+
def remove_file(storage, relative_path)
|
|
45
|
+
storage.remove(relative_path)
|
|
33
46
|
end
|
|
34
47
|
|
|
35
|
-
def
|
|
36
|
-
|
|
48
|
+
def mkdir(storage, relative_path)
|
|
49
|
+
storage.mkdir(relative_path)
|
|
37
50
|
end
|
|
38
51
|
|
|
39
|
-
def
|
|
40
|
-
|
|
52
|
+
def add_translation(json_file, path, value)
|
|
53
|
+
hash = json_file.content
|
|
54
|
+
SchemaHelper.set(hash, path, value)
|
|
55
|
+
json_file.update_contents(hash)
|
|
41
56
|
end
|
|
42
57
|
|
|
43
|
-
def
|
|
44
|
-
hash =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
pointer[token] = {} unless pointer.key?(token)
|
|
48
|
-
pointer[token]
|
|
49
|
-
end
|
|
50
|
-
file.update_contents(hash)
|
|
58
|
+
def remove_translation(json_file, path)
|
|
59
|
+
hash = json_file.content
|
|
60
|
+
SchemaHelper.delete(hash, path)
|
|
61
|
+
json_file.update_contents(hash)
|
|
51
62
|
end
|
|
52
63
|
end
|
|
53
64
|
end
|
|
@@ -11,6 +11,10 @@ module ThemeCheck
|
|
|
11
11
|
@files = {}
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def relative_path(absolute_path)
|
|
15
|
+
Pathname.new(absolute_path).relative_path_from(@root).to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
14
18
|
def path(relative_path)
|
|
15
19
|
@root.join(relative_path)
|
|
16
20
|
end
|
|
@@ -32,13 +36,14 @@ module ThemeCheck
|
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
def mkdir(relative_path)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
file(relative_path).mkpath
|
|
39
|
+
return if file_exists?(relative_path)
|
|
40
|
+
reset_memoizers
|
|
41
|
+
file(relative_path).mkpath
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def files
|
|
41
45
|
@file_array ||= glob("**/*")
|
|
46
|
+
.reject { |path| File.directory?(path) }
|
|
42
47
|
.map { |path| path.relative_path_from(@root).to_s }
|
|
43
48
|
end
|
|
44
49
|
|
|
@@ -5,12 +5,75 @@ module ThemeCheck
|
|
|
5
5
|
class HtmlNode < Node
|
|
6
6
|
extend Forwardable
|
|
7
7
|
include RegexHelpers
|
|
8
|
+
include PositionHelper
|
|
8
9
|
attr_reader :theme_file, :parent
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
class << self
|
|
12
|
+
include RegexHelpers
|
|
13
|
+
|
|
14
|
+
def parse(liquid_file)
|
|
15
|
+
placeholder_values = []
|
|
16
|
+
parseable_source = +liquid_file.source.clone
|
|
17
|
+
|
|
18
|
+
# Replace all non-empty liquid tags with ≬{i}######≬ to prevent the HTML
|
|
19
|
+
# parser from freaking out. We transparently replace those placeholders in
|
|
20
|
+
# HtmlNode.
|
|
21
|
+
#
|
|
22
|
+
# We're using base36 to prevent index bleeding on 36^3 tags.
|
|
23
|
+
# `{{x}}` -> `≬#{i}≬` would properly be transformed for 46656 tags in a single file.
|
|
24
|
+
# Should be enough.
|
|
25
|
+
#
|
|
26
|
+
# The base10 alternative would have overflowed at 1000 (`{{x}}` -> `≬1000≬`) which seemed more likely.
|
|
27
|
+
#
|
|
28
|
+
# Didn't go with base64 because of the `=` character that would have messed with HTML parsing.
|
|
29
|
+
#
|
|
30
|
+
# (Note, we're also maintaining newline characters in there so
|
|
31
|
+
# that line numbers match the source...)
|
|
32
|
+
matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
|
|
33
|
+
value = m[0]
|
|
34
|
+
next unless value.size > 4 # skip empty tags/variables {%%} and {{}}
|
|
35
|
+
placeholder_values.push(value)
|
|
36
|
+
key = (placeholder_values.size - 1).to_s(36)
|
|
37
|
+
|
|
38
|
+
# Doing shenanigans so that line numbers match... Ugh.
|
|
39
|
+
keyed_placeholder = parseable_source[m.begin(0)...m.end(0)]
|
|
40
|
+
|
|
41
|
+
# First and last chars are ≬
|
|
42
|
+
keyed_placeholder[0] = "≬"
|
|
43
|
+
keyed_placeholder[-1] = "≬"
|
|
44
|
+
|
|
45
|
+
# Non newline characters are #
|
|
46
|
+
keyed_placeholder.gsub!(/[^\n≬]/, '#')
|
|
47
|
+
|
|
48
|
+
# First few # are replaced by the base10 ID of the tag
|
|
49
|
+
i = -1
|
|
50
|
+
keyed_placeholder.gsub!('#') do
|
|
51
|
+
i += 1
|
|
52
|
+
if i > key.size - 1
|
|
53
|
+
'#'
|
|
54
|
+
else
|
|
55
|
+
key[i]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Replace source by placeholder
|
|
60
|
+
parseable_source[m.begin(0)...m.end(0)] = keyed_placeholder
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
new(
|
|
64
|
+
Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400),
|
|
65
|
+
liquid_file,
|
|
66
|
+
placeholder_values,
|
|
67
|
+
parseable_source
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize(value, theme_file, placeholder_values, parseable_source, parent = nil)
|
|
11
73
|
@value = value
|
|
12
74
|
@theme_file = theme_file
|
|
13
75
|
@placeholder_values = placeholder_values
|
|
76
|
+
@parseable_source = parseable_source
|
|
14
77
|
@parent = parent
|
|
15
78
|
end
|
|
16
79
|
|
|
@@ -27,11 +90,11 @@ module ThemeCheck
|
|
|
27
90
|
def children
|
|
28
91
|
@children ||= @value
|
|
29
92
|
.children
|
|
30
|
-
.map { |child| HtmlNode.new(child, theme_file, @placeholder_values, self) }
|
|
93
|
+
.map { |child| HtmlNode.new(child, theme_file, @placeholder_values, @parseable_source, self) }
|
|
31
94
|
end
|
|
32
95
|
|
|
33
96
|
def markup
|
|
34
|
-
@markup ||= replace_placeholders(
|
|
97
|
+
@markup ||= replace_placeholders(parseable_markup)
|
|
35
98
|
end
|
|
36
99
|
|
|
37
100
|
def line_number
|
|
@@ -39,11 +102,27 @@ module ThemeCheck
|
|
|
39
102
|
end
|
|
40
103
|
|
|
41
104
|
def start_index
|
|
42
|
-
|
|
105
|
+
position.start_index
|
|
43
106
|
end
|
|
44
107
|
|
|
45
108
|
def end_index
|
|
46
|
-
|
|
109
|
+
position.end_index
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def start_row
|
|
113
|
+
position.start_row
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def start_column
|
|
117
|
+
position.start_column
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def end_row
|
|
121
|
+
position.end_row
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def end_column
|
|
125
|
+
position.end_column
|
|
47
126
|
end
|
|
48
127
|
|
|
49
128
|
def literal?
|
|
@@ -60,6 +139,12 @@ module ThemeCheck
|
|
|
60
139
|
.to_h
|
|
61
140
|
end
|
|
62
141
|
|
|
142
|
+
def parseable_markup
|
|
143
|
+
start_index = from_row_column_to_index(@parseable_source, line_number - 1, 0)
|
|
144
|
+
@parseable_source
|
|
145
|
+
.match(/<\s*#{@value.name}[^>]*>/im, start_index)[0]
|
|
146
|
+
end
|
|
147
|
+
|
|
63
148
|
def content
|
|
64
149
|
@content ||= replace_placeholders(@value.content)
|
|
65
150
|
end
|
|
@@ -74,10 +159,18 @@ module ThemeCheck
|
|
|
74
159
|
|
|
75
160
|
private
|
|
76
161
|
|
|
162
|
+
def position
|
|
163
|
+
@position ||= Position.new(
|
|
164
|
+
markup,
|
|
165
|
+
theme_file.source,
|
|
166
|
+
line_number_1_indexed: line_number,
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
77
170
|
def replace_placeholders(string)
|
|
78
171
|
# Replace all ≬{i}####≬ with the actual content.
|
|
79
172
|
string.gsub(HTML_LIQUID_PLACEHOLDER) do |match|
|
|
80
|
-
key = /[0-9a-z]+/.match(match)[0]
|
|
173
|
+
key = /[0-9a-z]+/.match(match.gsub("\n", ''))[0]
|
|
81
174
|
@placeholder_values[key.to_i(36)]
|
|
82
175
|
end
|
|
83
176
|
end
|
|
@@ -4,7 +4,6 @@ require "forwardable"
|
|
|
4
4
|
|
|
5
5
|
module ThemeCheck
|
|
6
6
|
class HtmlVisitor
|
|
7
|
-
include RegexHelpers
|
|
8
7
|
attr_reader :checks
|
|
9
8
|
|
|
10
9
|
def initialize(checks)
|
|
@@ -12,43 +11,13 @@ module ThemeCheck
|
|
|
12
11
|
end
|
|
13
12
|
|
|
14
13
|
def visit_liquid_file(liquid_file)
|
|
15
|
-
|
|
16
|
-
visit(HtmlNode.new(doc, liquid_file, placeholder_values))
|
|
14
|
+
visit(HtmlNode.parse(liquid_file))
|
|
17
15
|
rescue ArgumentError => e
|
|
18
16
|
call_checks(:on_parse_error, e, liquid_file)
|
|
19
17
|
end
|
|
20
18
|
|
|
21
19
|
private
|
|
22
20
|
|
|
23
|
-
def parse(liquid_file)
|
|
24
|
-
placeholder_values = []
|
|
25
|
-
parseable_source = +liquid_file.source.clone
|
|
26
|
-
|
|
27
|
-
# Replace all non-empty liquid tags with ≬{i}######≬ to prevent the HTML
|
|
28
|
-
# parser from freaking out. We transparently replace those placeholders in
|
|
29
|
-
# HtmlNode.
|
|
30
|
-
#
|
|
31
|
-
# We're using base36 to prevent index bleeding on 36^3 tags.
|
|
32
|
-
# `{{x}}` -> `≬#{i}≬` would properly be transformed for 46656 tags in a single file.
|
|
33
|
-
# Should be enough.
|
|
34
|
-
#
|
|
35
|
-
# The base10 alternative would have overflowed at 1000 (`{{x}}` -> `≬1000≬`) which seemed more likely.
|
|
36
|
-
#
|
|
37
|
-
# Didn't go with base64 because of the `=` character that would have messed with HTML parsing.
|
|
38
|
-
matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
|
|
39
|
-
value = m[0]
|
|
40
|
-
next unless value.size > 4 # skip empty tags/variables {%%} and {{}}
|
|
41
|
-
placeholder_values.push(value)
|
|
42
|
-
key = (placeholder_values.size - 1).to_s(36)
|
|
43
|
-
parseable_source[m.begin(0)...m.end(0)] = "≬#{key.ljust(m.end(0) - m.begin(0) - 2, '#')}≬"
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
[
|
|
47
|
-
Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400),
|
|
48
|
-
placeholder_values,
|
|
49
|
-
]
|
|
50
|
-
end
|
|
51
|
-
|
|
52
21
|
def visit(node)
|
|
53
22
|
call_checks(:on_element, node) if node.element?
|
|
54
23
|
call_checks(:"on_#{node.name}", node)
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
# as a big hash already, leave it like that and save yourself some IO.
|
|
7
7
|
module ThemeCheck
|
|
8
8
|
class InMemoryStorage < Storage
|
|
9
|
+
attr_reader :root
|
|
10
|
+
|
|
9
11
|
def initialize(files = {}, root = "/dev/null")
|
|
10
12
|
@files = files
|
|
11
13
|
@root = Pathname.new(root)
|
|
@@ -29,6 +31,7 @@ module ThemeCheck
|
|
|
29
31
|
|
|
30
32
|
def mkdir(relative_path)
|
|
31
33
|
@files[relative_path] = nil
|
|
34
|
+
reset_memoizers
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
def files
|
|
@@ -46,5 +49,11 @@ module ThemeCheck
|
|
|
46
49
|
def relative_path(absolute_path)
|
|
47
50
|
Pathname.new(absolute_path).relative_path_from(@root).to_s
|
|
48
51
|
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def reset_memoizers
|
|
56
|
+
@directories = nil
|
|
57
|
+
end
|
|
49
58
|
end
|
|
50
59
|
end
|
|
@@ -5,5 +5,19 @@ module ThemeCheck
|
|
|
5
5
|
message = error.message[/\d+: (.+)$/, 1] || 'Invalid syntax'
|
|
6
6
|
"#{message} in JSON"
|
|
7
7
|
end
|
|
8
|
+
|
|
9
|
+
def pretty_json(hash, start_level: 1, indent: " ")
|
|
10
|
+
start_indent = indent * start_level
|
|
11
|
+
|
|
12
|
+
<<~JSON
|
|
13
|
+
|
|
14
|
+
#{start_indent}#{JSON.pretty_generate(
|
|
15
|
+
hash,
|
|
16
|
+
indent: indent,
|
|
17
|
+
array_nl: "\n#{start_indent}",
|
|
18
|
+
object_nl: "\n#{start_indent}",
|
|
19
|
+
)}
|
|
20
|
+
JSON
|
|
21
|
+
end
|
|
8
22
|
end
|
|
9
23
|
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This class exists as a bridge (or boundary) between our handlers and the outside world.
|
|
4
|
+
#
|
|
5
|
+
# It is concerned with all the Language Server Protocol constructs. i.e.
|
|
6
|
+
#
|
|
7
|
+
# - sending Hash messages as JSON
|
|
8
|
+
# - reading JSON messages as Hashes
|
|
9
|
+
# - preparing, sending and resolving requests
|
|
10
|
+
# - preparing and sending responses
|
|
11
|
+
# - preparing and sending notifications
|
|
12
|
+
# - preparing and sending progress notifications
|
|
13
|
+
#
|
|
14
|
+
# But it _not_ concerned by _how_ those messages are sent to the
|
|
15
|
+
# outside world. That's the job of the messenger.
|
|
16
|
+
#
|
|
17
|
+
# This enables us to have all the language server protocol logic
|
|
18
|
+
# in here living independently of how we communicate with the
|
|
19
|
+
# client (STDIO or websocket)
|
|
20
|
+
module ThemeCheck
|
|
21
|
+
module LanguageServer
|
|
22
|
+
class Bridge
|
|
23
|
+
attr_writer :supports_work_done_progress
|
|
24
|
+
|
|
25
|
+
def initialize(messenger)
|
|
26
|
+
# The messenger is responsible for IO.
|
|
27
|
+
# Could be STDIO or WebSockets or Mock.
|
|
28
|
+
@messenger = messenger
|
|
29
|
+
|
|
30
|
+
# Whether the client supports work done progress notifications
|
|
31
|
+
@supports_work_done_progress = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def log(message)
|
|
35
|
+
@messenger.log(message)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def read_message
|
|
39
|
+
message_body = @messenger.read_message
|
|
40
|
+
message_json = JSON.parse(message_body, symbolize_names: true)
|
|
41
|
+
@messenger.log(JSON.pretty_generate(message_json)) if ThemeCheck.debug?
|
|
42
|
+
message_json
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def send_message(message_hash)
|
|
46
|
+
message_hash[:jsonrpc] = '2.0'
|
|
47
|
+
message_body = JSON.dump(message_hash)
|
|
48
|
+
@messenger.log(JSON.pretty_generate(message_hash)) if ThemeCheck.debug?
|
|
49
|
+
@messenger.send_message(message_body)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#requestMessage
|
|
53
|
+
def send_request(method, params = nil)
|
|
54
|
+
channel = Channel.create
|
|
55
|
+
message = { id: channel.id }
|
|
56
|
+
message[:method] = method
|
|
57
|
+
message[:params] = params if params
|
|
58
|
+
send_message(message)
|
|
59
|
+
channel.pop
|
|
60
|
+
ensure
|
|
61
|
+
channel.close
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def receive_response(id, result)
|
|
65
|
+
Channel.by_id(id) << result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
|
|
69
|
+
def send_response(id, result = nil, error = nil)
|
|
70
|
+
message = { id: id }
|
|
71
|
+
if error
|
|
72
|
+
message[:error] = error
|
|
73
|
+
else
|
|
74
|
+
message[:result] = result
|
|
75
|
+
end
|
|
76
|
+
send_message(message)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#responseError
|
|
80
|
+
def send_internal_error(id, e)
|
|
81
|
+
send_response(id, nil, {
|
|
82
|
+
code: ErrorCodes::INTERNAL_ERROR,
|
|
83
|
+
message: <<~EOS,
|
|
84
|
+
#{e.class}: #{e.message}
|
|
85
|
+
#{e.backtrace.join("\n ")}
|
|
86
|
+
EOS
|
|
87
|
+
})
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
|
|
91
|
+
def send_notification(method, params)
|
|
92
|
+
message = { method: method }
|
|
93
|
+
message[:params] = params
|
|
94
|
+
send_message(message)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#progress
|
|
98
|
+
def send_progress(token, value)
|
|
99
|
+
send_notification("$/progress", token: token, value: value)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def supports_work_done_progress?
|
|
103
|
+
@supports_work_done_progress
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def send_create_work_done_progress_request(token)
|
|
107
|
+
return unless supports_work_done_progress?
|
|
108
|
+
send_request("window/workDoneProgress/create", {
|
|
109
|
+
token: token,
|
|
110
|
+
})
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def send_work_done_progress_begin(token, title)
|
|
114
|
+
return unless supports_work_done_progress?
|
|
115
|
+
send_progress(token, {
|
|
116
|
+
kind: 'begin',
|
|
117
|
+
title: title,
|
|
118
|
+
cancellable: false,
|
|
119
|
+
percentage: 0,
|
|
120
|
+
})
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def send_work_done_progress_report(token, message, percentage)
|
|
124
|
+
return unless supports_work_done_progress?
|
|
125
|
+
send_progress(token, {
|
|
126
|
+
kind: 'report',
|
|
127
|
+
message: message,
|
|
128
|
+
cancellable: false,
|
|
129
|
+
percentage: percentage,
|
|
130
|
+
})
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def send_work_done_progress_end(token, message)
|
|
134
|
+
return unless supports_work_done_progress?
|
|
135
|
+
send_progress(token, {
|
|
136
|
+
kind: 'end',
|
|
137
|
+
message: message,
|
|
138
|
+
})
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThemeCheck
|
|
4
|
+
module LanguageServer
|
|
5
|
+
# How you'd use this class:
|
|
6
|
+
#
|
|
7
|
+
# In thread #1:
|
|
8
|
+
# def foo
|
|
9
|
+
# chan = Channel.create
|
|
10
|
+
# send_request(chan.id, ...)
|
|
11
|
+
# result = chan.pop
|
|
12
|
+
# do_stuff_with_result(result)
|
|
13
|
+
# ensure
|
|
14
|
+
# chan.close
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# In thread #2:
|
|
18
|
+
# Channel.by_id(id) << result
|
|
19
|
+
class Channel
|
|
20
|
+
MUTEX = Mutex.new
|
|
21
|
+
CHANNELS = {}
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def create
|
|
25
|
+
id = new_id
|
|
26
|
+
CHANNELS[id] = new(id)
|
|
27
|
+
CHANNELS[id]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def by_id(id)
|
|
31
|
+
CHANNELS[id]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def close(id)
|
|
35
|
+
CHANNELS.delete(id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def new_id
|
|
41
|
+
MUTEX.synchronize do
|
|
42
|
+
@id ||= 0
|
|
43
|
+
@id += 1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :id
|
|
49
|
+
|
|
50
|
+
def initialize(id)
|
|
51
|
+
@id = id
|
|
52
|
+
@response = SizedQueue.new(1)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pop
|
|
56
|
+
@response.pop
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def <<(value)
|
|
60
|
+
@response << value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close
|
|
64
|
+
@response.close
|
|
65
|
+
Channel.close(id)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThemeCheck
|
|
4
|
+
module LanguageServer
|
|
5
|
+
class ClientCapabilities
|
|
6
|
+
def initialize(capabilities)
|
|
7
|
+
@capabilities = capabilities
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def supports_work_done_progress?
|
|
11
|
+
@capabilities.dig(:window, :workDoneProgress) || false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def supports_workspace_configuration?
|
|
15
|
+
@capabilities.dig(:workspace, :configuration) || false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def supports_workspace_did_change_configuration_dynamic_registration?
|
|
19
|
+
@capabilities.dig(:workspace, :didChangeConfiguration, :dynamicRegistration) || false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialization_option(key)
|
|
23
|
+
@capabilities.dig(:initializationOptions, key)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThemeCheck
|
|
4
|
+
module LanguageServer
|
|
5
|
+
class CodeActionEngine
|
|
6
|
+
include PositionHelper
|
|
7
|
+
|
|
8
|
+
def initialize(storage, diagnostics_manager)
|
|
9
|
+
@storage = storage
|
|
10
|
+
@providers = CodeActionProvider.all.map { |c| c.new(storage, diagnostics_manager) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def code_actions(absolute_path, start_position, end_position, only_kinds = [])
|
|
14
|
+
relative_path = @storage.relative_path(absolute_path)
|
|
15
|
+
buffer = @storage.read(relative_path)
|
|
16
|
+
start_index = from_row_column_to_index(buffer, start_position[0], start_position[1])
|
|
17
|
+
end_index = from_row_column_to_index(buffer, end_position[0], end_position[1])
|
|
18
|
+
range = (start_index...end_index)
|
|
19
|
+
|
|
20
|
+
@providers
|
|
21
|
+
.filter do |provider|
|
|
22
|
+
only_kinds.empty? ||
|
|
23
|
+
only_kinds.include?(provider.kind) ||
|
|
24
|
+
only_kinds.include?(provider.base_kind)
|
|
25
|
+
end
|
|
26
|
+
.flat_map do |provider|
|
|
27
|
+
provider.code_actions(relative_path, range)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|