syntax_tree 1.1.1 → 2.0.1

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,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