astel 0.2.0 → 0.3.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.
@@ -5,6 +5,7 @@ module Astel
5
5
  class Lexer
6
6
  SINGLE = { '(' => :lparen, ')' => :rparen, '{' => :lbrace, '}' => :rbrace,
7
7
  ':' => :colon, '$' => :capture }.freeze
8
+ OPERATOR_CHARACTERS = '+-*/%<>=~&|^![]@`'.freeze
8
9
 
9
10
  def initialize(source)
10
11
  @source = source
@@ -34,8 +35,9 @@ module Astel
34
35
  single = SINGLE[character]
35
36
  return token(single, advance, line, column) if single
36
37
  return read_string(line, column) if ['"', "'"].include?(character)
37
- return read_number(line, column) if character.match?(/[0-9-]/)
38
- return read_identifier(line, column) if character.match?(/[A-Za-z_]/)
38
+ return read_number(line, column) if number_start?(character)
39
+ return read_identifier(line, column) if identifier_start?(character)
40
+ return read_operator(line, column) if OPERATOR_CHARACTERS.include?(character)
39
41
 
40
42
  raise PatternError.new("unexpected character #{character.inspect}", line: line, column: column)
41
43
  end
@@ -44,15 +46,7 @@ module Astel
44
46
  quote = advance
45
47
  value = +''
46
48
  until eof? || current == quote
47
- if current == '\\'
48
- advance
49
- raise PatternError.new('unterminated escape', line: @line, column: @column) if eof?
50
-
51
- escaped = advance
52
- value << { 'n' => "\n", 'r' => "\r", 't' => "\t" }.fetch(escaped, escaped)
53
- else
54
- value << advance
55
- end
49
+ value << (current == '\\' ? read_escaped_character : advance)
56
50
  end
57
51
  raise PatternError.new('unterminated string', line: line, column: column) if eof?
58
52
 
@@ -60,6 +54,14 @@ module Astel
60
54
  token(:literal, value.freeze, line, column)
61
55
  end
62
56
 
57
+ def read_escaped_character
58
+ advance
59
+ raise PatternError.new('unterminated escape', line: @line, column: @column) if eof?
60
+
61
+ escaped = advance
62
+ { 'n' => "\n", 'r' => "\r", 't' => "\t" }.fetch(escaped, escaped)
63
+ end
64
+
63
65
  def read_number(line, column)
64
66
  start = @index
65
67
  advance if current == '-'
@@ -72,12 +74,31 @@ module Astel
72
74
 
73
75
  def read_identifier(line, column)
74
76
  start = @index
75
- advance while !eof? && current.match?(/[A-Za-z0-9_?!]/)
77
+ advance while !eof? && identifier_character?(current)
78
+ advance if !eof? && current == '='
76
79
  value = @source[start...@index]
77
80
  type = value == '_' ? :wildcard : :identifier
78
81
  token(type, value, line, column)
79
82
  end
80
83
 
84
+ def read_operator(line, column)
85
+ start = @index
86
+ advance while !eof? && OPERATOR_CHARACTERS.include?(current)
87
+ token(:identifier, @source[start...@index], line, column)
88
+ end
89
+
90
+ def number_start?(character)
91
+ character.match?(/[0-9]/) || (character == '-' && @source[@index + 1]&.match?(/[0-9]/))
92
+ end
93
+
94
+ def identifier_start?(character)
95
+ character.match?(/[[:alpha:]_]/)
96
+ end
97
+
98
+ def identifier_character?(character)
99
+ character.match?(/[[:alnum:]_?!]/)
100
+ end
101
+
81
102
  def skip_whitespace
82
103
  advance while !eof? && current.match?(/\s/)
83
104
  end
@@ -50,15 +50,20 @@ module Astel
50
50
  captures = []
51
51
 
52
52
  fields.each do |name, field_pattern|
53
- variable = next_variable('value')
54
- condition, field_captures = branch_for(field_pattern, variable)
55
- conditions << "((#{variable} = #{value}.#{name}); #{condition})"
53
+ condition, field_captures = field_branch(name, field_pattern, value)
54
+ conditions << condition
56
55
  captures.concat(field_captures)
57
56
  end
58
57
 
59
58
  [conditions.join(' && '), captures]
60
59
  end
61
60
 
