syntax_tree 1.1.1 → 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class SyntaxTree
3
+ module SyntaxTree
4
4
  module CLI
5
5
  # A utility wrapper around colored strings in the output.
6
6
  class Color
@@ -15,6 +15,10 @@ class SyntaxTree
15
15
  "\033[#{code}m#{value}\033[0m"
16
16
  end
17
17
 
18
+ def self.bold(value)
19
+ new(value, "1")
20
+ end
21
+
18
22
  def self.gray(value)
19
23
  new(value, "38;5;102")
20
24
  end
@@ -30,7 +34,7 @@ class SyntaxTree
30
34
 
31
35
  # The parent action class for the CLI that implements the basics.
32
36
  class Action
33
- def run(filepath, source)
37
+ def run(handler, filepath, source)
34
38
  end
35
39
 
36
40
  def success
@@ -42,8 +46,8 @@ class SyntaxTree
42
46
 
43
47
  # An action of the CLI that prints out the AST for the given source.
44
48
  class AST < Action
45
- def run(filepath, source)
46
- pp SyntaxTree.parse(source)
49
+ def run(handler, filepath, source)
50
+ pp handler.parse(source)
47
51
  end
48
52
  end
49
53
 
@@ -53,8 +57,8 @@ class SyntaxTree
53
57
  class UnformattedError < StandardError
54
58
  end
55
59
 
56
- def run(filepath, source)
57
- raise UnformattedError if source != SyntaxTree.format(source)
60
+ def run(handler, filepath, source)
61
+ raise UnformattedError if source != handler.format(source)
58
62
  rescue StandardError
59
63
  warn("[#{Color.yellow("warn")}] #{filepath}")
60
64
  raise
@@ -75,11 +79,11 @@ class SyntaxTree
75
79
  class NonIdempotentFormatError < StandardError
76
80
  end
77
81
 
78
- def run(filepath, source)
82
+ def run(handler, filepath, source)
79
83
  warning = "[#{Color.yellow("warn")}] #{filepath}"
80
- formatted = SyntaxTree.format(source)
84
+ formatted = handler.format(source)
81
85
 
82
- if formatted != SyntaxTree.format(formatted)
86
+ if formatted != handler.format(formatted)
83
87
  raise NonIdempotentFormatError
84
88
  end
85
89
  rescue StandardError
@@ -98,28 +102,28 @@ class SyntaxTree
98
102
 
99
103
  # An action of the CLI that prints out the doc tree IR for the given source.
100
104
  class Doc < Action
101
- def run(filepath, source)
105
+ def run(handler, filepath, source)
102
106
  formatter = Formatter.new([])
103
- SyntaxTree.parse(source).format(formatter)
107
+ handler.parse(source).format(formatter)
104
108
  pp formatter.groups.first
105
109
  end
106
110
  end
107
111
 
108
112
  # An action of the CLI that formats the input source and prints it out.
109
113
  class Format < Action
110
- def run(filepath, source)
111
- puts SyntaxTree.format(source)
114
+ def run(handler, filepath, source)
115
+ puts handler.format(source)
112
116
  end
113
117
  end
114
118
 
115
119
  # An action of the CLI that formats the input source and writes the
116
120
  # formatted output back to the file.
117
121
  class Write < Action
118
- def run(filepath, source)
122
+ def run(handler, filepath, source)
119
123
  print filepath
120
124
  start = Time.now
121
125
 
122
- formatted = SyntaxTree.format(source)
126
+ formatted = handler.format(source)
123
127
  File.write(filepath, formatted)
124
128
 
125
129
  color = source == formatted ? Color.gray(filepath) : filepath
@@ -135,24 +139,65 @@ class SyntaxTree
135
139
  # The help message displayed if the input arguments are not correctly
136
140
  # ordered or formatted.
137
141
  HELP = <<~HELP
138
- stree MODE FILE
142
+ #{Color.bold("stree ast [OPTIONS] [FILE]")}
143
+ Print out the AST corresponding to the given files
144
+
145
+ #{Color.bold("stree check [OPTIONS] [FILE]")}
146
+ Check that the given files are formatted as syntax tree would format them
147
+
148
+ #{Color.bold("stree debug [OPTIONS] [FILE]")}
149
+ Check that the given files can be formatted idempotently
150
+
151
+ #{Color.bold("stree doc [OPTIONS] [FILE]")}
152
+ Print out the doc tree that would be used to format the given files
153
+
154
+ #{Color.bold("stree format [OPTIONS] [FILE]")}
155
+ Print out the formatted version of the given files
156
+
157
+ #{Color.bold("stree help")}
158
+ Display this help message
159
+
160
+ #{Color.bold("stree lsp")}
161
+ Run syntax tree in language server mode
139
162
 
140
- MODE: ast | check | debug | doc | format | write
141
- FILE: one or more paths to files to parse
163
+ #{Color.bold("stree version")}
164
+ Output the current version of syntax tree
165
+
166
+ #{Color.bold("stree write [OPTIONS] [FILE]")}
167
+ Read, format, and write back the source of the given files
168
+
169
+ [OPTIONS]
170
+
171
+ --plugins=...
172
+ A comma-separated list of plugins to load.
142
173
  HELP
143
174
 
144
175
  class << self
145
176
  # Run the CLI over the given array of strings that make up the arguments
146
177
  # passed to the invocation.
147
178
  def run(argv)
148
- if argv.length < 2
179
+ name, *arguments = argv
180
+
181
+ case name
182
+ when "help"
183
+ puts HELP
184
+ return 0
185
+ when "lsp"
186
+ require "syntax_tree/language_server"
187
+ LanguageServer.new.run
188
+ return 0
189
+ when "version"
190
+ puts SyntaxTree::VERSION
191
+ return 0
192
+ end
193
+
194
+ if arguments.empty?
149
195
  warn(HELP)
150
196
  return 1
151
197
  end
152
198
 
153
- arg, *patterns = argv
154
199
  action =
155
- case arg
200
+ case name
156
201
  when "a", "ast"
157
202
  AST.new
158
203
  when "c", "check"
@@ -170,15 +215,31 @@ class SyntaxTree
170
215
  return 1
171
216
  end
172
217
 
218
+ # If there are any plugins specified on the command line, then load them
219
+ # by requiring them here. We do this by transforming something like
220
+ #
221
+ # stree format --plugins=haml template.haml
222
+ #
223
+ # into
224
+ #
225
+ # require "syntax_tree/haml"
226
+ #
227
+ if arguments.first.start_with?("--plugins=")
228
+ plugins = arguments.shift[/^--plugins=(.*)$/, 1]
229
+ plugins.split(",").each { |plugin| require "syntax_tree/#{plugin}" }
230
+ end
231
+
173
232
  errored = false
174
- patterns.each do |pattern|
233
+ arguments.each do |pattern|
175
234
  Dir.glob(pattern).each do |filepath|
176
235
  next unless File.file?(filepath)
177
- source = SyntaxTree.read(filepath)
236
+
237
+ handler = HANDLERS[File.extname(filepath)]
238
+ source = handler.read(filepath)
178
239
 
179
240
  begin
180
- action.run(filepath, source)
181
- rescue ParseError => error
241
+ action.run(handler, filepath, source)
242
+ rescue Parser::ParseError => error
182
243
  warn("Error: #{error.message}")
183
244
 
184
245
  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