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.
@@ -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,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 != SyntaxTree.format(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 = 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
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
- 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
@@ -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
139
170
 
140
- MODE: ast | check | debug | doc | format | write
141
- FILE: one or more paths to files to parse
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
- 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 = SyntaxTree.read(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
@@ -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