syntax_tree 1.2.0 → 2.0.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/.github/workflows/gh-pages.yml +26 -0
- data/.gitignore +1 -1
- data/CHANGELOG.md +17 -0
- data/Gemfile.lock +5 -5
- data/README.md +4 -0
- data/bin/bench +11 -11
- data/bin/console +3 -3
- data/bin/profile +3 -3
- data/doc/logo.svg +284 -0
- data/exe/stree +1 -0
- data/lib/syntax_tree/cli.rb +97 -25
- data/lib/syntax_tree/formatter.rb +71 -0
- data/lib/syntax_tree/language_server/inlay_hints.rb +93 -0
- data/lib/syntax_tree/language_server.rb +99 -0
- data/lib/syntax_tree/node.rb +11979 -0
- data/lib/syntax_tree/parser.rb +3098 -0
- data/lib/syntax_tree/version.rb +2 -4
- data/lib/syntax_tree.rb +9 -13959
- metadata +10 -3
data/lib/syntax_tree/cli.rb
CHANGED
@@ -1,7 +1,18 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
module SyntaxTree
|
4
4
|
module CLI
|
5
|
+
# This holds references to objects that respond to both #parse and #format
|
6
|
+
# so that we can use them in the CLI.
|
7
|
+
HANDLERS = {}
|
8
|
+
HANDLERS.default = SyntaxTree
|
9
|
+
|
10
|
+
# This is a hook provided so that plugins can register themselves as the
|
11
|
+
# handler for a particular file type.
|
12
|
+
def self.register_handler(extension, handler)
|
13
|
+
HANDLERS[extension] = handler
|
14
|
+
end
|
15
|
+
|
5
16
|
# A utility wrapper around colored strings in the output.
|
6
17
|
class Color
|
7
18
|
attr_reader :value, :code
|
@@ -15,6 +26,10 @@ class SyntaxTree
|
|
15
26
|
"\033[#{code}m#{value}\033[0m"
|
16
27
|
end
|
17
28
|
|
29
|
+
def self.bold(value)
|
30
|
+
new(value, "1")
|
31
|
+
end
|
32
|
+
|
18
33
|
def self.gray(value)
|
19
34
|
new(value, "38;5;102")
|
20
35
|
end
|
@@ -30,7 +45,7 @@ class SyntaxTree
|
|
30
45
|
|
31
46
|
# The parent action class for the CLI that implements the basics.
|
32
47
|
class Action
|
33
|
-
def run(filepath, source)
|
48
|
+
def run(handler, filepath, source)
|
34
49
|
end
|
35
50
|
|
36
51
|
def success
|
@@ -42,8 +57,8 @@ class SyntaxTree
|
|
42
57
|
|
43
58
|
# An action of the CLI that prints out the AST for the given source.
|
44
59
|
class AST < Action
|
45
|
-
def run(filepath, source)
|
46
|
-
pp
|
60
|
+
def run(handler, filepath, source)
|
61
|
+
pp handler.parse(source)
|
47
62
|
end
|
48
63
|
end
|
49
64
|
|
@@ -53,8 +68,8 @@ class SyntaxTree
|
|
53
68
|
class UnformattedError < StandardError
|
54
69
|
end
|
55
70
|
|
56
|
-
def run(filepath, source)
|
57
|
-
raise UnformattedError if source !=
|
71
|
+
def run(handler, filepath, source)
|
72
|
+
raise UnformattedError if source != handler.format(source)
|
58
73
|
rescue StandardError
|
59
74
|
warn("[#{Color.yellow("warn")}] #{filepath}")
|
60
75
|
raise
|
@@ -75,11 +90,11 @@ class SyntaxTree
|
|
75
90
|
class NonIdempotentFormatError < StandardError
|
76
91
|
end
|
77
92
|
|
78
|
-
def run(filepath, source)
|
93
|
+
def run(handler, filepath, source)
|
79
94
|
warning = "[#{Color.yellow("warn")}] #{filepath}"
|
80
|
-
formatted =
|
95
|
+
formatted = handler.format(source)
|
81
96
|
|
82
|
-
if formatted !=
|
97
|
+
if formatted != handler.format(formatted)
|
83
98
|
raise NonIdempotentFormatError
|
84
99
|
end
|
85
100
|
rescue StandardError
|
@@ -98,28 +113,28 @@ class SyntaxTree
|
|
98
113
|
|
99
114
|
# An action of the CLI that prints out the doc tree IR for the given source.
|
100
115
|
class Doc < Action
|
101
|
-
def run(filepath, source)
|
116
|
+
def run(handler, filepath, source)
|
102
117
|
formatter = Formatter.new([])
|
103
|
-
|
118
|
+
handler.parse(source).format(formatter)
|
104
119
|
pp formatter.groups.first
|
105
120
|
end
|
106
121
|
end
|
107
122
|
|
108
123
|
# An action of the CLI that formats the input source and prints it out.
|
109
124
|
class Format < Action
|
110
|
-
def run(filepath, source)
|
111
|
-
puts
|
125
|
+
def run(handler, filepath, source)
|
126
|
+
puts handler.format(source)
|
112
127
|
end
|
113
128
|
end
|
114
129
|
|
115
130
|
# An action of the CLI that formats the input source and writes the
|
116
131
|
# formatted output back to the file.
|
117
132
|
class Write < Action
|
118
|
-
def run(filepath, source)
|
133
|
+
def run(handler, filepath, source)
|
119
134
|
print filepath
|
120
135
|
start = Time.now
|
121
136
|
|
122
|
-
formatted =
|
137
|
+
formatted = handler.format(source)
|
123
138
|
File.write(filepath, formatted)
|
124
139
|
|
125
140
|
color = source == formatted ? Color.gray(filepath) : filepath
|
@@ -135,24 +150,65 @@ class SyntaxTree
|
|
135
150
|
# The help message displayed if the input arguments are not correctly
|
136
151
|
# ordered or formatted.
|
137
152
|
HELP = <<~HELP
|
138
|
-
stree
|
153
|
+
#{Color.bold("stree ast [OPTIONS] [FILE]")}
|
154
|
+
Print out the AST corresponding to the given files
|
155
|
+
|
156
|
+
#{Color.bold("stree check [OPTIONS] [FILE]")}
|
157
|
+
Check that the given files are formatted as syntax tree would format them
|
158
|
+
|
159
|
+
#{Color.bold("stree debug [OPTIONS] [FILE]")}
|
160
|
+
Check that the given files can be formatted idempotently
|
161
|
+
|
162
|
+
#{Color.bold("stree doc [OPTIONS] [FILE]")}
|
163
|
+
Print out the doc tree that would be used to format the given files
|
164
|
+
|
165
|
+
#{Color.bold("stree format [OPTIONS] [FILE]")}
|
166
|
+
Print out the formatted version of the given files
|
167
|
+
|
168
|
+
#{Color.bold("stree help")}
|
169
|
+
Display this help message
|
139
170
|
|
140
|
-
|
141
|
-
|
171
|
+
#{Color.bold("stree lsp")}
|
172
|
+
Run syntax tree in language server mode
|
173
|
+
|
174
|
+
#{Color.bold("stree version")}
|
175
|
+
Output the current version of syntax tree
|
176
|
+
|
177
|
+
#{Color.bold("stree write [OPTIONS] [FILE]")}
|
178
|
+
Read, format, and write back the source of the given files
|
179
|
+
|
180
|
+
[OPTIONS]
|
181
|
+
|
182
|
+
--plugins=...
|
183
|
+
A comma-separated list of plugins to load.
|
142
184
|
HELP
|
143
185
|
|
144
186
|
class << self
|
145
187
|
# Run the CLI over the given array of strings that make up the arguments
|
146
188
|
# passed to the invocation.
|
147
189
|
def run(argv)
|
148
|
-
|
190
|
+
name, *arguments = argv
|
191
|
+
|
192
|
+
case name
|
193
|
+
when "help"
|
194
|
+
puts HELP
|
195
|
+
return 0
|
196
|
+
when "lsp"
|
197
|
+
require "syntax_tree/language_server"
|
198
|
+
LanguageServer.new.run
|
199
|
+
return 0
|
200
|
+
when "version"
|
201
|
+
puts SyntaxTree::VERSION
|
202
|
+
return 0
|
203
|
+
end
|
204
|
+
|
205
|
+
if arguments.empty?
|
149
206
|
warn(HELP)
|
150
207
|
return 1
|
151
208
|
end
|
152
209
|
|
153
|
-
arg, *patterns = argv
|
154
210
|
action =
|
155
|
-
case
|
211
|
+
case name
|
156
212
|
when "a", "ast"
|
157
213
|
AST.new
|
158
214
|
when "c", "check"
|
@@ -170,15 +226,31 @@ class SyntaxTree
|
|
170
226
|
return 1
|
171
227
|
end
|
172
228
|
|
229
|
+
# If there are any plugins specified on the command line, then load them
|
230
|
+
# by requiring them here. We do this by transforming something like
|
231
|
+
#
|
232
|
+
# stree format --plugins=haml template.haml
|
233
|
+
#
|
234
|
+
# into
|
235
|
+
#
|
236
|
+
# require "syntax_tree/haml"
|
237
|
+
#
|
238
|
+
if arguments.first.start_with?("--plugins=")
|
239
|
+
plugins = arguments.shift[/^--plugins=(.*)$/, 1]
|
240
|
+
plugins.split(",").each { |plugin| require "syntax_tree/#{plugin}" }
|
241
|
+
end
|
242
|
+
|
173
243
|
errored = false
|
174
|
-
|
244
|
+
arguments.each do |pattern|
|
175
245
|
Dir.glob(pattern).each do |filepath|
|
176
246
|
next unless File.file?(filepath)
|
177
|
-
|
247
|
+
|
248
|
+
handler = HANDLERS[File.extname(filepath)]
|
249
|
+
source = handler.read(filepath)
|
178
250
|
|
179
251
|
begin
|
180
|
-
action.run(filepath, source)
|
181
|
-
rescue ParseError => error
|
252
|
+
action.run(handler, filepath, source)
|
253
|
+
rescue Parser::ParseError => error
|
182
254
|
warn("Error: #{error.message}")
|
183
255
|
|
184
256
|
if error.lineno
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SyntaxTree
|
4
|
+
# A slightly enhanced PP that knows how to format recursively including
|
5
|
+
# comments.
|
6
|
+
class Formatter < PP
|
7
|
+
COMMENT_PRIORITY = 1
|
8
|
+
HEREDOC_PRIORITY = 2
|
9
|
+
|
10
|
+
attr_reader :source, :stack, :quote
|
11
|
+
|
12
|
+
def initialize(source, ...)
|
13
|
+
super(...)
|
14
|
+
|
15
|
+
@source = source
|
16
|
+
@stack = []
|
17
|
+
@quote = "\""
|
18
|
+
end
|
19
|
+
|
20
|
+
def format(node, stackable: true)
|
21
|
+
stack << node if stackable
|
22
|
+
doc = nil
|
23
|
+
|
24
|
+
# If there are comments, then we're going to format them around the node
|
25
|
+
# so that they get printed properly.
|
26
|
+
if node.comments.any?
|
27
|
+
leading, trailing = node.comments.partition(&:leading?)
|
28
|
+
|
29
|
+
# Print all comments that were found before the node.
|
30
|
+
leading.each do |comment|
|
31
|
+
comment.format(self)
|
32
|
+
breakable(force: true)
|
33
|
+
end
|
34
|
+
|
35
|
+
# If the node has a stree-ignore comment right before it, then we're
|
36
|
+
# going to just print out the node as it was seen in the source.
|
37
|
+
if leading.last&.ignore?
|
38
|
+
doc = text(source[node.location.start_char...node.location.end_char])
|
39
|
+
else
|
40
|
+
doc = node.format(self)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Print all comments that were found after the node.
|
44
|
+
trailing.each do |comment|
|
45
|
+
line_suffix(priority: COMMENT_PRIORITY) do
|
46
|
+
text(" ")
|
47
|
+
comment.format(self)
|
48
|
+
break_parent
|
49
|
+
end
|
50
|
+
end
|
51
|
+
else
|
52
|
+
doc = node.format(self)
|
53
|
+
end
|
54
|
+
|
55
|
+
stack.pop if stackable
|
56
|
+
doc
|
57
|
+
end
|
58
|
+
|
59
|
+
def format_each(nodes)
|
60
|
+
nodes.each { |node| format(node) }
|
61
|
+
end
|
62
|
+
|
63
|
+
def parent
|
64
|
+
stack[-2]
|
65
|
+
end
|
66
|
+
|
67
|
+
def parents
|
68
|
+
stack[0...-1].reverse_each
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SyntaxTree
|
4
|
+
class LanguageServer
|
5
|
+
class InlayHints
|
6
|
+
attr_reader :before, :after
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@before = Hash.new { |hash, key| hash[key] = +"" }
|
10
|
+
@after = Hash.new { |hash, key| hash[key] = +"" }
|
11
|
+
end
|
12
|
+
|
13
|
+
# Adds the implicitly rescued StandardError into a bare rescue clause. For
|
14
|
+
# example,
|
15
|
+
#
|
16
|
+
# begin
|
17
|
+
# rescue
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# becomes
|
21
|
+
#
|
22
|
+
# begin
|
23
|
+
# rescue StandardError
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
def bare_rescue(location)
|
27
|
+
after[location.start_char + "rescue".length] << " StandardError"
|
28
|
+
end
|
29
|
+
|
30
|
+
# Adds the implicitly referenced value (local variable or method call)
|
31
|
+
# that is added into a hash when the value of a key-value pair is omitted.
|
32
|
+
# For example,
|
33
|
+
#
|
34
|
+
# { value: }
|
35
|
+
#
|
36
|
+
# becomes
|
37
|
+
#
|
38
|
+
# { value: value }
|
39
|
+
#
|
40
|
+
def missing_hash_value(key, location)
|
41
|
+
after[location.end_char] << " #{key}"
|
42
|
+
end
|
43
|
+
|
44
|
+
# Adds implicit parentheses around certain expressions to make it clear
|
45
|
+
# which subexpression will be evaluated first. For example,
|
46
|
+
#
|
47
|
+
# a + b * c
|
48
|
+
#
|
49
|
+
# becomes
|
50
|
+
#
|
51
|
+
# a + ₍b * c₎
|
52
|
+
#
|
53
|
+
def precedence_parentheses(location)
|
54
|
+
before[location.start_char] << "₍"
|
55
|
+
after[location.end_char] << "₎"
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.find(program)
|
59
|
+
inlay_hints = new
|
60
|
+
queue = [[nil, program]]
|
61
|
+
|
62
|
+
until queue.empty?
|
63
|
+
parent_node, child_node = queue.shift
|
64
|
+
|
65
|
+
child_node.child_nodes.each do |grand_child_node|
|
66
|
+
queue << [child_node, grand_child_node] if grand_child_node
|
67
|
+
end
|
68
|
+
|
69
|
+
case [parent_node, child_node]
|
70
|
+
in _, Rescue[exception: nil, location:]
|
71
|
+
inlay_hints.bare_rescue(location)
|
72
|
+
in _, Assoc[key: Label[value: key], value: nil, location:]
|
73
|
+
inlay_hints.missing_hash_value(key[0...-1], location)
|
74
|
+
in Assign | Binary | IfOp | OpAssign, IfOp[location:]
|
75
|
+
inlay_hints.precedence_parentheses(location)
|
76
|
+
in Assign | OpAssign, Binary[location:]
|
77
|
+
inlay_hints.precedence_parentheses(location)
|
78
|
+
in Binary[operator: parent_oper], Binary[operator: child_oper, location:] if parent_oper != child_oper
|
79
|
+
inlay_hints.precedence_parentheses(location)
|
80
|
+
in Binary, Unary[operator: "-", location:]
|
81
|
+
inlay_hints.precedence_parentheses(location)
|
82
|
+
in Params, Assign[location:]
|
83
|
+
inlay_hints.precedence_parentheses(location)
|
84
|
+
else
|
85
|
+
# do nothing
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
inlay_hints
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "cgi"
|
4
|
+
require "json"
|
5
|
+
require "uri"
|
6
|
+
|
7
|
+
require_relative "language_server/inlay_hints"
|
8
|
+
|
9
|
+
module SyntaxTree
|
10
|
+
class LanguageServer
|
11
|
+
attr_reader :input, :output
|
12
|
+
|
13
|
+
def initialize(input: STDIN, output: STDOUT)
|
14
|
+
@input = input.binmode
|
15
|
+
@output = output.binmode
|
16
|
+
end
|
17
|
+
|
18
|
+
def run
|
19
|
+
store =
|
20
|
+
Hash.new do |hash, uri|
|
21
|
+
hash[uri] = File.binread(CGI.unescape(URI.parse(uri).path))
|
22
|
+
end
|
23
|
+
|
24
|
+
while headers = input.gets("\r\n\r\n")
|
25
|
+
source = input.read(headers[/Content-Length: (\d+)/i, 1].to_i)
|
26
|
+
request = JSON.parse(source, symbolize_names: true)
|
27
|
+
|
28
|
+
case request
|
29
|
+
in { method: "initialize", id: }
|
30
|
+
store.clear
|
31
|
+
write(id: id, result: { capabilities: capabilities })
|
32
|
+
in { method: "initialized" }
|
33
|
+
# ignored
|
34
|
+
in { method: "shutdown" }
|
35
|
+
store.clear
|
36
|
+
return
|
37
|
+
in { method: "textDocument/didChange", params: { textDocument: { uri: }, contentChanges: [{ text: }, *] } }
|
38
|
+
store[uri] = text
|
39
|
+
in { method: "textDocument/didOpen", params: { textDocument: { uri:, text: } } }
|
40
|
+
store[uri] = text
|
41
|
+
in { method: "textDocument/didClose", params: { textDocument: { uri: } } }
|
42
|
+
store.delete(uri)
|
43
|
+
in { method: "textDocument/formatting", id:, params: { textDocument: { uri: } } }
|
44
|
+
write(id: id, result: [format(store[uri])])
|
45
|
+
in { method: "textDocument/inlayHints", id:, params: { textDocument: { uri: } } }
|
46
|
+
write(id: id, result: inlay_hints(store[uri]))
|
47
|
+
in { method: "syntaxTree/visualizing", id:, params: { textDocument: { uri: } } }
|
48
|
+
output = []
|
49
|
+
PP.pp(SyntaxTree.parse(store[uri]), output)
|
50
|
+
write(id: id, result: output.join)
|
51
|
+
in { method: %r{\$/.+} }
|
52
|
+
# ignored
|
53
|
+
else
|
54
|
+
raise "Unhandled: #{request}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def capabilities
|
62
|
+
{
|
63
|
+
documentFormattingProvider: true,
|
64
|
+
textDocumentSync: { change: 1, openClose: true }
|
65
|
+
}
|
66
|
+
end
|
67
|
+
|
68
|
+
def format(source)
|
69
|
+
{
|
70
|
+
range: {
|
71
|
+
start: { line: 0, character: 0 },
|
72
|
+
end: { line: source.lines.size + 1, character: 0 }
|
73
|
+
},
|
74
|
+
newText: SyntaxTree.format(source)
|
75
|
+
}
|
76
|
+
end
|
77
|
+
|
78
|
+
def log(message)
|
79
|
+
write(method: "window/logMessage", params: { type: 4, message: message })
|
80
|
+
end
|
81
|
+
|
82
|
+
def inlay_hints(source)
|
83
|
+
inlay_hints = InlayHints.find(SyntaxTree.parse(source))
|
84
|
+
serialize = ->(position, text) { { position: position, text: text } }
|
85
|
+
|
86
|
+
{
|
87
|
+
before: inlay_hints.before.map(&serialize),
|
88
|
+
after: inlay_hints.after.map(&serialize)
|
89
|
+
}
|
90
|
+
rescue Parser::ParseError
|
91
|
+
end
|
92
|
+
|
93
|
+
def write(value)
|
94
|
+
response = value.merge(jsonrpc: "2.0").to_json
|
95
|
+
output.print("Content-Length: #{response.bytesize}\r\n\r\n#{response}")
|
96
|
+
output.flush
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|