astel 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +168 -0
- data/Rakefile +8 -0
- data/benchmark/dispatch_bench.rb +51 -0
- data/benchmark/node_pattern_bench.rb +83 -0
- data/benchmark/rewriter_bench.rb +105 -0
- data/lib/astel/dispatcher.rb +105 -0
- data/lib/astel/location.rb +37 -0
- data/lib/astel/node_ext.rb +13 -0
- data/lib/astel/node_pattern/lexer.rb +110 -0
- data/lib/astel/node_pattern/matcher_compiler.rb +68 -0
- data/lib/astel/node_pattern/parser.rb +92 -0
- data/lib/astel/node_pattern/predicate_compiler.rb +62 -0
- data/lib/astel/node_pattern.rb +90 -0
- data/lib/astel/node_type.rb +14 -0
- data/lib/astel/rewriter.rb +171 -0
- data/lib/astel/source_file.rb +68 -0
- data/lib/astel/version.rb +5 -0
- data/lib/astel.rb +15 -0
- metadata +81 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
class NodePattern
|
|
5
|
+
class Lexer
|
|
6
|
+
SINGLE = { '(' => :lparen, ')' => :rparen, '{' => :lbrace, '}' => :rbrace,
|
|
7
|
+
':' => :colon, '$' => :capture }.freeze
|
|
8
|
+
|
|
9
|
+
def initialize(source)
|
|
10
|
+
@source = source
|
|
11
|
+
@index = 0
|
|
12
|
+
@line = 1
|
|
13
|
+
@column = 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def tokens
|
|
17
|
+
result = []
|
|
18
|
+
loop do
|
|
19
|
+
skip_whitespace
|
|
20
|
+
break if eof?
|
|
21
|
+
|
|
22
|
+
result << next_token
|
|
23
|
+
end
|
|
24
|
+
result << Token.new(type: :eof, value: nil, line: @line, column: @column)
|
|
25
|
+
result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def next_token
|
|
31
|
+
line = @line
|
|
32
|
+
column = @column
|
|
33
|
+
character = current
|
|
34
|
+
single = SINGLE[character]
|
|
35
|
+
return token(single, advance, line, column) if single
|
|
36
|
+
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_]/)
|
|
39
|
+
|
|
40
|
+
raise PatternError.new("unexpected character #{character.inspect}", line: line, column: column)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read_string(line, column)
|
|
44
|
+
quote = advance
|
|
45
|
+
value = +''
|
|
46
|
+
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
|
|
56
|
+
end
|
|
57
|
+
raise PatternError.new('unterminated string', line: line, column: column) if eof?
|
|
58
|
+
|
|
59
|
+
advance
|
|
60
|
+
token(:literal, value.freeze, line, column)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def read_number(line, column)
|
|
64
|
+
start = @index
|
|
65
|
+
advance if current == '-'
|
|
66
|
+
advance while !eof? && current.match?(/[0-9]/)
|
|
67
|
+
text = @source[start...@index]
|
|
68
|
+
raise PatternError.new('invalid number', line: line, column: column) if text == '-'
|
|
69
|
+
|
|
70
|
+
token(:literal, Integer(text, 10), line, column)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def read_identifier(line, column)
|
|
74
|
+
start = @index
|
|
75
|
+
advance while !eof? && current.match?(/[A-Za-z0-9_?!]/)
|
|
76
|
+
value = @source[start...@index]
|
|
77
|
+
type = value == '_' ? :wildcard : :identifier
|
|
78
|
+
token(type, value, line, column)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def skip_whitespace
|
|
82
|
+
advance while !eof? && current.match?(/\s/)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def token(type, value, line, column)
|
|
86
|
+
Token.new(type: type, value: value, line: line, column: column)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def current
|
|
90
|
+
@source[@index]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def eof?
|
|
94
|
+
@index >= @source.length
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def advance
|
|
98
|
+
character = current
|
|
99
|
+
@index += 1
|
|
100
|
+
if character == "\n"
|
|
101
|
+
@line += 1
|
|
102
|
+
@column = 1
|
|
103
|
+
else
|
|
104
|
+
@column += 1
|
|
105
|
+
end
|
|
106
|
+
character
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
class NodePattern
|
|
5
|
+
class MatcherCompiler
|
|
6
|
+
def compile(pattern)
|
|
7
|
+
@variable = 0
|
|
8
|
+
expression = result_expression(pattern, 'value')
|
|
9
|
+
eval("->(value) { #{expression} }", binding, __FILE__, __LINE__)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def result_expression(pattern, value)
|
|
15
|
+
if pattern.first == :or
|
|
16
|
+
return pattern.fetch(1).map do |alternative|
|
|
17
|
+
"(#{result_expression(alternative, value)})"
|
|
18
|
+
end.join(' || ')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
condition, captures = branch_for(pattern, value)
|
|
22
|
+
"((#{condition}) ? [#{captures.join(', ')}] : nil)"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def branch_for(pattern, value)
|
|
26
|
+
type, *arguments = pattern
|
|
27
|
+
case type
|
|
28
|
+
when :literal then ["#{value} == #{arguments.fetch(0).inspect}", []]
|
|
29
|
+
when :wildcard then ["!#{value}.nil?", []]
|
|
30
|
+
when :capture then capture_branch(arguments.fetch(0), value)
|
|
31
|
+
when :or then or_branch(pattern, value)
|
|
32
|
+
when :node then node_branch(arguments.fetch(0), arguments.fetch(1), value)
|
|
33
|
+
else raise ArgumentError, "unknown pattern type: #{type}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def capture_branch(pattern, value)
|
|
38
|
+
condition, captures = branch_for(pattern, value)
|
|
39
|
+
[condition, [value, *captures]]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def or_branch(pattern, value)
|
|
43
|
+
captures = next_variable('captures')
|
|
44
|
+
["(#{captures} = #{result_expression(pattern, value)})", ["*#{captures}"]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def node_branch(type, fields, value)
|
|
48
|
+
node_class = PredicateCompiler::NODE_CLASSES.fetch(type)
|
|
49
|
+
conditions = ["#{node_class.name} === #{value}"]
|
|
50
|
+
captures = []
|
|
51
|
+
|
|
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})"
|
|
56
|
+
captures.concat(field_captures)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
[conditions.join(' && '), captures]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def next_variable(prefix)
|
|
63
|
+
@variable += 1
|
|
64
|
+
"#{prefix}_#{@variable}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
class NodePattern
|
|
5
|
+
class Parser
|
|
6
|
+
def initialize(tokens)
|
|
7
|
+
@tokens = tokens
|
|
8
|
+
@index = 0
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def parse
|
|
12
|
+
pattern = parse_pattern
|
|
13
|
+
error("unexpected token #{current.value.inspect}") unless current.type == :eof
|
|
14
|
+
pattern
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def parse_pattern
|
|
20
|
+
return parse_node if accept(:lparen)
|
|
21
|
+
return parse_or if accept(:lbrace)
|
|
22
|
+
return [:capture, parse_pattern] if accept(:capture)
|
|
23
|
+
return [:wildcard] if accept(:wildcard)
|
|
24
|
+
return parse_symbol if accept(:colon)
|
|
25
|
+
return [:literal, previous.value] if accept(:literal)
|
|
26
|
+
|
|
27
|
+
return parse_identifier if accept(:identifier)
|
|
28
|
+
|
|
29
|
+
error('expected a pattern')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse_identifier
|
|
33
|
+
value = previous.value
|
|
34
|
+
return [:literal, nil] if value == 'nil'
|
|
35
|
+
return [:literal, true] if value == 'true'
|
|
36
|
+
return [:literal, false] if value == 'false'
|
|
37
|
+
|
|
38
|
+
[:literal, value.to_sym]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_node
|
|
42
|
+
type = consume(:identifier, 'expected node type').value.to_sym
|
|
43
|
+
fields = []
|
|
44
|
+
until accept(:rparen)
|
|
45
|
+
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]
|
|
50
|
+
end
|
|
51
|
+
[:node, type, fields.freeze]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_or
|
|
55
|
+
alternatives = []
|
|
56
|
+
alternatives << parse_pattern until accept(:rbrace)
|
|
57
|
+
error('OR pattern must contain an alternative') if alternatives.empty?
|
|
58
|
+
[:or, alternatives.freeze]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse_symbol
|
|
62
|
+
value = consume(:identifier, 'expected symbol name').value
|
|
63
|
+
[:literal, value.to_sym]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def accept(type)
|
|
67
|
+
return false unless current.type == type
|
|
68
|
+
|
|
69
|
+
@index += 1
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def consume(type, message)
|
|
74
|
+
token = current
|
|
75
|
+
error(message) unless accept(type)
|
|
76
|
+
token
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def current
|
|
80
|
+
@tokens[@index]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def previous
|
|
84
|
+
@tokens[@index - 1]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def error(message)
|
|
88
|
+
raise PatternError.new(message, line: current.line, column: current.column)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
class NodePattern
|
|
5
|
+
class PredicateCompiler
|
|
6
|
+
NODE_CLASSES = Prism.constants.filter_map do |constant|
|
|
7
|
+
value = Prism.const_get(constant)
|
|
8
|
+
next unless value.is_a?(Class) && value < Prism::Node
|
|
9
|
+
|
|
10
|
+
type = NodeType.from_class_name(constant.to_s)
|
|
11
|
+
[type, value]
|
|
12
|
+
rescue NameError
|
|
13
|
+
nil
|
|
14
|
+
end.to_h.freeze
|
|
15
|
+
|
|
16
|
+
def compile(pattern)
|
|
17
|
+
@variable = 0
|
|
18
|
+
expression = expression_for(pattern, 'value')
|
|
19
|
+
eval("->(value) { #{expression} }", binding, __FILE__, __LINE__)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def expression_for(pattern, value)
|
|
25
|
+
type, *arguments = pattern
|
|
26
|
+
case type
|
|
27
|
+
when :literal then "#{value} == #{arguments.fetch(0).inspect}"
|
|
28
|
+
when :wildcard then "!#{value}.nil?"
|
|
29
|
+
when :capture then expression_for(arguments.fetch(0), value)
|
|
30
|
+
when :or then or_expression(arguments.fetch(0), value)
|
|
31
|
+
when :node then node_expression(arguments.fetch(0), arguments.fetch(1), value)
|
|
32
|
+
else raise ArgumentError, "unknown pattern type: #{type}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def or_expression(alternatives, value)
|
|
37
|
+
alternatives.map { |alternative| "(#{expression_for(alternative, value)})" }.join(' || ')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def node_expression(type, fields, value)
|
|
41
|
+
node_class = NODE_CLASSES[type]
|
|
42
|
+
raise PatternError.new("unknown Prism node type #{type}", line: 1, column: 1) unless node_class
|
|
43
|
+
|
|
44
|
+
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)})"
|
|
52
|
+
end
|
|
53
|
+
conditions.join(' && ')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def next_variable
|
|
57
|
+
@variable += 1
|
|
58
|
+
"value_#{@variable}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Astel
|
|
6
|
+
class NodePattern
|
|
7
|
+
COMPILER_CACHE_LIMIT = 128
|
|
8
|
+
COMPILER_CACHE = {}
|
|
9
|
+
COMPILER_CACHE_MUTEX = Mutex.new
|
|
10
|
+
private_constant :COMPILER_CACHE, :COMPILER_CACHE_MUTEX
|
|
11
|
+
|
|
12
|
+
class PatternError < Error
|
|
13
|
+
attr_reader :line, :column
|
|
14
|
+
|
|
15
|
+
def initialize(message, line:, column:)
|
|
16
|
+
@line = line
|
|
17
|
+
@column = column
|
|
18
|
+
super("#{message} at line #{line}, column #{column}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Token = Data.define(:type, :value, :line, :column)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require_relative 'node_pattern/lexer'
|
|
27
|
+
require_relative 'node_pattern/parser'
|
|
28
|
+
require_relative 'node_pattern/matcher_compiler'
|
|
29
|
+
require_relative 'node_pattern/predicate_compiler'
|
|
30
|
+
|
|
31
|
+
module Astel
|
|
32
|
+
class NodePattern
|
|
33
|
+
module CaptureMatching
|
|
34
|
+
def match(node)
|
|
35
|
+
captures = match_captures(node)
|
|
36
|
+
return unless captures
|
|
37
|
+
|
|
38
|
+
yield captures if block_given?
|
|
39
|
+
captures
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.compile(source)
|
|
44
|
+
predicate, matcher = compiled_matchers(source)
|
|
45
|
+
new(predicate, matcher)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.compiled_matchers(source)
|
|
49
|
+
COMPILER_CACHE_MUTEX.synchronize do
|
|
50
|
+
cached = COMPILER_CACHE[source]
|
|
51
|
+
return cached if cached
|
|
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
|
+
COMPILER_CACHE.shift if COMPILER_CACHE.length >= COMPILER_CACHE_LIMIT
|
|
58
|
+
COMPILER_CACHE[source.dup.freeze] = [predicate, matcher].freeze
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
private_class_method :compiled_matchers
|
|
62
|
+
|
|
63
|
+
def initialize(predicate, matcher)
|
|
64
|
+
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
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def match(node)
|
|
73
|
+
return unless match?(node)
|
|
74
|
+
|
|
75
|
+
captures = []
|
|
76
|
+
yield captures if block_given?
|
|
77
|
+
captures
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.captures?(pattern)
|
|
81
|
+
type, *arguments = pattern
|
|
82
|
+
return true if type == :capture
|
|
83
|
+
return arguments.fetch(0).any? { |alternative| captures?(alternative) } if type == :or
|
|
84
|
+
return arguments.fetch(1).any? { |_name, field_pattern| captures?(field_pattern) } if type == :node
|
|
85
|
+
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
private_class_method :captures?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
class Rewriter
|
|
5
|
+
class ConflictError < Error
|
|
6
|
+
attr_reader :edit, :conflicting_edit
|
|
7
|
+
|
|
8
|
+
def initialize(edit, conflicting_edit)
|
|
9
|
+
@edit = edit
|
|
10
|
+
@conflicting_edit = conflicting_edit
|
|
11
|
+
super("edit #{edit.start_offset}...#{edit.end_offset} conflicts with " \
|
|
12
|
+
"#{conflicting_edit.start_offset}...#{conflicting_edit.end_offset}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Edit = Data.define(:start_offset, :end_offset, :replacement, :sequence) do
|
|
17
|
+
def insertion?
|
|
18
|
+
start_offset == end_offset
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class EditIndex
|
|
23
|
+
include Enumerable
|
|
24
|
+
|
|
25
|
+
SPLIT_THRESHOLD = 256
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@blocks = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add(edit)
|
|
32
|
+
if @blocks.empty?
|
|
33
|
+
@blocks << [edit]
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
block_index = block_index_for(edit)
|
|
38
|
+
block = @blocks.fetch(block_index)
|
|
39
|
+
edit_index = block.bsearch_index { |existing| ordered_after?(existing, edit) } || block.length
|
|
40
|
+
following = block[edit_index] || @blocks[block_index + 1]&.first
|
|
41
|
+
return following if following && yield(following)
|
|
42
|
+
|
|
43
|
+
preceding = if edit_index.positive?
|
|
44
|
+
block[edit_index - 1]
|
|
45
|
+
elsif block_index.positive?
|
|
46
|
+
@blocks[block_index - 1].last
|
|
47
|
+
end
|
|
48
|
+
return preceding if preceding && yield(preceding)
|
|
49
|
+
|
|
50
|
+
block.insert(edit_index, edit)
|
|
51
|
+
split(block_index, block) if block.length > SPLIT_THRESHOLD
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def each
|
|
56
|
+
return enum_for(__method__) unless block_given?
|
|
57
|
+
|
|
58
|
+
@blocks.each do |block|
|
|
59
|
+
block.each { |edit| yield edit }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def block_index_for(edit)
|
|
66
|
+
@blocks.bsearch_index { |block| ordered_after?(block.last, edit) } || @blocks.length - 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ordered_after?(existing, edit)
|
|
70
|
+
existing.start_offset > edit.start_offset ||
|
|
71
|
+
(existing.start_offset == edit.start_offset && existing.end_offset > edit.end_offset) ||
|
|
72
|
+
(existing.start_offset == edit.start_offset && existing.end_offset == edit.end_offset &&
|
|
73
|
+
existing.sequence > edit.sequence)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def split(block_index, block)
|
|
77
|
+
middle = block.length / 2
|
|
78
|
+
@blocks.insert(block_index + 1, block.slice!(middle, block.length - middle))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def initialize(source_file)
|
|
83
|
+
@source_file = source_file
|
|
84
|
+
@edits = []
|
|
85
|
+
@edits_snapshot = nil
|
|
86
|
+
@edit_index = EditIndex.new
|
|
87
|
+
@sequence = 0
|
|
88
|
+
@result_size_delta = 0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def edits
|
|
92
|
+
@edits_snapshot ||= @edits.dup.freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def replace(location, replacement)
|
|
96
|
+
start_offset, end_offset = offsets(location)
|
|
97
|
+
add_edit(start_offset, end_offset, replacement)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def insert_before(location, content)
|
|
101
|
+
start_offset, = offsets(location)
|
|
102
|
+
add_edit(start_offset, start_offset, content)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def insert_after(location, content)
|
|
106
|
+
_, end_offset = offsets(location)
|
|
107
|
+
add_edit(end_offset, end_offset, content)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def remove(location)
|
|
111
|
+
replace(location, '')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def rewrite
|
|
115
|
+
source = @source_file.source
|
|
116
|
+
result = String.new(capacity: source.bytesize + @result_size_delta, encoding: source.encoding)
|
|
117
|
+
cursor = 0
|
|
118
|
+
|
|
119
|
+
@edit_index.each do |edit|
|
|
120
|
+
result << source.byteslice(cursor, edit.start_offset - cursor)
|
|
121
|
+
result << edit.replacement
|
|
122
|
+
cursor = edit.end_offset
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
result << source.byteslice(cursor, source.bytesize - cursor)
|
|
126
|
+
result
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def add_edit(start_offset, end_offset, replacement)
|
|
132
|
+
validate_offsets(start_offset, end_offset)
|
|
133
|
+
edit = Edit.new(
|
|
134
|
+
start_offset: start_offset,
|
|
135
|
+
end_offset: end_offset,
|
|
136
|
+
replacement: replacement.to_s.dup.freeze,
|
|
137
|
+
sequence: @sequence
|
|
138
|
+
)
|
|
139
|
+
conflict = @edit_index.add(edit) { |existing| conflict?(edit, existing) }
|
|
140
|
+
raise ConflictError.new(edit, conflict) if conflict
|
|
141
|
+
|
|
142
|
+
@sequence += 1
|
|
143
|
+
@result_size_delta += edit.replacement.bytesize - (edit.end_offset - edit.start_offset)
|
|
144
|
+
@edits << edit
|
|
145
|
+
@edits_snapshot = nil
|
|
146
|
+
self
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def offsets(location)
|
|
150
|
+
if location.is_a?(Range)
|
|
151
|
+
ending = location.exclude_end? ? location.end : location.end + 1
|
|
152
|
+
return [location.begin, ending]
|
|
153
|
+
end
|
|
154
|
+
[location.start_offset, location.end_offset]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_offsets(start_offset, end_offset)
|
|
158
|
+
valid = start_offset.is_a?(Integer) && end_offset.is_a?(Integer) &&
|
|
159
|
+
start_offset >= 0 && start_offset <= end_offset && end_offset <= @source_file.source.bytesize
|
|
160
|
+
raise RangeError, 'edit range is outside the source' unless valid
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def conflict?(left, right)
|
|
164
|
+
return left.start_offset == right.start_offset if left.insertion? && right.insertion?
|
|
165
|
+
return left.start_offset > right.start_offset && left.start_offset < right.end_offset if left.insertion?
|
|
166
|
+
return right.start_offset > left.start_offset && right.start_offset < left.end_offset if right.insertion?
|
|
167
|
+
|
|
168
|
+
left.start_offset < right.end_offset && right.start_offset < left.end_offset
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Astel
|
|
6
|
+
class SourceFile
|
|
7
|
+
attr_reader :path, :source, :ast, :comments, :errors, :parse_result
|
|
8
|
+
|
|
9
|
+
def self.parse(path:, version: nil)
|
|
10
|
+
from_string(File.binread(path), path: path, version: version)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.from_string(code, path: '(string)', version: nil)
|
|
14
|
+
new(code, path: path, version: version)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(code, path:, version: nil)
|
|
18
|
+
@path = path.to_s.freeze
|
|
19
|
+
raw_source = code.dup.freeze
|
|
20
|
+
options = { filepath: @path }
|
|
21
|
+
options[:version] = normalize_version(version) if version
|
|
22
|
+
@parse_result = Prism.parse(raw_source, **options)
|
|
23
|
+
@source = @parse_result.source.source.freeze
|
|
24
|
+
@ast = @parse_result.value
|
|
25
|
+
@comments = @parse_result.comments.freeze
|
|
26
|
+
@errors = @parse_result.errors.freeze
|
|
27
|
+
@lines = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def lines
|
|
31
|
+
@lines ||= source.lines(chomp: false).map!(&:freeze).freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def valid?
|
|
35
|
+
errors.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def bof
|
|
39
|
+
Location.point(offset: 0, line: 1, column: 0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def first_line_location
|
|
43
|
+
line = lines.first || ''
|
|
44
|
+
Location.new(
|
|
45
|
+
start_offset: 0,
|
|
46
|
+
end_offset: line.bytesize,
|
|
47
|
+
start_line: 1,
|
|
48
|
+
start_column: 0,
|
|
49
|
+
end_line: 1,
|
|
50
|
+
end_column: line.delete_suffix("\n").delete_suffix("\r").length
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def magic_comment?(name)
|
|
55
|
+
pattern = /\A#\s*#{Regexp.escape(name.to_s)}\s*:\s*true\b/
|
|
56
|
+
lines.first(2).any? do |line|
|
|
57
|
+
candidate = line.start_with?("\uFEFF") ? line.delete_prefix("\uFEFF") : line
|
|
58
|
+
candidate.match?(pattern)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def normalize_version(version)
|
|
65
|
+
version.match?(/\A\d+\.\d+\z/) ? "#{version}.0" : version
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/astel.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'astel/version'
|
|
4
|
+
|
|
5
|
+
module Astel
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
autoload :Location, File.expand_path('astel/location', __dir__)
|
|
9
|
+
autoload :SourceFile, File.expand_path('astel/source_file', __dir__)
|
|
10
|
+
autoload :NodeExt, File.expand_path('astel/node_ext', __dir__)
|
|
11
|
+
autoload :NodeType, File.expand_path('astel/node_type', __dir__)
|
|
12
|
+
autoload :Dispatcher, File.expand_path('astel/dispatcher', __dir__)
|
|
13
|
+
autoload :NodePattern, File.expand_path('astel/node_pattern', __dir__)
|
|
14
|
+
autoload :Rewriter, File.expand_path('astel/rewriter', __dir__)
|
|
15
|
+
end
|