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.
@@ -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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astel
4
+ module NodeType
5
+ module_function
6
+
7
+ def from_class_name(name)
8
+ name.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
9
+ .gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
10
+ .downcase
11
+ .to_sym
12
+ end
13
+ end
14
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astel
4
+ VERSION = '0.1.0'
5
+ 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