61
+ def field_branch(name, pattern, value)
62
+ variable = next_variable('value')
63
+ condition, captures = branch_for(pattern, variable)
64
+ ["((#{variable} = #{value}.#{name}); #{condition})", captures]
65
+ end
66
+
62
67
  def next_variable(prefix)
63
68
  @variable += 1
64
69
  "#{prefix}_#{@variable}"
@@ -39,16 +39,21 @@ module Astel
39
39
  end
40
40
 
41
41
  def parse_node
42
- type = consume(:identifier, 'expected node type').value.to_sym
42
+ token = consume(:identifier, 'expected node type')
43
+ type = token.value.to_sym
43
44
  fields = []
44
45
  until accept(:rparen)
45
46
  error('unterminated node pattern') if current.type == :eof
46
-
47
- name = consume(:identifier, 'expected attribute name').value.to_sym
48
- consume(:colon, "expected ':' after attribute name")
49
- fields << [name, parse_pattern]
47
+ fields << parse_field
50
48
  end
51
- [:node, type, fields.freeze]
49
+ [:node, type, fields.freeze, token.line, token.column]
50
+ end
51
+
52
+ def parse_field
53
+ token = consume(:identifier, 'expected attribute name')
54
+ name = token.value.to_sym
55
+ consume(:colon, "expected ':' after attribute name")
56
+ [name, parse_pattern, token.line, token.column]
52
57
  end
53
58
 
54
59
  def parse_or
@@ -59,8 +64,11 @@ module Astel
59
64
  end
60
65
 
61
66
  def parse_symbol
62
- value = consume(:identifier, 'expected symbol name').value
63
- [:literal, value.to_sym]
67
+ token = current
68
+ unless accept(:identifier) || accept(:wildcard) || (token.type == :literal && token.value.is_a?(String) && accept(:literal))
69
+ error('expected symbol name')
70
+ end
71
+ [:literal, token.value.to_sym]
64
72
  end
65
73
 
66
74
  def accept(type)
@@ -28,7 +28,9 @@ module Astel
28
28
  when :wildcard then "!#{value}.nil?"
29
29
  when :capture then expression_for(arguments.fetch(0), value)
30
30
  when :or then or_expression(arguments.fetch(0), value)
31
- when :node then node_expression(arguments.fetch(0), arguments.fetch(1), value)
31
+ when :node
32
+ node_expression(arguments.fetch(0), arguments.fetch(1), value,
33
+ line: arguments.fetch(2), column: arguments.fetch(3))
32
34
  else raise ArgumentError, "unknown pattern type: #{type}"
33
35
  end
34
36
  end
@@ -37,22 +39,34 @@ module Astel
37
39
  alternatives.map { |alternative| "(#{expression_for(alternative, value)})" }.join(' || ')
38
40
  end
39
41
 
40
- def node_expression(type, fields, value)
42
+ def node_expression(type, fields, value, line:, column:)
41
43
  node_class = NODE_CLASSES[type]
42
- raise PatternError.new("unknown Prism node type #{type}", line: 1, column: 1) unless node_class
44
+ raise PatternError.new("unknown Prism node type #{type}", line: line, column: column) unless node_class
43
45
 
44
46
  conditions = ["#{node_class.name} === #{value}"]
45
- fields.each do |name, pattern|
46
- unless node_class.method_defined?(name)
47
- raise PatternError.new("unknown #{type} attribute #{name}", line: 1, column: 1)
48
- end
49
-
50
- variable = next_variable
51
- conditions << "((#{variable} = #{value}.#{name}); #{expression_for(pattern, variable)})"
47
+ fields.each do |name, pattern, field_line, field_column|
48
+ conditions << field_condition(
49
+ node_class, type, name, pattern, value,
50
+ line: field_line, column: field_column
51
+ )
52
52
  end
53
53
  conditions.join(' && ')
54
54
  end
55
55
 
56
+ def field_condition(node_class, type, name, pattern, value, line:, column:)
57
+ unless node_attribute?(node_class, name)
58
+ raise PatternError.new("unknown #{type} attribute #{name}", line: line, column: column)
59
+ end
60
+
61
+ variable = next_variable
62
+ "((#{variable} = #{value}.#{name}); #{expression_for(pattern, variable)})"
63
+ end
64
+
65
+ def node_attribute?(node_class, name)
66
+ fields = node_class.instance_method(:copy).parameters.map(&:last) - %i[node_id flags]
67
+ fields.include?(name) || (name.to_s.end_with?('?') && node_class.instance_methods(false).include?(name))
68
+ end
69
+
56
70
  def next_variable
