ffast 0.2.0 → 0.2.3

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +27 -0
  3. data/.github/workflows/ruby.yml +34 -0
  4. data/.gitignore +2 -0
  5. data/Fastfile +146 -3
  6. data/README.md +244 -132
  7. data/bin/console +6 -1
  8. data/bin/fast-experiment +3 -0
  9. data/bin/fast-mcp +7 -0
  10. data/fast.gemspec +24 -7
  11. data/lib/fast/cli.rb +129 -38
  12. data/lib/fast/experiment.rb +19 -2
  13. data/lib/fast/git.rb +1 -1
  14. data/lib/fast/mcp_server.rb +317 -0
  15. data/lib/fast/node.rb +258 -0
  16. data/lib/fast/prism_adapter.rb +310 -0
  17. data/lib/fast/rewriter.rb +64 -10
  18. data/lib/fast/scan.rb +203 -0
  19. data/lib/fast/shortcut.rb +23 -6
  20. data/lib/fast/source.rb +116 -0
  21. data/lib/fast/source_rewriter.rb +153 -0
  22. data/lib/fast/sql/rewriter.rb +98 -0
  23. data/lib/fast/sql.rb +165 -0
  24. data/lib/fast/summary.rb +435 -0
  25. data/lib/fast/version.rb +1 -1
  26. data/lib/fast.rb +165 -79
  27. data/mkdocs.yml +27 -3
  28. data/requirements-docs.txt +3 -0
  29. metadata +48 -62
  30. data/docs/command_line.md +0 -238
  31. data/docs/editors-integration.md +0 -46
  32. data/docs/experiments.md +0 -153
  33. data/docs/ideas.md +0 -80
  34. data/docs/index.md +0 -402
  35. data/docs/pry-integration.md +0 -27
  36. data/docs/research.md +0 -93
  37. data/docs/shortcuts.md +0 -323
  38. data/docs/similarity_tutorial.md +0 -176
  39. data/docs/syntax.md +0 -395
  40. data/docs/videos.md +0 -16
  41. data/examples/build_stubbed_and_let_it_be_experiment.rb +0 -51
  42. data/examples/experimental_replacement.rb +0 -46
  43. data/examples/find_usage.rb +0 -26
  44. data/examples/let_it_be_experiment.rb +0 -11
  45. data/examples/method_complexity.rb +0 -37
  46. data/examples/search_duplicated.rb +0 -15
  47. data/examples/similarity_research.rb +0 -58
  48. data/examples/simple_rewriter.rb +0 -6
  49. data/experiments/let_it_be_experiment.rb +0 -9
  50. data/experiments/remove_useless_hook.rb +0 -9
  51. data/experiments/replace_create_with_build_stubbed.rb +0 -10
data/lib/fast/shortcut.rb CHANGED
@@ -7,14 +7,18 @@ module Fast
7
7
  # 1. Current directory that the command is being runned
8
8
  # 2. Home folder
9
9
  # 3. Using the `FAST_FILE_DIR` variable to set an extra folder
10
- LOOKUP_FAST_FILES_DIRECTORIES = [Dir.pwd, ENV['HOME'], ENV['FAST_FILE_DIR']].freeze
10
+ LOOKUP_FAST_FILES_DIRECTORIES = [
11
+ Dir.pwd,
12
+ ENV['HOME'],
13
+ ENV['FAST_FILE_DIR'],
14
+ File.join(File.dirname(__FILE__), '..', '..')
15
+ ].reverse.compact.map(&File.method(:expand_path)).uniq.freeze
11
16
 
12
17
  # Store predefined searches with default paths through shortcuts.
13
18
  # define your Fastfile in you root folder or
14
19
  # @example Shortcut for finding validations in rails models
15
20
  # Fast.shortcut(:validations, "(send nil {validate validates})", "app/models")
16
21
  def shortcut(identifier, *args, &block)
17
- puts "identifier #{identifier.inspect} will be override" if shortcuts.key?(identifier)
18
22
  shortcuts[identifier] = Shortcut.new(*args, &block)
19
23
  end
20
24
 
