astel 0.1.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.
@@ -1,13 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'prism'
3
+ require_relative 'node_source_text'
4
4
 
5
5
  module Astel
6
- module NodeExt
7
- refine Prism::Node do
8
- def source_text
9
- location.slice
10
- end
11
- end
12
- end
6
+ NodeExt = NodeSourceText
13
7
  end
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rewriter/structured'
4
+
5
+ module Astel
6
+ Refactor = Rewriter.const_get(:Structured, false)
7
+ end
@@ -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
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astel
4
+ class Rewriter
5
+ module Structured
6
+ def remove_list_element(collection_node, element_node)
7
+ remove_list_elements(collection_node, [element_node])
8
+ end
9
+
10
+ def remove_list_elements(collection_node, element_nodes)
11
+ elements = collection_elements(collection_node)
12
+ indexes = element_indexes(elements, element_nodes)
13
+ return self if indexes.empty?
14
+
15
+ trailing_end = trailing_comma_end(elements.last)
16
+ start_offset = elements.first.location.start_offset
17
+ content = content_without_elements(
18
+ elements, indexes, start_offset: start_offset, end_offset: trailing_end
19
+ )
20
+ extra_ranges = indexes.flat_map { |index| heredoc_ranges(elements[index]) }
21
+ return replace(start_offset...trailing_end, content) if extra_ranges.empty?
22
+
23
+ transaction(raise_on_conflict: true) do |rewriter|
24
+ rewriter.replace(start_offset...trailing_end, content)
25
+ extra_ranges.each { |range| rewriter.remove(range) }
26
+ end
27
+ self
28
+ end
29
+
30
+ def insert_list_element(collection_node, index, text)
31
+ elements = collection_elements(collection_node)
32
+ raise IndexError, 'list index is outside the collection' unless index.between?(0, elements.length)
33
+
34
+ if elements.empty?
35
+ opening = collection_node.respond_to?(:opening_loc) && collection_node.opening_loc
36
+ raise ArgumentError, 'cannot locate an empty collection body' unless opening
37
+
38
+ return insert_into_empty_delimited(collection_node, text)
39
+ end
40
+
41
+ if index < elements.length
42
+ target = elements[index]
43
+ return insert_before(target.location, "#{text}#{list_separator(collection_node, target)}")
44
+ end
45
+
46
+ last = elements.last
47
+ insert_after(last.location, "#{list_separator(collection_node, last)}#{text}")
48
+ end
49
+
50
+ private
51
+
52
+ def element_indexes(elements, element_nodes)
53
+ element_nodes.map do |element|
54
+ elements.index { |candidate| candidate.equal?(element) } ||
55
+ raise(ArgumentError, 'element does not belong to collection')
56
+ end.uniq.sort
57
+ end
58
+
59
+ def content_without_elements(elements, indexes, start_offset:, end_offset:)
60
+ content = @source_file.source.byteslice(start_offset, end_offset - start_offset).b
61
+ deletion_ranges(elements, indexes, end_offset).reverse_each do |from, to, replacement|
62
+ content.bytesplice(from - start_offset, to - from, replacement.b)
63
+ end
64
+ content.force_encoding(@source_file.source.encoding)
65
+ end
66
+
67
+ def list_separator(collection_node, adjacent_element)
68
+ separator = percent_list?(collection_node) ? '' : ','
69
+ return "#{separator} " unless multiline_collection?(collection_node)
70
+
71
+ "#{separator}#{@source_file.newline}#{indentation_of(adjacent_element)}"
72
+ end
73
+
74
+ def collection_elements(collection_node)
75
+ case collection_node
76
+ when Prism::ArgumentsNode then collection_node.arguments
77
+ when Prism::ArrayNode, Prism::KeywordHashNode, Prism::HashNode then collection_node.elements
78
+ else
79
+ raise ArgumentError, 'collection must be an arguments, array, or hash node'
80
+ end
81
+ end
82
+
83
+ def deletion_ranges(elements, indexes, trailing_end)
84
+ removed = indexes.to_h { |index| [index, true] }
85
+ indexes.slice_when { |left, right| right != left + 1 }.map do |group|
86
+ deletion_range(elements, group, removed, trailing_end)
87
+ end
88
+ end
89
+
90
+ def deletion_range(elements, group, removed, trailing_end)
91
+ first = group.first
92
+ last = group.last
93
+ following = ((last + 1)...elements.length).find { |index| !removed[index] }
94
+ return [elements[first].location.start_offset, elements[following].location.start_offset, ''] if following
95
+
96
+ preceding = (first - 1).downto(0).find { |index| !removed[index] }
97
+ return [elements.first.location.start_offset, trailing_end, ''] unless preceding
98
+
99
+ from = elements[preceding].location.end_offset
100
+ has_trailing_comma = last == elements.length - 1 && trailing_end > elements.last.location.end_offset
101
+ to = has_trailing_comma ? trailing_end : elements[last].location.end_offset
102
+ between = @source_file.source.byteslice(from, elements[first].location.start_offset - from)
103
+ replacement = tail_deletion_replacement(
104
+ between,
105
+ trailing_comma: has_trailing_comma,
106
+ line_break_follows: line_break_follows?(to)
107
+ )
108
+ [from, to, replacement]
109
+ end
110
+
111
+ def tail_deletion_replacement(separator, trailing_comma:, line_break_follows:)
112
+ replacement = if trailing_comma
113
+ separator.include?('#') ? trim_trailing_horizontal_space(separator) : ','
114
+ elsif separator.include?('#')
115
+ trim_trailing_horizontal_space(separator.sub(',', ''))
116
+ else
117
+ return ''
118
+ end
119
+ line_break_follows ? replacement.delete_suffix("\r\n").delete_suffix("\n") : replacement
120
+ end
121
+
122
+ def line_break_follows?(offset)
123
+ source = @source_file.source
124
+ offset += 1 while [32, 9].include?(source.getbyte(offset))
125
+ [10, 13].include?(source.getbyte(offset))
126
+ end
127
+
128
+ def trailing_comma_end(element)
129
+ offset = element.location.end_offset
130
+ source = @source_file.source
131
+ offset += 1 while [32, 9].include?(source.getbyte(offset))
132
+ source.getbyte(offset) == 44 ? offset + 1 : element.location.end_offset
133
+ end
134
+
135
+ def multiline_collection?(collection_node)
136
+ return true if @source_file.slice(collection_node.location).include?("\n")
137
+
138
+ start_offset = collection_node.location.start_offset
139
+ prefix = @source_file.source.byteslice(@source_file.line_start(start_offset),
140
+ start_offset - @source_file.line_start(start_offset))
141
+ collection_node.is_a?(Prism::KeywordHashNode) && !prefix.empty? && prefix.strip.empty?
142
+ end
143
+
144
+ def percent_list?(collection_node)
145
+ collection_node.is_a?(Prism::ArrayNode) &&
146
+ collection_node.opening_loc&.slice&.match?(/\A%[wWiI]/)
147
+ end
148
+
149
+ def trim_trailing_horizontal_space(text)
150
+ text.sub(/[ \t]+\z/, '')
151
+ end
152
+
153
+ def insert_into_empty_delimited(node, text)
154
+ opening = node.opening_loc
155
+ closing = node.closing_loc
156
+ return insert_after(opening, text) unless closing
157
+
158
+ source = @source_file.source
159
+ interior = source.byteslice(opening.end_offset, closing.start_offset - opening.end_offset)
160
+ return insert_after(opening, text) unless interior.include?("\n")
161
+
162
+ indentation = @source_file.indentation_at(closing.start_offset)
163
+ offset = @source_file.line_start(closing.start_offset)
164
+ insert_before(offset...offset, "#{indentation}#{@source_file.indent_unit}#{text}#{@source_file.newline}")
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astel
4
+ class Rewriter
5
+ module Structured
6
+ def leading_comments(node)
7
+ attach_comments
8
+ node.location.leading_comments
9
+ end
10
+
11
+ def trailing_comment(node)
12
+ attach_comments
13
+ node.location.trailing_comments.first
14
+ end
15
+
16
+ def remove_with_comments(node)
17
+ comments = leading_comments(node)
18
+ trailing = trailing_comment(node)
19
+ start_offset = comments.empty? ? node.location.start_offset : comments.first.location.start_offset
20
+ end_offset = [node_end_offset(node), trailing&.location&.end_offset].compact.max
21
+ remove_lines_if_isolated(start_offset: start_offset, end_offset: end_offset)
22
+ end
23
+
24
+ private
25
+
26
+ def attach_comments
27
+ @source_file.attach_comments!
28
+ end
29
+ end
30
+ end
31
+ end