57
71
  @variable += 1
58
72
  "value_#{@variable}"
@@ -50,23 +50,23 @@ module Astel
50
50
  cached = COMPILER_CACHE[source]
51
51
  return cached if cached
52
52
 
53
- tokens = Lexer.new(source).tokens
54
- syntax = Parser.new(tokens).parse
55
- predicate = PredicateCompiler.new.compile(syntax)
56
- matcher = MatcherCompiler.new.compile(syntax) if captures?(syntax)
57
53
  COMPILER_CACHE.shift if COMPILER_CACHE.length >= COMPILER_CACHE_LIMIT
58
- COMPILER_CACHE[source.dup.freeze] = [predicate, matcher].freeze
54
+ COMPILER_CACHE[source.dup.freeze] = compile_matchers(source)
59
55
  end
60
56
  end
61
57
  private_class_method :compiled_matchers
62
58
 
59
+ def self.compile_matchers(source)
60
+ syntax = Parser.new(Lexer.new(source).tokens).parse
61
+ predicate = PredicateCompiler.new.compile(syntax)
62
+ matcher = MatcherCompiler.new.compile(syntax) if captures?(syntax)
63
+ [predicate, matcher].freeze
64
+ end
65
+ private_class_method :compile_matchers
66
+
63
67
  def initialize(predicate, matcher)
64
68
  define_singleton_method(:match?, predicate)
65
- return unless matcher
66
-
67
- define_singleton_method(:match_captures, matcher)
68
- singleton_class.send(:private, :match_captures)
69
- extend CaptureMatching
69
+ install_capture_matcher(matcher) if matcher
70
70
  end
71
71
 
72
72
  def match(node)
@@ -77,6 +77,14 @@ module Astel
77
77
  captures
78
78
  end
79
79
 
80
+ private
81
+
82
+ def install_capture_matcher(matcher)
83
+ define_singleton_method(:match_captures, matcher)
84
+ singleton_class.send(:private, :match_captures)
85
+ extend CaptureMatching
86
+ end
87
+
80
88
  def self.captures?(pattern)
81
89
  type, *arguments = pattern
82
90
  return true if type == :capture
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Astel
6
+ module NodeSourceText
7
+ refine Prism::Node do
8
+ def source_text
9
+ location.slice
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,192 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../astel'
3
+ require_relative 'rewriter/structured'
4
4
 
5
5
  module Astel