@@ -29,12 +33,25 @@ module Fast
29
33
  def fast_files
30
34
  @fast_files ||= LOOKUP_FAST_FILES_DIRECTORIES.compact
31
35
  .map { |dir| File.join(dir, 'Fastfile') }
32
- .select(&File.method(:exists?))
36
+ .select(&File.method(:exist?))
33
37
  end
34
38
 
35
39
  # Loads `Fastfiles` from {.fast_files} list
36
40
  def load_fast_files!
37
- fast_files.each(&method(:load))
41
+ @loaded_fast_files ||= []
42
+ fast_files.each do |file|
43
+ next if @loaded_fast_files.include?(file)
44
+
45
+ load file
46
+ @loaded_fast_files << file
47
+ end
48
+ end
49
+
50
+ def render_markdown_for_terminal(line)
51
+ require 'tty-markdown'
52
+ TTY::Markdown.parse(line)
53
+ rescue LoadError
54
+ line
38
55
  end
39
56
  end
40
57
 
@@ -60,7 +77,7 @@ module Fast
60
77
  def merge_args(extra_args)
61
78
  all_args = (@args + extra_args).uniq
62
79
  options = all_args.select { |arg| arg.start_with? '-' }
63
- files = extra_args.select(&File.method(:exists?))
80
+ files = extra_args.select(&File.method(:exist?))
64
81
  command = (@args - options - files).first
65
82
 
66
83
  [command, *options, *files]
@@ -73,7 +90,7 @@ module Fast
73
90
  # Use ARGV to catch regular arguments from command line if the block is
74
91
  # given.
75
92
  #
76
- # @return [Hash<String, Array<Astrolabe::Node>] with file => search results.
93
+ # @return [Hash<String, Array<Fast::Node>>] with file => search results.
77
94
  def run
78
95
  Fast.instance_exec(&@block) if single_run_with_block?
