fastererer 0.12.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee0a8fc9f430360ccef546a4f391fdb3112801f6fb2c56204900f0dca8214063
4
- data.tar.gz: a477c6a7b4b9f19011ea89bee00aa193d562f91981dd38e5136cd88fd7ebca14
3
+ metadata.gz: de024ad42cbd1fb10608607fd4b8f4bbe0c6ff58414f001fea232aee478d2c40
4
+ data.tar.gz: 2c2722e7fedbe2d4d8542b41db6ad37d61020bea79b5e437305651549cb8c6d5
5
5
  SHA512:
6
- metadata.gz: 4d239a4233228c45cc0c0ea28ef00110a8d2ea6476c258ef8f68d3f5b443a66cc48724f47529bb6eb787857843a3c7a4d84aed4aeda09c3a2266880c65ccb14a
7
- data.tar.gz: 6ac88eca14ca854edc0e549b4ddbd6cd9b39796fb9658c10e3c16399b65a7341cea039c0d18fbb9e243406fe8503072ec25ba1a9671dba35d77f0549995bc7ea
6
+ metadata.gz: c67fad403a643c864bc7989ca2bb0346802c143baef0bf5bd211a6b6a294abf81dcea1d031a8cf9adca452ee3d83cf58dc75f04297b2ab05fb87c23cfa73cd01
7
+ data.tar.gz: f034537ef76b425c479ff9dff2304859f869543f7bddad5c2596baa68d4d39525584b65f8c132ca7bc7918049408951c2f02446ae8181929e0c6b4f42695043f
data/CHANGELOG.md CHANGED
@@ -8,6 +8,36 @@ Versions 0.11.0 and earlier were released as [`fasterer`](https://github.com/Dam
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.0.0] - 2026-06-05
12
+
13
+ First stable release of `fastererer` after the fork.
14
+
15
+ ### Changed
16
+
17
+ - [#9]: Migrate from `ruby_parser` to Ruby's native Prism parser. Prism ships with Ruby itself,
18
+ so this drops the `ruby_parser`, `sexp_processor`, and `racc` dependencies and keeps the
19
+ analyzer in step with current and future Ruby syntax, including Ruby 4.0.
20
+
21
+ ### Added
22
+
23
+ - [#9]: Detect speedups reached through a safe-navigation chain (e.g. `arr&.first`,
24
+ `arr&.select { … }.first`), which the previous S-expression scanner skipped.
25
+ - [#9]: Detect speedups inside `rescue` bodies — getter-vs-`attr_reader`,
26
+ setter-vs-`attr_writer`, and proc-call-vs-`yield` offenses are now scanned in rescue clauses.
27
+ - [#15]: Offense output now mirrors RuboCop's format with a `Performance/RuleName` rule name and a
28
+ [fast-ruby](https://github.com/fastruby/fast-ruby) documentation link, e.g.
29
+ `path:line: W: Performance/ForLoopVsEach: For loop is slower than using each. (url)`.
30
+
31
+ ### Fixed
32
+
33
+ - [#9]: Block calls inside a nested scope (a singleton class or a nested method definition) are
34
+ no longer misattributed to the enclosing method.
35
+ - [#9]: Unparseable source now raises a clear error instead of silently reporting no offenses.
36
+ - [#28]: `hash_merge_bang_vs_hash_brackets` now flags `Hash#update`, an alias of `Hash#merge!`, but
37
+ only when the receiver is provably a Hash (a `{}` literal, `Hash.new`, or `Hash[...]`). Because
38
+ `update` is also defined on `ActiveRecord`, `ActionController::Parameters`, and others, a bare
39
+ `obj.update(k => v)` is left untouched to avoid false positives.
40
+
11
41
  ## [0.12.0] - 2026-05-18
12
42
 
13
43
  ### Added
@@ -106,8 +136,12 @@ Versions 0.11.0 and earlier were released as [`fasterer`](https://github.com/Dam
106
136
 
107
137
  [Keep a Changelog]: https://keepachangelog.com
108
138
  [Semantic Versioning]: https://semver.org/spec/v2.0.0.html
109
- [Unreleased]: https://github.com/ExtractableMedia/fastererer/compare/v0.12.0...HEAD
139
+ [Unreleased]: https://github.com/ExtractableMedia/fastererer/compare/v1.0.0...HEAD
140
+ [1.0.0]: https://github.com/ExtractableMedia/fastererer/compare/v0.12.0...v1.0.0
110
141
  [0.12.0]: https://github.com/ExtractableMedia/fastererer/compare/v0.11.0...v0.12.0
111
142
  [#1]: https://github.com/ExtractableMedia/fastererer/pull/1
112
143
  [#2]: https://github.com/ExtractableMedia/fastererer/pull/2
144
+ [#9]: https://github.com/ExtractableMedia/fastererer/pull/9
145
+ [#15]: https://github.com/ExtractableMedia/fastererer/pull/15
146
+ [#28]: https://github.com/ExtractableMedia/fastererer/issues/28
113
147
  [#42]: https://github.com/ExtractableMedia/fastererer/pull/42
data/README.md CHANGED
@@ -53,20 +53,28 @@ Fastererer exits with status `1` when offenses are found, making it suitable for
53
53
 
54
54
  ## Example output
55
55
 
56
+ Each offense is reported on a single line, following the same shape as
57
+ RuboCop and its plugins (`path:line: SEVERITY: Department/RuleName: message.
58
+ (url)`), so the rule name and a link to documentation are always visible:
59
+
56
60
  ```text
57
- app/models/post.rb:57 Array#select.first is slower than Array#detect.
58
- app/models/post.rb:61 Array#select.first is slower than Array#detect.
61
+ app/models/post.rb:57: W: Performance/SelectFirstVsDetect: Array#select.first is slower than Array#detect. (https://github.com/fastruby/fast-ruby#enumerabledetect-vs-enumerableselectfirst-code)
62
+ app/models/post.rb:61: W: Performance/SelectFirstVsDetect: Array#select.first is slower than Array#detect. (https://github.com/fastruby/fast-ruby#enumerabledetect-vs-enumerableselectfirst-code)
59
63
 
60
- db/seeds/cities.rb:15 Hash#keys.each is slower than Hash#each_key.
61
- db/seeds/cities.rb:33 Hash#keys.each is slower than Hash#each_key.
64
+ db/seeds/cities.rb:15: W: Performance/KeysEachVsEachKey: Hash#keys.each is slower than Hash#each_key. N.B. Hash#each_key cannot be used if the hash is modified during the each block. (https://github.com/fastruby/fast-ruby#hasheach_key-instead-of-hashkeyseach-code)
62
65
 
63
- test/options_test.rb:84 Hash#merge! with one argument is slower than Hash#[].
66
+ test/options_test.rb:84: W: Performance/HashMergeBangVsHashBrackets: Hash#merge! with one argument is slower than Hash#[]. (https://github.com/fastruby/fast-ruby#hashmerge-vs-hash-code)
64
67
 
65
- test/module_test.rb:272 Don't rescue NoMethodError, rather check with respond_to?.
68
+ test/module_test.rb:272: W: Performance/RescueVsRespondTo: Don't rescue NoMethodError, rather check with respond_to?. (https://github.com/fastruby/fast-ruby#beginrescue-vs-respond_to-for-control-flow-code)
66
69
 
67
- spec/cache/mem_cache_store_spec.rb:161 Using tr is faster than gsub when replacing a single character in a string with another single character.
70
+ spec/cache/mem_cache_store_spec.rb:161: W: Performance/GsubVsTr: Using tr is faster than gsub when replacing a single character in a string with another single character. (https://github.com/fastruby/fast-ruby#stringgsub-vs-stringtr-code)
68
71
  ```
69
72
 
73
+ The rule name (e.g. `Performance/SelectFirstVsDetect`) is derived from the
74
+ underlying snake_case rule key (e.g. `select_first_vs_detect`), which is what
75
+ you reference in `.fastererer.yml` under `speedups:` to disable a rule.
76
+ Descriptions and documentation URLs live in `config/locales/en.yml`.
77
+
70
78
  ## Configuration
71
79
 
72
80
  Configuration lives in a `.fastererer.yml` file at the root of your project (or any ancestor
@@ -0,0 +1,60 @@
1
+ en:
2
+ fastererer:
3
+ rules:
4
+ rescue_vs_respond_to:
5
+ description: "Don't rescue NoMethodError, rather check with respond_to?"
6
+ url: "https://github.com/fastruby/fast-ruby#beginrescue-vs-respond_to-for-control-flow-code"
7
+ module_eval:
8
+ description: "Using module_eval is slower than define_method"
9
+ url: "https://github.com/fastruby/fast-ruby#define_method-vs-module_eval-for-defining-methods-code"
10
+ shuffle_first_vs_sample:
11
+ description: "Array#shuffle.first is slower than Array#sample"
12
+ url: "https://github.com/fastruby/fast-ruby#arrayshufflefirst-vs-arraysample-code"
13
+ for_loop_vs_each:
14
+ description: "For loop is slower than using each"
15
+ url: "https://github.com/fastruby/fast-ruby#enumerableeach-vs-for-loop-code"
16
+ each_with_index_vs_while:
17
+ description: "Using each_with_index is slower than while loop"
18
+ url: "https://github.com/fastruby/fast-ruby#enumerableeach_with_index-vs-while-loop-code"
19
+ map_flatten_vs_flat_map:
20
+ description: "Array#map.flatten(1) is slower than Array#flat_map"
21
+ url: "https://github.com/fastruby/fast-ruby#enumerablemaparrayflatten-vs-enumerableflat_map-code"
22
+ reverse_each_vs_reverse_each:
23
+ description: "Array#reverse.each is slower than Array#reverse_each"
24
+ url: "https://github.com/fastruby/fast-ruby#enumerablereverseeach-vs-enumerablereverse_each-code"
25
+ select_first_vs_detect:
26
+ description: "Array#select.first is slower than Array#detect"
27
+ url: "https://github.com/fastruby/fast-ruby#enumerabledetect-vs-enumerableselectfirst-code"
28
+ sort_vs_sort_by:
29
+ description: "Enumerable#sort is slower than Enumerable#sort_by"
30
+ url: "https://github.com/fastruby/fast-ruby#enumerablesort-vs-enumerablesort_by-code"
31
+ fetch_with_argument_vs_block:
32
+ description: "Hash#fetch with second argument is slower than Hash#fetch with block"
33
+ url: "https://github.com/fastruby/fast-ruby#hashfetch-with-argument-vs-hashfetch--block-code"
34
+ keys_each_vs_each_key:
35
+ description: "Hash#keys.each is slower than Hash#each_key. N.B. Hash#each_key cannot be used if the hash is modified during the each block"
36
+ url: "https://github.com/fastruby/fast-ruby#hasheach_key-instead-of-hashkeyseach-code"
37
+ hash_merge_bang_vs_hash_brackets:
38
+ description: "Hash#merge!/#update with one argument is slower than Hash#[]"
39
+ url: "https://github.com/fastruby/fast-ruby#hashmerge-vs-hash-code"
40
+ block_vs_symbol_to_proc:
41
+ description: "Calling argumentless methods within blocks is slower than using symbol to proc"
42
+ url: "https://github.com/fastruby/fast-ruby#block-vs-symbolto_proc-code"
43
+ proc_call_vs_yield:
44
+ description: "Calling blocks with call is slower than yielding"
45
+ url: "https://github.com/fastruby/fast-ruby#proccall-and-block-arguments-vs-yield-code"
46
+ gsub_vs_tr:
47
+ description: "Using tr is faster than gsub when replacing a single character in a string with another single character"
48
+ url: "https://github.com/fastruby/fast-ruby#stringgsub-vs-stringtr-code"
49
+ select_last_vs_reverse_detect:
50
+ description: "Array#select.last is slower than Array#reverse.detect"
51
+ url: "https://github.com/fastruby/fast-ruby#enumerableselectlast-vs-enumerablereversedetect-code"
52
+ getter_vs_attr_reader:
53
+ description: "Use attr_reader for reading ivars"
54
+ url: "https://github.com/fastruby/fast-ruby#attr_accessor-vs-getter-and-setter-code"
55
+ setter_vs_attr_writer:
56
+ description: "Use attr_writer for writing to ivars"
57
+ url: "https://github.com/fastruby/fast-ruby#attr_accessor-vs-getter-and-setter-code"
58
+ include_vs_cover_on_range:
59
+ description: "Use #cover? instead of #include? on ranges"
60
+ url: "https://github.com/fastruby/fast-ruby#cover-vs-include-code"
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
3
4
  require 'fastererer/method_definition'
4
5
  require 'fastererer/method_call'
5
6
  require 'fastererer/rescue_call'
7
+ require 'fastererer/offense'
6
8
  require 'fastererer/offense_collector'
7
9
  require 'fastererer/parser'
8
10
  require 'fastererer/scanners/method_call_scanner'
@@ -20,75 +22,47 @@ module Fastererer
20
22
  end
21
23
 
22
24
  def scan
23
- sexp_tree = Fastererer::Parser.parse(@file_content)
24
- traverse_sexp_tree(sexp_tree)
25
+ root_node = Fastererer::Parser.parse(@file_content)
26
+ root_node.accept(AnalyzerVisitor.new(errors))
25
27
  end
26
28
 
27
29
  def errors
28
30
  @errors ||= Fastererer::OffenseCollector.new
29
31
  end
32
+ end
30
33
 
31
- private
32
-
33
- def traverse_sexp_tree(sexp_tree)
34
- return unless sexp_tree.is_a?(Sexp)
35
-
36
- token = sexp_tree.first
37
- scan_by_token(token, sexp_tree)
38
-
39
- if %i[call iter].include?(token)
40
- descend_into_call(sexp_tree)
41
- else
42
- sexp_tree.each { |element| traverse_sexp_tree(element) }
43
- end
44
- end
45
-
46
- def descend_into_call(sexp_tree)
47
- method_call = MethodCall.new(sexp_tree)
48
- traverse_sexp_tree(method_call.receiver_element) if method_call.receiver_element
49
- traverse_sexp_tree(method_call.arguments_element)
50
- traverse_sexp_tree(method_call.block_body) if method_call.block?
34
+ class AnalyzerVisitor < Prism::Visitor
35
+ def initialize(offenses)
36
+ super()
37
+ @offenses = offenses
51
38
  end
52
39
 
53
- def scan_by_token(token, element)
54
- case token
55
- when :defn
56
- scan_method_definitions(element)
57
- when :call, :iter
58
- scan_method_calls(element)
59
- when :for
60
- scan_for_loop(element)
61
- when :resbody
62
- scan_rescue(element)
63
- end
40
+ def visit_call_node(node)
41
+ collect(MethodCallScanner.new(node))
42
+ super
64
43
  end
65
44
 
66
- def scan_method_definitions(element)
67
- method_definition_scanner = MethodDefinitionScanner.new(element)
68
-
69
- return unless method_definition_scanner.offense_detected?
70
-
71
- errors.push(method_definition_scanner.offense)
45
+ def visit_def_node(node)
46
+ collect(MethodDefinitionScanner.new(node))
47
+ super
72
48
  end
73
49
 
74
- def scan_method_calls(element)
75
- method_call_scanner = MethodCallScanner.new(element)
76
-
77
- return unless method_call_scanner.offense_detected?
78
-
79
- errors.push(method_call_scanner.offense)
50
+ def visit_for_node(node)
51
+ @offenses.push(Fastererer::Offense.new(:for_loop_vs_each, node.location.start_line))
52
+ super
80
53
  end
81
54
 
82
- def scan_for_loop(element)
83
- errors.push(Fastererer::Offense.new(:for_loop_vs_each, element.line))
55
+ def visit_rescue_node(node)
56
+ collect(RescueCallScanner.new(node))
57
+ super
84
58
  end
85
59
 
86
- def scan_rescue(element)
87
- rescue_call_scanner = RescueCallScanner.new(element)
88
-
89
- return unless rescue_call_scanner.offense_detected?
60
+ private
90
61
 
91
- errors.push(rescue_call_scanner.offense)
62
+ def collect(scanner)
63
+ @offenses.push(scanner.offense) if scanner.offense_detected?
92
64
  end
93
65
  end
66
+
67
+ private_constant :AnalyzerVisitor
94
68
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rule_catalog'
4
+ require_relative 'rule_name'
5
+
6
+ module Fastererer
7
+ class Explanation
8
+ def self.for(offense_name)
9
+ @instances ||= {}
10
+ @instances[offense_name.to_sym] ||= new(offense_name)
11
+ end
12
+
13
+ attr_reader :offense_name
14
+
15
+ def initialize(offense_name)
16
+ @offense_name = offense_name.to_sym
17
+ @row = RuleCatalog.fetch(@offense_name)
18
+ end
19
+
20
+ def description
21
+ row.fetch('description')
22
+ end
23
+
24
+ def url
25
+ row.fetch('url')
26
+ end
27
+
28
+ def rule_name
29
+ @rule_name ||= RuleName.from(offense_name)
30
+ end
31
+
32
+ def to_s
33
+ "#{rule_name}: #{description.delete_suffix('.')}. (#{url})"
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :row
39
+ end
40
+ end
@@ -5,6 +5,7 @@ require 'English'
5
5
 
6
6
  require_relative 'analyzer'
7
7
  require_relative 'config'
8
+ require_relative 'explanation'
8
9
  require_relative 'painter'
9
10
 
10
11
  module Fastererer
@@ -56,7 +57,7 @@ module Fastererer
56
57
  def scan_file(path)
57
58
  analyzer = Analyzer.new(path)
58
59
  analyzer.scan
59
- rescue RubyParser::SyntaxError, Racc::ParseError, Timeout::Error => e
60
+ rescue Fastererer::ParseError, SystemCallError, SystemStackError, EncodingError => e
60
61
  parse_error_paths.push(ErrorData.new(path, e.class, e.message).to_s)
61
62
  else
62
63
  if offenses_grouped_by_type(analyzer).any?
@@ -81,16 +82,22 @@ module Fastererer
81
82
  end
82
83
 
83
84
  def output(analyzer)
84
- offenses_grouped_by_type(analyzer).each do |error_group_name, error_occurences|
85
- error_occurences.map(&:line_number).each do |line|
85
+ offenses_grouped_by_type(analyzer).each_value do |error_occurrences|
86
+ explanation = error_occurrences.first.explanation
87
+
88
+ error_occurrences.map(&:line_number).each do |line|
86
89
  file_and_line = "#{analyzer.file_path}:#{line}"
87
- print "#{Painter.paint(file_and_line, :red)} #{Fastererer::Offense::EXPLANATIONS[error_group_name]}.\n"
90
+ print "#{Painter.paint(file_and_line, :red)}: #{severity}: #{explanation}\n"
88
91
  end
89
92
  end
90
93
 
91
94
  print "\n"
92
95
  end
93
96
 
97
+ def severity
98
+ @severity ||= Painter.paint('W', :magenta)
99
+ end
100
+
94
101
  def offenses_grouped_by_type(analyzer)
95
102
  analyzer.errors.group_by(&:name).delete_if do |offense_name, _|
96
103
  ignored_speedups.include?(offense_name)
@@ -100,9 +107,7 @@ module Fastererer
100
107
  def output_parse_errors
101
108
  return if parse_error_paths.none?
102
109
 
103
- puts 'Fastererer was unable to process some files because the'
104
- puts 'internal parser is not able to read some characters or'
105
- puts 'has timed out. Unprocessable files were:'
110
+ puts 'Fastererer was unable to process some files. Unprocessable files were:'
106
111
  puts '-----------------------------------------------------'
107
112
  puts parse_error_paths
108
113
  puts
@@ -1,95 +1,130 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+
3
5
  module Fastererer
4
6
  class MethodCall
5
- attr_reader :element, :receiver, :method_name, :arguments, :block_body, :block_argument_names
7
+ attr_reader :element
6
8
 
7
- alias name method_name
9
+ def self.build(node)
10
+ node.is_a?(Prism::LambdaNode) ? LambdaCall.new(node) : new(node)
11
+ end
8
12
 
9
- def initialize(element)
10
- @element = element
11
- set_call_element
12
- set_receiver
13
- set_method_name
14
- set_arguments
15
- set_block_presence
16
- set_block_body
17
- set_block_argument_names
13
+ def initialize(node)
14
+ @element = node
18
15
  end
19
16
 
20
- def block?
21
- @block_present || false
17
+ def receiver
18
+ @receiver ||= ReceiverFactory.build(element.receiver)
22
19
  end
23
20
 
24
- def receiver_element
25
- call_element[1]
21
+ def method_name
22
+ element.name
26
23
  end
27
24
 
28
- def arguments_element
29
- call_element.sexp_body(3) || []
25
+ def name
26
+ method_name
30
27
  end
31
28
 
32
- def lambda_literal?
33
- call_element.sexp_type == :lambda
29
+ def arguments
30
+ @arguments ||= argument_nodes.map { |argument| Argument.new(argument) }
34
31
  end
35
32
 
36
- private
33
+ def block_body
34
+ @block_body ||= block_statements
35
+ end
37
36
 
38
- attr_reader :call_element
37
+ def block_argument_names
38
+ @block_argument_names ||= positional_block_parameter_names
39
+ end
39
40
 
40
- # TODO: explanation
41
- def set_call_element
42
- @call_element = case element.sexp_type
43
- when :call
44
- @element
45
- when :iter
46
- @element[1]
47
- end
41
+ def block?
42
+ !block_node.nil?
48
43
  end
49
44
 
50
- def set_receiver
51
- @receiver = ReceiverFactory.new(receiver_element)
45
+ def lambda_literal?
46
+ false
52
47
  end
53
48
 
54
- def set_method_name
55
- @method_name = call_element[2]
49
+ # Provably a Hash: bare `Hash.new`/`Hash[...]` (any args — every `Hash[...]` yields a Hash),
50
+ # not qualified `Foo::Hash`. The is_a?(ConstantReadNode) check is load-bearing:
51
+ # ConstantPathNode#name is :Hash too, so the type (not the name) excludes qualified constants.
52
+ # unwrap_parentheses lets `(Hash).new` match, as the receiver factory normalizes its input.
53
+ def hash?
54
+ return false unless %i[new []].include?(method_name)
55
+
56
+ constant = ReceiverFactory.unwrap_parentheses(element.receiver)
57
+ constant.is_a?(Prism::ConstantReadNode) && constant.name == :Hash
56
58
  end
57
59
 
58
- def set_arguments
59
- @arguments = arguments_element.map { |argument| Argument.new(argument) }
60
+ private
61
+
62
+ def block_node
63
+ @block_node ||= element.block
60
64
  end
61
65
 
62
- def set_block_presence
63
- if element.sexp_type == :iter || (arguments.last && arguments.last.type == :block_pass)
64
- @block_present = true
65
- end
66
+ def argument_nodes
67
+ element.arguments&.arguments || []
66
68
  end
67
69
 
68
- def set_block_body
69
- @block_body = element[3] if block?
70
+ def block_statements
71
+ return unless block_node.is_a?(Prism::BlockNode)
72
+
73
+ body = block_node.body
74
+ body.body if body.is_a?(Prism::StatementsNode)
70
75
  end
71
76
 
72
- # TODO: write specs for lambdas and procs
73
- def set_block_argument_names
74
- @block_argument_names = if block? && element[2].is_a?(Sexp) # HACK: for lambdas
75
- element[2].drop(1).map { |argument| argument }
76
- end || []
77
+ # Only single positional params convert to &:sym, so a splat or keyword param must not count
78
+ # toward block_argument_names.one?
79
+ def positional_block_parameter_names
80
+ return [] unless block_node.is_a?(Prism::BlockNode)
81
+
82
+ params = block_node.parameters
83
+ return [] unless params.is_a?(Prism::BlockParametersNode) && params.parameters
84
+
85
+ positional = params.parameters.requireds + params.parameters.optionals
86
+ positional.map { |param| param.is_a?(Prism::MultiTargetNode) ? nil : param.name }
77
87
  end
78
88
  end
79
89
 
80
- # For now, used for determening if the
81
- # receiver is a reference or a method call.
82
- class ReceiverFactory
83
- def self.new(receiver_info)
84
- return unless receiver_info.is_a?(Sexp)
85
-
86
- case receiver_info.sexp_type
87
- when :lvar
88
- VariableReference.new(receiver_info)
89
- when :call, :iter
90
- MethodCall.new(receiver_info)
91
- when :array, :dot2, :dot3, :lit
92
- Primitive.new(receiver_info)
90
+ # A `-> {}` lambda literal. Checks ignore it, so its call attributes are inert
91
+ class LambdaCall < MethodCall
92
+ def receiver = nil
93
+ def method_name = :lambda
94
+ def arguments = []
95
+ def block_body = nil
96
+ def block_argument_names = []
97
+ def block? = true
98
+ def lambda_literal? = true
99
+ end
100
+
101
+ module ReceiverFactory
102
+ PRIMITIVE_NODE_TYPES = [Prism::ArrayNode, Prism::RangeNode, Prism::IntegerNode,
103
+ Prism::FloatNode, Prism::SymbolNode, Prism::StringNode,
104
+ Prism::HashNode].freeze
105
+
106
+ def self.build(node)
107
+ return unless node
108
+
109
+ node = unwrap_parentheses(node)
110
+ case node
111
+ # A ConstantPathNode's #name is the unqualified tail only (Foo::Bar => :Bar), which is all
112
+ # the symbol-to-proc check needs; revisit if a check ever needs the fully-qualified path.
113
+ when Prism::LocalVariableReadNode, Prism::ConstantReadNode,
114
+ Prism::ConstantPathNode then VariableReference.new(node)
115
+ when Prism::CallNode then MethodCall.build(node)
116
+ when *PRIMITIVE_NODE_TYPES then Primitive.new(node)
117
+ end
118
+ end
119
+
120
+ # Peels a single parenthesis level, so `((x))` stays wrapped — matches how callers normalize
121
+ def self.unwrap_parentheses(node)
122
+ if node.is_a?(Prism::ParenthesesNode) &&
123
+ node.body.is_a?(Prism::StatementsNode) &&
124
+ node.body.body.size == 1
125
+ node.body.body.first
126
+ else
127
+ node
93
128
  end
94
129
  end
95
130
  end
@@ -97,45 +132,70 @@ module Fastererer
97
132
  class VariableReference
98
133
  attr_reader :name
99
134
 
100
- def initialize(reference_info)
101
- @reference_info = reference_info
102
- @name = reference_info[1]
135
+ def initialize(node)
136
+ @name = node.name
137
+ end
138
+
139
+ # A bare local/constant reference can't be proven to hold a Hash
140
+ def hash?
141
+ false
103
142
  end
104
143
  end
105
144
 
106
145
  class Argument
107
146
  attr_reader :element
108
147
 
109
- def initialize(element)
110
- @element = element
111
- end
148
+ def initialize(node)
149
+ @element = node
150
+ end
151
+
152
+ TYPE_BY_NODE_CLASS = {
153
+ Prism::KeywordHashNode => :hash,
154
+ Prism::HashNode => :hash,
155
+ Prism::StringNode => :string,
156
+ Prism::IntegerNode => :integer,
157
+ Prism::SymbolNode => :symbol,
158
+ Prism::FloatNode => :float,
159
+ Prism::RegularExpressionNode => :regexp,
160
+ Prism::NilNode => :nil,
161
+ Prism::TrueNode => :boolean,
162
+ Prism::FalseNode => :boolean,
163
+ Prism::LocalVariableReadNode => :variable,
164
+ Prism::CallNode => :method_call
165
+ }.freeze
112
166
 
113
167
  def type
114
- @type ||= @element[0]
168
+ @type ||= TYPE_BY_NODE_CLASS[element.class] || :unknown
115
169
  end
116
170
 
117
171
  def value
118
- @value ||= @element[1]
172
+ return @value if defined?(@value)
173
+
174
+ @value = case element
175
+ when Prism::StringNode then element.unescaped
176
+ when Prism::IntegerNode, Prism::FloatNode then element.value
177
+ when Prism::SymbolNode then element.unescaped.to_sym
178
+ end
119
179
  end
120
180
  end
121
181
 
122
182
  class Primitive
123
183
  attr_reader :element
124
184
 
125
- def initialize(element)
126
- @element = element
127
- end
128
-
129
- def type
130
- @type ||= @element[0]
185
+ def initialize(node)
186
+ @element = node
131
187
  end
132
188
 
133
189
  def range?
134
- %i[dot2 dot3 lit].include?(type)
190
+ element.is_a?(Prism::RangeNode)
135
191
  end
136
192
 
137
193
  def array?
138
- type == :array
194
+ element.is_a?(Prism::ArrayNode)
195
+ end
196
+
197
+ def hash?
198
+ element.is_a?(Prism::HashNode)
139
199
  end
140
200
  end
141
201
  end