6
- module Refactor
7
- def find_keyword_argument(call_node, name)
8
- keyword_hashes(call_node).each do |keyword_hash|
9
- match = keyword_hash.elements.find { |element| keyword_name(element) == name.to_sym }
10
- return match if match
11
- end
12
- nil
13
- end
14
-
15
- def remove_keyword_argument(call_node, name)
16
- argument = find_keyword_argument(call_node, name)
17
- return self unless argument
18
-
19
- keyword_hash = keyword_hashes(call_node).find { |hash| hash.elements.include?(argument) }
20
- if keyword_hash.elements.one?
21
- if call_node.arguments.arguments.one? && !call_node.opening_loc
22
- return remove(call_node.message_loc.end_offset...keyword_hash.location.end_offset)
23
- end
24
- remove_list_element(call_node.arguments, keyword_hash)
25
- else
26
- remove_list_element(keyword_hash, argument)
27
- end
28
- end
29
-
30
- def replace_keyword_key(assoc_node, name)
31
- replacement = assoc_node.operator_loc ? ":#{name}" : "#{name}:"
32
- replace(assoc_node.key.location, replacement)
33
- end
34
-
35
- def replace_keyword_value(assoc_node, replacement)
36
- replace(assoc_node.value.location, replacement)
37
- end
38
-
39
- def insert_keyword_argument(call_node, text, position: :last)
40
- raise ArgumentError, 'position must be :first or :last' unless %i[first last].include?(position)
41
-
42
- arguments = call_node.arguments
43
- unless arguments
44
- location = call_node.opening_loc || call_node.message_loc
45
- if call_node.opening_loc && call_node.closing_loc
46
- return insert_into_empty_delimited(call_node, text)
47
- end
48
- return insert_after(location, call_node.opening_loc ? text : " #{text}")
49
- end
50
-
51
- keyword_hash = keyword_hashes(call_node).first
52
- if keyword_hash
53
- index = position == :first ? 0 : keyword_hash.elements.length
54
- return insert_list_element(keyword_hash, index, text)
55
- end
56
-
57
- index = arguments.arguments.length
58
- insert_list_element(arguments, index, text)
59
- end
60
-
61
- def remove_list_element(collection_node, element_node)
62
- remove_list_elements(collection_node, [element_node])
63
- end
64
-
65
- def remove_list_elements(collection_node, element_nodes)
66
- elements = collection_elements(collection_node)
67
- indexes = element_nodes.map do |element|
68
- elements.index { |candidate| candidate.equal?(element) } ||
69
- raise(ArgumentError, 'element does not belong to collection')
70
- end.uniq.sort
71
- return self if indexes.empty?
72
-
73
- trailing_end = trailing_comma_end(elements.last)
74
- replacements = deletion_ranges(elements, indexes, trailing_end)
75
- start_offset = elements.first.location.start_offset
76
- end_offset = trailing_end
77
- content = @source_file.source.byteslice(start_offset, end_offset - start_offset).dup
78
- replacements.reverse_each do |from, to, replacement|
79
- content[(from - start_offset)...(to - start_offset)] = replacement
80
- end
81
- replace(start_offset...end_offset, content)
82
- end
83
-
84
- def insert_list_element(collection_node, index, text)
85
- elements = collection_elements(collection_node)
86
- raise IndexError, 'list index is outside the collection' unless index.between?(0, elements.length)
87
-
88
- if elements.empty?
89
- opening = collection_node.respond_to?(:opening_loc) && collection_node.opening_loc
90
- raise ArgumentError, 'cannot locate an empty collection body' unless opening
91
-
92
- return insert_into_empty_delimited(collection_node, text)
93
- end
94
-
95
- if index < elements.length
96
- target = elements[index]
97
- separator = multiline_collection?(collection_node) ? ",#{@source_file.newline}#{indentation_of(target)}" : ', '
98
- return insert_before(target.location, "#{text}#{separator}")
99
- end
100
-
101
- last = elements.last
102
- separator = multiline_collection?(collection_node) ? ",#{@source_file.newline}#{indentation_of(last)}" : ', '
103
- insert_after(last.location, "#{separator}#{text}")
104
- end
105
-
106
- private
107
-
108
- def keyword_hashes(call_node)
109
- call_node.arguments&.arguments&.grep(Prism::KeywordHashNode) || []
110
- end
111
-
112
- def keyword_name(assoc_node)
113
- key = assoc_node.key
114
- key.unescaped.to_sym if key.is_a?(Prism::SymbolNode)
115
- end
116
-
117
- def collection_elements(collection_node)
118
- case collection_node
119
- when Prism::ArgumentsNode then collection_node.arguments
120
- when Prism::ArrayNode, Prism::KeywordHashNode, Prism::HashNode then collection_node.elements
121
- else
122
- raise ArgumentError, 'collection must be an arguments, array, or hash node'
123
- end
124
- end
125
-
126
- def deletion_ranges(elements, indexes, trailing_end)
127
- removed = indexes.to_h { |index| [index, true] }
128
- groups = indexes.slice_when { |left, right| right != left + 1 }.to_a
129
- groups.map do |group|
130
- first = group.first
131
- last = group.last
132
- following = ((last + 1)...elements.length).find { |index| !removed[index] }
133
- preceding = (first - 1).downto(0).find { |index| !removed[index] }
134
-
135
- if following
136
- [elements[first].location.start_offset, elements[following].location.start_offset, '']
137
- elsif preceding
138
- from = elements[preceding].location.end_offset
139
- has_trailing_comma = last == elements.length - 1 && trailing_end > elements.last.location.end_offset
140
- to = has_trailing_comma ? trailing_end : elements[last].location.end_offset
141
- between = @source_file.source.byteslice(from, elements[first].location.start_offset - from)
142
- replacement = if has_trailing_comma
143
- between.include?('#') ? between.rstrip : ','
144
- elsif between.include?('#')
145
- between.sub(',', '').rstrip
146
- else
147
- ''
148
- end
149
- [from, to, replacement]
150
- else
151
- [elements.first.location.start_offset, trailing_end, '']
152
- end
153
- end
154
- end
155
-
156
- def trailing_comma_end(element)
157
- offset = element.location.end_offset
158
- source = @source_file.source
159
- offset += 1 while [32, 9].include?(source.getbyte(offset))
160
- source.getbyte(offset) == 44 ? offset + 1 : element.location.end_offset
161
- end
162
-
163
- def multiline_collection?(collection_node)
164
- return true if @source_file.slice(collection_node.location).include?("\n")
165
-
166
- start_offset = collection_node.location.start_offset
167
- prefix = @source_file.source.byteslice(@source_file.line_start(start_offset),
168
- start_offset - @source_file.line_start(start_offset))
169
- collection_node.is_a?(Prism::KeywordHashNode) && !prefix.empty? && prefix.strip.empty?
170
- end
171
-
172
- def insert_into_empty_delimited(node, text)
173
- opening = node.opening_loc
174
- closing = node.closing_loc
175
- return insert_after(opening, text) unless closing
176
-
177
- source = @source_file.source
178
- interior = source.byteslice(opening.end_offset, closing.start_offset - opening.end_offset)
179
- return insert_after(opening, text) unless interior.include?("\n")
180
-
181
- indentation = @source_file.indentation_at(closing.start_offset)
182
- replacement = "#{@source_file.newline}#{indentation}#{@source_file.indent_unit}#{text}" \
183
- "#{@source_file.newline}#{indentation}"
184
- replace(opening.end_offset...closing.start_offset, replacement)
185
- end
186
- end
6
+ Refactor = Rewriter.const_get(:Structured, false)
187
7
  end
