syntax_tree 1.1.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,7 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class SyntaxTree
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 SyntaxTree.parse(source)
60
+ def run(handler, filepath, source)
61
+ pp handler.parse(source)
47
62
  end
48
63
  end
49
64
 
@@ -53,9 +68,9 @@ class SyntaxTree
53
68
  class UnformattedError < StandardError
54
69
  end
55
70
 
56
- def run(filepath, source)
57
- raise UnformattedError if source != SyntaxTree.format(source)
58
- rescue
71
+ def run(handler, filepath, source)
72
+ raise UnformattedError if source != handler.format(source)
73
+ rescue StandardError
59
74
  warn("[#{Color.yellow("warn")}] #{filepath}")
60
75
  raise
61
76
  end
@@ -75,14 +90,14 @@ 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 = SyntaxTree.format(source)
95
+ formatted = handler.format(source)
81
96
 
82
- if formatted != SyntaxTree.format(formatted)
97
+ if formatted != handler.format(formatted)
83
98
  raise NonIdempotentFormatError
84
99
  end
85
- rescue
100
+ rescue StandardError
86
101
  warn(warning)
87
102
  raise
88
103
  end
@@ -98,35 +113,35 @@ 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
- SyntaxTree.parse(source).format(formatter)
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 SyntaxTree.format(source)
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 = SyntaxTree.format(source)
137
+ formatted = handler.format(source)
123
138
  File.write(filepath, formatted)
124
139
 
125
140
  color = source == formatted ? Color.gray(filepath) : filepath
126
141
  delta = ((Time.now - start) * 1000).round
127
142
 
128
143
  puts "\r#{color} #{delta}ms"
129
- rescue
144
+ rescue StandardError
130
145
  puts "\r#{filepath}"
131
146
  raise
132
147
  end
@@ -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 MODE FILE
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
170
+
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
139
176
 
140
- MODE: ast | check | debug | doc | format | write
141
- FILE: one or more paths to files to parse
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
- if argv.length < 2
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 arg
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
- patterns.each do |pattern|
244
+ arguments.each do |pattern|
175
245
  Dir.glob(pattern).each do |filepath|
176
246
  next unless File.file?(filepath)
177
- source = source_for(filepath)
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
@@ -210,19 +282,6 @@ class SyntaxTree
210
282
 
211
283
  private
212
284
 
213
- # Returns the source from the given filepath taking into account any
214
- # potential magic encoding comments.
215
- def source_for(filepath)
216
- encoding =
217
- File.open(filepath, "r") do |file|
218
- header = file.readline
219
- header += file.readline if header.start_with?("#!")
220
- Ripper.new(header).tap(&:parse).encoding
221
- end
222
-
223
- File.read(filepath, encoding: encoding)
224
- end
225
-
226
285
  # Highlights a snippet from a source and parse error.
227
286
  def highlight_error(error, source)
228
287
  lines = source.lines
@@ -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