79
96
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Source
5
+ class Buffer
6
+ attr_accessor :source
7
+ attr_reader :name
8
+
9
+ def initialize(name, source: nil)
10
+ @name = name
11
+ @source = source
12
+ end
13
+
14
+ def source_range(begin_pos = 0, end_pos = source.to_s.length)
15
+ Fast::Source.range(self, begin_pos, end_pos)
16
+ end
17
+ end
18
+
19
+ class Range
20
+ attr_reader :begin_pos, :end_pos, :source_buffer
21
+
22
+ def initialize(source_buffer, begin_pos, end_pos)
23
+ @source_buffer = source_buffer
24
+ @begin_pos = begin_pos
25
+ @end_pos = end_pos
26
+ end
27
+
28
+ def begin
29
+ self.class.new(source_buffer, begin_pos, begin_pos)
30
+ end
31
+
32
+ def end
33
+ self.class.new(source_buffer, end_pos, end_pos)
34
+ end
35
+
36
+ def source
37
+ source_buffer.source.to_s[begin_pos...end_pos]
38
+ end
39
+
40
+ def line
41
+ first_line
42
+ end
43
+
44
+ def first_line
45
+ source_buffer.source.to_s[0...begin_pos].count("\n") + 1
46
+ end
47
+
48
+ def last_line
49
+ source_buffer.source.to_s[0...end_pos].count("\n") + 1
50
+ end
51
+
52
+ def column
53
+ last_newline = source_buffer.source.to_s.rindex("\n", begin_pos - 1)
54
+ begin_pos - (last_newline ? last_newline + 1 : 0)
55
+ end
56
+
57
+ def to_range
58
+ begin_pos...end_pos
59
+ end
60
+
61
+ def join(other)
62
+ self.class.new(source_buffer, [begin_pos, other.begin_pos].min, [end_pos, other.end_pos].max)
63
+ end
64
+
65
+ def adjust(begin_pos: 0, end_pos: 0)
66
+ self.class.new(source_buffer, self.begin_pos + begin_pos, self.end_pos + end_pos)
67
+ end
68
+ end
69
+
70
+ class Map
71
+ attr_accessor :expression, :node
72
+
73
+ def initialize(expression)
74
+ @expression = expression
75
+ end
76
+
77
+ def begin
78
+ expression.begin_pos
79
+ end
80
+
81
+ def end
82
+ expression.end_pos
83
+ end
84
+
85
+ def with_expression(new_expression)
86
+ duplicate_with(expression: new_expression)
87
+ end
88
+
89
+ def with_operator(operator)
90
+ duplicate_with(operator: operator)
91
+ end
92
+
93
+ private
94
+
95
+ def duplicate_with(overrides = {})
96
+ copy = dup
97
+ overrides.each { |name, value| copy.instance_variable_set(:"@#{name}", value) }
98
+ copy
99
+ end
100
+ end
101
+
102
+ module_function
103
+
104
+ def buffer(name, source: nil, buffer_class: Fast::Source::Buffer)
105
+ buffer_class.new(name, source: source)
106
+ end
107
+
108
+ def range(buffer, start_pos, end_pos)
109
+ Fast::Source::Range.new(buffer, start_pos, end_pos)
110
+ end
111
+
112
+ def map(expression)
113
+ Fast::Source::Map.new(expression)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'source'
4
+
5
+ module Fast
6
+ class SourceRewriter
7
+ Edit = Struct.new(:kind, :begin_pos, :end_pos, :content, :order, keyword_init: true)
8
+ ClobberingError = Class.new(StandardError)
9
+
10
+ attr_reader :source_buffer
11
+
12
+ def initialize(source_buffer)
13
+ @source_buffer = source_buffer
14
+ @edits = []
15
+ @order = 0
16
+ end
17
+
18
+ def replace(range, content)
19
+ add_edit(:replace, range, content.to_s)
20
+ self
21
+ end
22
+
23
+ def remove(range)
24
+ replace(range, '')
25
+ end
26
+
27
+ def wrap(range, before, after)
28
+ insert_before(range, before) unless before.nil?
29
+ insert_after(range, after) unless after.nil?
30
+ self
31
+ end
32
+
33
+ def insert_before(range, content)
34
+ add_edit(:insert_before, range.begin, content.to_s)
35
+ self
36
+ end
37
+
38
+ def insert_after(range, content)
39
+ add_edit(:insert_after, range.end, content.to_s)
40
+ self
41
+ end
42
+
43
+ def process
44
+ source = source_buffer.source.to_s
45
+ normalized_replacements = normalize_replacements
46
+ return source if normalized_replacements.empty? && insertions.empty?
47
+
48
+ before_insertions = build_insertions(:insert_before)
49
+ after_insertions = build_insertions(:insert_after)
50
+
51
+ result = +''
52
+ cursor = 0
53
+
54
+ normalized_replacements.each do |replacement|
55
+ result << emit_unreplaced_segment(source, cursor, replacement.begin_pos, before_insertions, after_insertions)
56
+ result << before_insertions.fetch(replacement.begin_pos, '')
57
+ result << replacement.content
58
+ result << after_insertions.fetch(replacement.end_pos, '')
59
+ cursor = replacement.end_pos
60
+ end
61
+
62
+ result << emit_unreplaced_segment(source, cursor, source.length, before_insertions, after_insertions)
63
+ result
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :edits
69
+
70
+ def add_edit(kind, range, content)
71
+ edits << Edit.new(
72
+ kind: kind,
73
+ begin_pos: range.begin_pos,
74
+ end_pos: range.end_pos,
75
+ content: content,
76
+ order: next_order
77
+ )
78
+ end
79
+
80
+ def next_order
81
+ @order += 1
82
+ end
83
+
84
+ def insertions
85
+ edits.select { |edit| insertion?(edit) }
86
+ end
87
+
88
+ def replacements
89
+ edits.reject { |edit| insertion?(edit) }
90
+ end
91
+
92
+ def insertion?(edit)
93
+ edit.kind == :insert_before || edit.kind == :insert_after
94
+ end
95
+
96
+ def normalize_replacements
97
+ replacements
98
+ .sort_by { |edit| [edit.begin_pos, edit.end_pos, edit.order] }
99
+ .each_with_object([]) do |edit, normalized|
100
+ previous = normalized.last
101
+ if previous && overlaps?(previous, edit)
102
+ if deletion?(previous) && deletion?(edit)
103
+ previous.end_pos = [previous.end_pos, edit.end_pos].max
104
+ elsif same_range?(previous, edit)
105
+ previous.content = edit.content
106
+ previous.order = edit.order
107
+ else
108
+ raise ClobberingError, "Overlapping rewrite on #{edit.begin_pos}...#{edit.end_pos}"
109
+ end
110
+ else
111
+ normalized << edit.dup
112
+ end
113
+ end
114
+ end
115
+
116
+ def overlaps?(left, right)
117
+ left.begin_pos < right.end_pos && right.begin_pos < left.end_pos
118
+ end
119
+
120
+ def same_range?(left, right)
121
+ left.begin_pos == right.begin_pos && left.end_pos == right.end_pos
122
+ end
123
+
124
+ def deletion?(edit)
125
+ edit.content.empty?
126
+ end
127
+
128
+ def build_insertions(kind)
129
+ edits
130
+ .select { |edit| edit.kind == kind }
131
+ .group_by(&:begin_pos)
132
+ .transform_values do |position_edits|
133
+ position_edits
134
+ .sort_by(&:order)
135
+ .map(&:content)
136
+ .join
137
+ end
138
+ end
139
+
140
+ def emit_unreplaced_segment(source, from, to, before_insertions, after_insertions)
141
+ segment = +''
142
+ cursor = from
143
+
144
+ while cursor < to
145
+ segment << before_insertions.fetch(cursor, '')
146
+ segment << source[cursor]
147
+ cursor += 1
148
+ segment << after_insertions.fetch(cursor, '')
149
+ end
150
+ segment
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,98 @@
1
+ module Fast
2
+ module SQL
3
+ class << self
4
+ # @see Fast::SQLRewriter
5
+ # @return string with the content updated in case the pattern matches.
6
+ def replace(pattern, ast, &replacement)
7
+ rewriter_for(pattern, ast, &replacement).rewrite!
8
+ end
9
+
10
+ # @return [Fast::SQL::Rewriter]
11
+ # @see Fast::Rewriter
12
+ def rewriter_for(pattern, ast, &replacement)
13
+ rewriter = Rewriter.new
14
+ rewriter.ast = ast
15
+ rewriter.search = pattern
16
+ rewriter.replacement = replacement
17
+ rewriter
18
+ end
19
+
20
+ # @return Fast::SQL::Node with the parsed content
21
+ def parse_file(file)
22
+ parse(IO.read(file), buffer_name: file)
23
+ end
24
+
25
+ # Replace a SQL file with the given pattern.
26
+ # Use a replacement code block to change the content.
27
+ # @return nil in case does not update the file
28
+ # @return true in case the file is updated
29
+ # @see Fast::SQL::Rewriter
30
+ def replace_file(pattern, file, &replacement)
31
+ original = IO.read(file)
32
+ ast = parse_file(file)
33
+ content = replace(pattern, ast, &replacement)
34
+ if content != original
35
+ File.open(file, 'w+') { |f| f.print content }
36
+ end
37
+ end
38
+ end
39
+
40
+ # Extends fast rewriter to support SQL
41
+ # @see Fast::Rewriter
42
+ class Rewriter < Fast::Rewriter
43
+
44
+ def rewrite!
45
+ replace_on(*types)
46
+ case ast
47
+ when Array
48
+ rewrite(buffer, ast.first)
49
+ else
50
+ rewrite(buffer, ast)
51
+ end
52
+ end
53
+
54
+ def source
55
+ super ||
56
+ begin
57
+ case ast
58
+ when Array
59
+ ast.first
60
+ else
61
+ ast
62
+ end.location.expression.source_buffer.source
63
+ end
64
+ end
65
+ # @return [Array<Symbol>] with all types that matches
66
+ def types
67
+ case ast
68
+ when Array
69
+ ast.map(&:type)
70
+ when NilClass
71
+ []
72
+ else
73
+ ast.type
74
+ end
75
+ end
76
+
77
+ # Generate methods for all affected types.
78
+ # Note the strategy is different from parent class,
79
+ # it will not stop on first match, but will execute the replacement on
80
+ # all matching elements.
81
+ # @see Fast.replace
82
+ def replace_on(*types)
83
+ types.map do |type|
84
+ self.instance_exec do
85
+ self.class.define_method :"on_#{type}" do |node|
86
+ # SQL nodes are not being automatically invoked by the rewriter,
87
+ # so we need to match the root node and invoke on matching inner elements.
88
+ Fast.search(search, ast).each_with_index do |node, i|
89
+ @match_index += 1
90
+ execute_replacement(node, i)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
data/lib/fast/sql.rb ADDED
@@ -0,0 +1,165 @@
1
+ require 'pg_query'
2
+ require_relative '../fast/source'
3
+ require_relative 'sql/rewriter'
4
+
5
+ module Fast
6
+
7
+ module_function
8
+
9
+ # Shortcut to parse a sql file
10
+ # @example Fast.parse_sql_file('spec/fixtures/sql/select.sql')
11
+ # @return [Fast::Node] the AST representation of the sql statements from a file
12
+ def parse_sql_file(file)
13
+ SQL.parse_file(file)
14
+ end
15
+
16
+ # @return [Fast::SQLRewriter] which can be used to rewrite the SQL
17
+ # @see Fast::SQLRewriter
18
+ def sql_rewriter_for(pattern, ast, &replacement)
19
+ SQL.rewriter_for(pattern, ast, &replacement)
20
+ end
21
+
22
+ # @return string with the sql content updated in case the pattern matches.
23
+ # @see Fast::SQLRewriter
24
+ # @example
25
+ # Fast.replace_sql('ival', Fast.parse_sql('select 1'), &->(node){ replace(node.location.expression, '2') }) # => "select 2"
26
+ def replace_sql(pattern, ast, &replacement)
27
+ SQL.replace(pattern, ast, &replacement)
28
+ end
29
+
30
+ # @return string with the sql content updated in case the pattern matches.
31
+ def replace_sql_file(pattern, file, &replacement)
32
+ SQL.replace_file(pattern, file, &replacement)
33
+ end
34
+
35
+ # @return [Fast::Node] the AST representation of the sql statement
36
+ # @example
37
+ # ast = Fast.parse_sql("select 'hello AST'")
38
+ # => s(:select_stmt,
39
+ # s(:target_list,
40
+ # s(:res_target,
41
+ # s(:val,
42
+ # s(:a_const,
43
+ # s(:val,
44
+ # s(:string,
45
+ # s(:str, "hello AST"))))))))
46
+ # `s` represents a Fast::Node with additional methods to access the tokens
47
+ # and location of the node.
48
+ # ast.search(:string).first.location.expression
49
+ # => #<Fast::Source::Range (sql) 7...18>
50
+ def parse_sql(statement, buffer_name: "(sql)")
51
+ SQL.parse(statement, buffer_name: buffer_name)
52
+ end
53
+
54
+ # This module contains methods to parse SQL statements and rewrite them.
55
+ # It uses PGQuery to parse the SQL statements.
56
+ # It uses Parser to rewrite the SQL statements.
57
+ # It uses Fast::Source::Map to map the AST nodes to the SQL tokens.
58
+ #
59
+ # @example
60
+ # Fast::SQL.parse("select 1")
61
+ # => s(:select_stmt, s(:target_list, ...
62
+ # @see Fast::SQL::Node
63
+ module SQL
64
+ # The SQL source buffer is a subclass of Fast::Source::Buffer
65
+ # which contains the tokens of the SQL statement.
66
+ # When you call `ast.location.expression` it will return a range
67
+ # which is mapped to the tokens.
68
+ # @example
69
+ # ast = Fast::SQL.parse("select 1")
70
+ # ast.location.expression # => #<Fast::Source::Range (sql) 0...9>
71
+ # ast.location.expression.source_buffer.tokens
72
+ # => [
73
+ # <PgQuery::ScanToken: start: 0, end: 6, token: :SELECT, keyword_kind: :RESERVED_KEYWORD>,
74
+ # <PgQuery::ScanToken: start: 7, end: 8, token: :ICONST, keyword_kind: :NO_KEYWORD>]
75
+ # @see Fast::SQL::Node
76
+ class SourceBuffer < Fast::Source::Buffer
77
+ def tokens
78
+ @tokens ||= PgQuery.scan(source).first.tokens
79
+ end
80
+ end
81
+
82
+ # The SQL node is an AST node with additional tokenization info
83
+ class Node < Fast::Node
84
+
85
+ def first(pattern)
86
+ search(pattern).first
87
+ end
88
+
89
+ def replace(pattern, with=nil, &replacement)
90
+ replacement ||= -> (n) { replace(n.loc.expression, with) }
91
+ if root?
92
+ SQL.replace(pattern, self, &replacement)
93
+ else
94
+ parent.replace(pattern, &replacement)
95
+ end
96
+ end
97
+
98
+ def token
99
+ tokens.find{|e|e.start == location.begin}
100
+ end
101
+
102
+ def tokens
103
+ location.expression.source_buffer.tokens
104
+ end
105
+ end
106
+
107
+ module_function
108
+
109
+ # Parses SQL statements Using PGQuery
110
+ # @see sql_to_h
111
+ def parse(statement, buffer_name: "(sql)")
112
+ return [] if statement.nil?
113
+ source_buffer = SQL::SourceBuffer.new(buffer_name, source: statement)
114
+ tree = PgQuery.parse(statement).tree
115
+ first, *, last = source_buffer.tokens
116
+ stmts = tree.stmts.map do |stmt|
117
+ from = stmt.stmt_location
118
+ to = stmt.stmt_len.zero? ? last.end : from + stmt.stmt_len
119
+ expression = Fast::Source.range(source_buffer, from, to)
120
+ source_map = Fast::Source.map(expression)
121
+ sql_tree_to_ast(clean_structure(stmt.stmt.to_h), source_buffer: source_buffer, source_map: source_map)
122
+ end.flatten
123
+ stmts.one? ? stmts.first : stmts
124
+ end
125
+
126
+ # Clean up the hash structure returned by PgQuery
127
+ # @arg [Hash] hash the hash representation of the sql statement
128
+ # @return [Hash] the hash representation of the sql statement
129
+ def clean_structure(stmt)
130
+ res_hash = stmt.map do |key, value|
131
+ value = clean_structure(value) if value.is_a?(Hash)
132
+ value = value.map(&Fast::SQL.method(:clean_structure)) if value.is_a?(Array)
133
+ value = nil if [{}, [], "", :SETOP_NONE, :LIMIT_OPTION_DEFAULT, false].include?(value)
134
+ key = key.to_s.tr('-','_').to_sym
135
+ [key, value]
136
+ end
137
+ res_hash.to_h.compact
138
+ end
139
+
140
+ # Transform a sql tree into an AST.
141
+ # Populates the location of the AST nodes with the source map.
142
+ # @arg [Hash] obj the hash representation of the sql statement
143
+ # @return [Array] the AST representation of the sql statement
144
+ def sql_tree_to_ast(obj, source_buffer: nil, source_map: nil)
145
+ recursive = -> (e) { sql_tree_to_ast(e, source_buffer: source_buffer, source_map: source_map.dup) }
146
+ case obj
147
+ when Array
148
+ obj.map(&recursive).flatten.compact
149
+ when Hash
150
+ if (start = obj.delete(:location))
151
+ if (token = source_buffer.tokens.find{|e|e.start == start})
152
+ expression = Fast::Source.range(source_buffer, token.start, token.end)
153
+ source_map = Fast::Source.map(expression)
154
+ end
155
+ end
156
+ obj.map do |key, value|
157
+ children = [*recursive.call(value)]
158
+ Node.new(key, children, location: source_map)
159
+ end.compact
160
+ else
161
+ obj
162
+ end
163
+ end
164
+ end
165
+ end