188
-
189
- require_relative 'refactor/formatting'
190
- require_relative 'refactor/comments'
191
-
192
- Astel::Rewriter.include(Astel::Refactor)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astel
4
+ class Rewriter
5
+ class EditIndex
6
+ include Enumerable
7
+
8
+ SPLIT_THRESHOLD = 256
9
+
10
+ def initialize
11
+ @blocks = []
12
+ end
13
+
14
+ def add(edit)
15
+ if @blocks.empty?
16
+ @blocks << [edit]
17
+ return
18
+ end
19
+
20
+ block_index, block, edit_index = insertion_position(edit)
21
+ conflict = conflict_at(block_index, block, edit_index) { |existing| yield existing }
22
+ return conflict if conflict
23
+
24
+ block.insert(edit_index, edit)
25
+ split(block_index, block) if block.length > SPLIT_THRESHOLD
26
+ nil
27
+ end
28
+
29
+ def find_conflict(edit)
30
+ return if @blocks.empty?
31
+
32
+ block_index, block, edit_index = insertion_position(edit)
33
+ conflict_at(block_index, block, edit_index) { |existing| yield existing }
34
+ end
35
+
36
+ def each
37
+ return enum_for(__method__) unless block_given?
38
+
39
+ @blocks.each do |block|
40
+ block.each { |edit| yield edit }
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def insertion_position(edit)
47
+ block_index = block_index_for(edit)
48
+ block = @blocks.fetch(block_index)
49
+ edit_index = block.bsearch_index { |existing| ordered_after?(existing, edit) } || block.length
50
+ [block_index, block, edit_index]
51
+ end
52
+
53
+ def conflict_at(block_index, block, edit_index)
54
+ following = block[edit_index] || @blocks[block_index + 1]&.first
55
+ return following if following && yield(following)
56
+
57
+ preceding = if edit_index.positive?
58
+ block[edit_index - 1]
59
+ elsif block_index.positive?
60
+ @blocks[block_index - 1].last
61
+ end
62
+ return preceding if preceding && yield(preceding)
63
+
64
+ nil
65
+ end
66
+
67
+ def block_index_for(edit)
68
+ @blocks.bsearch_index { |block| ordered_after?(block.last, edit) } || @blocks.length - 1
69
+ end
70
+
71
+ def ordered_after?(existing, edit)
72
+ existing.start_offset > edit.start_offset ||
73
+ (existing.start_offset == edit.start_offset && existing.end_offset > edit.end_offset) ||
74
+ (existing.start_offset == edit.start_offset && existing.end_offset == edit.end_offset &&
75
+ existing.sequence > edit.sequence)
76
+ end
77
+
78
+ def split(block_index, block)
79
+ middle = block.length / 2
80
+ @blocks.insert(block_index + 1, block.slice!(middle, block.length - middle))
81
+ end
82
+ end
83
+ end
84
+ end