hone 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.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/.standard.yml +8 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +201 -0
  6. data/Rakefile +10 -0
  7. data/examples/.hone/harness.rb +41 -0
  8. data/examples/README.md +22 -0
  9. data/examples/allocation_patterns.rb +66 -0
  10. data/examples/cpu_patterns.rb +50 -0
  11. data/examples/jit_patterns.rb +69 -0
  12. data/exe/hone +7 -0
  13. data/lib/hone/adapters/base.rb +35 -0
  14. data/lib/hone/adapters/fasterer.rb +38 -0
  15. data/lib/hone/adapters/rubocop_performance.rb +85 -0
  16. data/lib/hone/analyzer.rb +258 -0
  17. data/lib/hone/cli.rb +247 -0
  18. data/lib/hone/config.rb +93 -0
  19. data/lib/hone/correlator.rb +250 -0
  20. data/lib/hone/exit_codes.rb +10 -0
  21. data/lib/hone/finding.rb +64 -0
  22. data/lib/hone/finding_filter.rb +57 -0
  23. data/lib/hone/formatters/base.rb +25 -0
  24. data/lib/hone/formatters/filterable.rb +31 -0
  25. data/lib/hone/formatters/github.rb +71 -0
  26. data/lib/hone/formatters/json.rb +75 -0
  27. data/lib/hone/formatters/junit.rb +154 -0
  28. data/lib/hone/formatters/sarif.rb +179 -0
  29. data/lib/hone/formatters/tsv.rb +49 -0
  30. data/lib/hone/harness.rb +57 -0
  31. data/lib/hone/harness_generator.rb +128 -0
  32. data/lib/hone/harness_runner.rb +172 -0
  33. data/lib/hone/method_map.rb +140 -0
  34. data/lib/hone/patterns/README.md +174 -0
  35. data/lib/hone/patterns/array_compact.rb +105 -0
  36. data/lib/hone/patterns/array_include_set.rb +34 -0
  37. data/lib/hone/patterns/base.rb +90 -0
  38. data/lib/hone/patterns/block_to_proc.rb +109 -0
  39. data/lib/hone/patterns/bsearch_vs_find.rb +80 -0
  40. data/lib/hone/patterns/chars_map_ord.rb +42 -0
  41. data/lib/hone/patterns/chars_to_variable.rb +136 -0
  42. data/lib/hone/patterns/chars_to_variable_tainted.rb +136 -0
  43. data/lib/hone/patterns/constant_regexp.rb +74 -0
  44. data/lib/hone/patterns/count_vs_size.rb +35 -0
  45. data/lib/hone/patterns/divmod.rb +92 -0
  46. data/lib/hone/patterns/dynamic_ivar.rb +44 -0
  47. data/lib/hone/patterns/dynamic_ivar_get.rb +33 -0
  48. data/lib/hone/patterns/each_with_index.rb +116 -0
  49. data/lib/hone/patterns/each_with_object.rb +63 -0
  50. data/lib/hone/patterns/flatten_once.rb +28 -0
  51. data/lib/hone/patterns/gsub_to_tr.rb +48 -0
  52. data/lib/hone/patterns/hash_each_key.rb +41 -0
  53. data/lib/hone/patterns/hash_each_value.rb +31 -0
  54. data/lib/hone/patterns/hash_keys_include.rb +30 -0
  55. data/lib/hone/patterns/hash_merge_bang.rb +33 -0
  56. data/lib/hone/patterns/hash_values_include.rb +31 -0
  57. data/lib/hone/patterns/inject_sum.rb +48 -0
  58. data/lib/hone/patterns/kernel_loop.rb +27 -0
  59. data/lib/hone/patterns/lazy_ivar.rb +39 -0
  60. data/lib/hone/patterns/map_compact.rb +32 -0
  61. data/lib/hone/patterns/map_flatten.rb +31 -0
  62. data/lib/hone/patterns/map_select_chain.rb +32 -0
  63. data/lib/hone/patterns/parallel_assignment.rb +127 -0
  64. data/lib/hone/patterns/positive_predicate.rb +27 -0
  65. data/lib/hone/patterns/range_include.rb +34 -0
  66. data/lib/hone/patterns/redundant_string_chars.rb +82 -0
  67. data/lib/hone/patterns/regexp_match.rb +126 -0
  68. data/lib/hone/patterns/reverse_each.rb +30 -0
  69. data/lib/hone/patterns/reverse_first.rb +40 -0
  70. data/lib/hone/patterns/select_count.rb +32 -0
  71. data/lib/hone/patterns/select_first.rb +31 -0
  72. data/lib/hone/patterns/select_map.rb +32 -0
  73. data/lib/hone/patterns/shuffle_first.rb +30 -0
  74. data/lib/hone/patterns/slice_with_length.rb +48 -0
  75. data/lib/hone/patterns/sort_by_first.rb +31 -0
  76. data/lib/hone/patterns/sort_by_last.rb +31 -0
  77. data/lib/hone/patterns/sort_first.rb +52 -0
  78. data/lib/hone/patterns/sort_last.rb +30 -0
  79. data/lib/hone/patterns/sort_reverse.rb +53 -0
  80. data/lib/hone/patterns/string_casecmp.rb +54 -0
  81. data/lib/hone/patterns/string_chars_each.rb +56 -0
  82. data/lib/hone/patterns/string_concat_in_loop.rb +116 -0
  83. data/lib/hone/patterns/string_delete_prefix.rb +53 -0
  84. data/lib/hone/patterns/string_delete_suffix.rb +53 -0
  85. data/lib/hone/patterns/string_empty.rb +64 -0
  86. data/lib/hone/patterns/string_end_with.rb +81 -0
  87. data/lib/hone/patterns/string_shovel.rb +75 -0
  88. data/lib/hone/patterns/string_start_with.rb +80 -0
  89. data/lib/hone/patterns/taint_tracking_base.rb +230 -0
  90. data/lib/hone/patterns/times_map.rb +38 -0
  91. data/lib/hone/patterns/uniq_by.rb +32 -0
  92. data/lib/hone/patterns/yield_vs_block.rb +72 -0
  93. data/lib/hone/profilers/base.rb +162 -0
  94. data/lib/hone/profilers/factory.rb +31 -0
  95. data/lib/hone/profilers/memory_profiler.rb +213 -0
  96. data/lib/hone/profilers/stackprof.rb +99 -0
  97. data/lib/hone/profilers/vernier.rb +147 -0
  98. data/lib/hone/reporter.rb +371 -0
  99. data/lib/hone/scanner.rb +75 -0
  100. data/lib/hone/suggestion_generator.rb +23 -0
  101. data/lib/hone/version.rb +5 -0
  102. data/lib/hone.rb +108 -0
  103. data/logo.png +0 -0
  104. data/sig/hone.rbs +4 -0
  105. metadata +176 -0
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: array.map { ... }.select { ... } -> array.filter_map { ... }
6
+ #
7
+ # Chaining .map and .select creates an intermediate array.
8
+ # Using .filter_map combines both operations in one pass.
9
+ #
10
+ # From sqids-ruby commit aa4e253
11
+ class MapSelectChain < Base
12
+ self.pattern_id = :map_select_chain
13
+ self.optimization_type = :allocation
14
+
15
+ def visit_call_node(node)
16
+ super
17
+
18
+ # Look for: .select { } where receiver is .map (with block or symbol arg)
19
+ return unless node.name == :select && block_attached?(node)
20
+
21
+ receiver = node.receiver
22
+ return unless receiver.is_a?(Prism::CallNode) && receiver.name == :map && block_attached?(receiver)
23
+
24
+ add_finding(
25
+ node,
26
+ message: "Use `.filter_map { }` instead of `.map { }.select { }` to avoid intermediate array",
27
+ speedup: "Fewer allocations"
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: Sequential array index assignments -> parallel assignment
6
+ #
7
+ # When assigning multiple variables from sequential array indices (0, 1, 2...),
8
+ # Ruby's parallel assignment syntax is more idiomatic and can be slightly faster
9
+ # as it avoids multiple array access operations.
10
+ #
11
+ # Example:
12
+ # # Before
13
+ # a = arr[0]
14
+ # b = arr[1]
15
+ # c = arr[2]
16
+ #
17
+ # # After
18
+ # a, b, c = arr
19
+ #
20
+ # Impact: Minor performance improvement, but more idiomatic Ruby
21
+ class ParallelAssignment < Base
22
+ self.pattern_id = :parallel_assignment
23
+ self.optimization_type = :cpu
24
+
25
+ def visit_statements_node(node)
26
+ check_sequential_assignments(node.body)
27
+ super
28
+ end
29
+
30
+ def visit_begin_node(node)
31
+ check_sequential_assignments(node.statements&.body || [])
32
+ super
33
+ end
34
+
35
+ private
36
+
37
+ def check_sequential_assignments(statements)
38
+ return if statements.nil? || statements.size < 2
39
+
40
+ i = 0
41
+ while i < statements.size
42
+ sequence = find_assignment_sequence(statements, i)
43
+
44
+ if sequence.size >= 2
45
+ report_sequence(sequence)
46
+ i += sequence.size
47
+ else
48
+ i += 1
49
+ end
50
+ end
51
+ end
52
+
53
+ def find_assignment_sequence(statements, start_index)
54
+ sequence = []
55
+ array_name = nil
56
+ expected_index = 0
57
+
58
+ (start_index...statements.size).each do |i|
59
+ stmt = statements[i]
60
+ break unless stmt.is_a?(Prism::LocalVariableWriteNode)
61
+
62
+ array_access = extract_array_access(stmt.value)
63
+ break unless array_access
64
+
65
+ name, index = array_access
66
+
67
+ if sequence.empty?
68
+ array_name = name
69
+ expected_index = index
70
+ end
71
+
72
+ break unless name == array_name && index == expected_index
73
+
74
+ sequence << stmt
75
+ expected_index += 1
76
+ end
77
+
78
+ sequence
79
+ end
80
+
81
+ def extract_array_access(node)
82
+ return nil unless node.is_a?(Prism::CallNode)
83
+ return nil unless node.name == :[]
84
+
85
+ args = node.arguments&.arguments
86
+ return nil unless args&.size == 1
87
+
88
+ index_node = args.first
89
+ return nil unless index_node.is_a?(Prism::IntegerNode)
90
+
91
+ index = index_node.value
92
+ return nil if index.negative?
93
+
94
+ receiver = node.receiver
95
+ array_name = extract_variable_name(receiver)
96
+ return nil unless array_name
97
+
98
+ [array_name, index]
99
+ end
100
+
101
+ def extract_variable_name(node)
102
+ case node
103
+ when Prism::LocalVariableReadNode
104
+ node.name
105
+ when Prism::InstanceVariableReadNode
106
+ node.name
107
+ when Prism::ClassVariableReadNode
108
+ node.name
109
+ when Prism::GlobalVariableReadNode
110
+ node.name
111
+ end
112
+ end
113
+
114
+ def report_sequence(sequence)
115
+ var_names = sequence.map(&:name).join(", ")
116
+ array_access = sequence.first.value
117
+ array_name = extract_variable_name(array_access.receiver)
118
+
119
+ add_finding(
120
+ sequence.first,
121
+ message: "Use parallel assignment `#{var_names} = #{array_name}` instead of sequential array index assignments",
122
+ speedup: "Minor, but more idiomatic"
123
+ )
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: (expr).positive? -> (expr) > 0
6
+ #
7
+ # In hot paths, the method call overhead of .positive? can add up.
8
+ # Direct comparison is faster.
9
+ #
10
+ # From sqids-ruby commit 8a74142
11
+ class PositivePredicate < Base
12
+ self.pattern_id = :positive_predicate
13
+ self.optimization_type = :cpu
14
+
15
+ def visit_call_node(node)
16
+ super
17
+ return unless node.name == :positive? && node.arguments.nil?
18
+
19
+ add_finding(
20
+ node,
21
+ message: "Use `> 0` instead of `.positive?` in hot paths to avoid method call overhead",
22
+ speedup: "Minor, but adds up in tight loops"
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: range.include?(x) -> range.cover?(x)
6
+ #
7
+ # include? iterates the range to check membership.
8
+ # cover? just checks if value is between bounds (O(1)).
9
+ #
10
+ # Note: Semantics differ for non-numeric ranges. cover? checks bounds only,
11
+ # include? checks actual membership. For numeric ranges they're equivalent.
12
+ class RangeInclude < Base
13
+ self.pattern_id = :range_include
14
+ self.optimization_type = :cpu
15
+
16
+ def visit_call_node(node)
17
+ super
18
+
19
+ # Look for: .include?(x) on a range literal
20
+ return unless node.name == :include?
21
+ return unless node.arguments&.arguments&.size == 1
22
+
23
+ receiver = node.receiver
24
+ return unless receiver.is_a?(Prism::RangeNode)
25
+
26
+ add_finding(
27
+ node,
28
+ message: "Use `.cover?` instead of `.include?` on ranges for O(1) bounds check",
29
+ speedup: "O(n) iteration to O(1) comparison (for numeric ranges)"
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Detects `str.chars[i]` which should be `str[i]`
6
+ #
7
+ # str.chars[n] creates an array of single-character strings, then indexes it.
8
+ # Direct string indexing is much faster.
9
+ #
10
+ # @example Bad
11
+ # str.chars[0]
12
+ # str.chars.first
13
+ # str.chars.last
14
+ #
15
+ # @example Good
16
+ # str[0]
17
+ # str[0]
18
+ # str[-1]
19
+ #
20
+ class RedundantStringChars < Base
21
+ self.pattern_id = :redundant_string_chars
22
+ self.optimization_type = :allocation
23
+
24
+ def visit_call_node(node)
25
+ if chained_chars_index?(node)
26
+ replacement = suggest_replacement(node)
27
+ add_finding(
28
+ node,
29
+ message: "Use `#{replacement}` instead of `chars[...]` to avoid array allocation",
30
+ speedup: "Significant - avoids creating array of all characters"
31
+ )
32
+ elsif chained_chars_first_last?(node)
33
+ replacement = suggest_first_last_replacement(node)
34
+ add_finding(
35
+ node,
36
+ message: "Use `#{replacement}` instead of `chars.#{node.name}` to avoid array allocation",
37
+ speedup: "Significant - avoids creating array of all characters"
38
+ )
39
+ end
40
+
41
+ super
42
+ end
43
+
44
+ private
45
+
46
+ def chained_chars_index?(node)
47
+ return false unless node.name == :[]
48
+
49
+ receiver = node.receiver
50
+ return false unless receiver.is_a?(Prism::CallNode)
51
+ return false unless receiver.name == :chars
52
+
53
+ true
54
+ end
55
+
56
+ def chained_chars_first_last?(node)
57
+ return false unless %i[first last].include?(node.name)
58
+
59
+ receiver = node.receiver
60
+ return false unless receiver.is_a?(Prism::CallNode)
61
+ return false unless receiver.name == :chars
62
+
63
+ true
64
+ end
65
+
66
+ def suggest_replacement(node)
67
+ receiver = node.receiver.receiver
68
+ receiver_src = receiver ? receiver.slice : "str"
69
+ args = node.arguments&.arguments&.first
70
+ index = args ? args.slice : "i"
71
+ "#{receiver_src}[#{index}]"
72
+ end
73
+
74
+ def suggest_first_last_replacement(node)
75
+ receiver = node.receiver.receiver
76
+ receiver_src = receiver ? receiver.slice : "str"
77
+ index = (node.name == :first) ? "0" : "-1"
78
+ "#{receiver_src}[#{index}]"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: str =~ /pattern/ or /pattern/.match(str) for boolean -> str.match?(/pattern/)
6
+ #
7
+ # When the result of =~ or .match is only used for truthiness (in if/unless/ternary),
8
+ # using .match? avoids creating a MatchData object.
9
+ #
10
+ # Example:
11
+ # # Bad - creates MatchData object
12
+ # if str =~ /pattern/
13
+ # if /pattern/.match(str)
14
+ #
15
+ # # Good - returns boolean without allocation
16
+ # if str.match?(/pattern/)
17
+ class RegexpMatch < Base
18
+ self.pattern_id = :regexp_match
19
+ self.optimization_type = :allocation
20
+
21
+ def initialize(file_path)
22
+ super
23
+ @in_boolean_context = false
24
+ end
25
+
26
+ # Track boolean contexts: if conditions
27
+ def visit_if_node(node)
28
+ visit_in_boolean_context(node.predicate)
29
+ node.statements&.accept(self)
30
+ node.subsequent&.accept(self)
31
+ end
32
+
33
+ # Track boolean contexts: unless conditions
34
+ def visit_unless_node(node)
35
+ visit_in_boolean_context(node.predicate)
36
+ node.statements&.accept(self)
37
+ node.else_clause&.accept(self)
38
+ end
39
+
40
+ # Track boolean contexts: while conditions
41
+ def visit_while_node(node)
42
+ visit_in_boolean_context(node.predicate)
43
+ node.statements&.accept(self)
44
+ end
45
+
46
+ # Track boolean contexts: until conditions
47
+ def visit_until_node(node)
48
+ visit_in_boolean_context(node.predicate)
49
+ node.statements&.accept(self)
50
+ end
51
+
52
+ # Track boolean contexts: ternary operator condition
53
+ def visit_ternary_node(node)
54
+ visit_in_boolean_context(node.predicate)
55
+ node.true_expression&.accept(self)
56
+ node.false_expression&.accept(self)
57
+ end
58
+
59
+ # Track boolean contexts: && and || operators
60
+ def visit_and_node(node)
61
+ visit_in_boolean_context(node.left)
62
+ visit_in_boolean_context(node.right)
63
+ end
64
+
65
+ def visit_or_node(node)
66
+ visit_in_boolean_context(node.left)
67
+ visit_in_boolean_context(node.right)
68
+ end
69
+
70
+ def visit_call_node(node)
71
+ super
72
+
73
+ if @in_boolean_context
74
+ check_regexp_match(node)
75
+ end
76
+ end
77
+
78
+ # Detect =~ operator (str =~ /pattern/ or /pattern/ =~ str)
79
+ def visit_match_last_line_node(node)
80
+ return unless @in_boolean_context
81
+
82
+ add_regexp_finding(node)
83
+ end
84
+
85
+ def visit_match_write_node(node)
86
+ return unless @in_boolean_context
87
+
88
+ add_regexp_finding(node)
89
+ end
90
+
91
+ private
92
+
93
+ def visit_in_boolean_context(node)
94
+ return unless node
95
+
96
+ with_context(:@in_boolean_context, true) do
97
+ node.accept(self)
98
+ end
99
+ end
100
+
101
+ def check_regexp_match(node)
102
+ # Check for =~ operator
103
+ if node.name == :=~
104
+ add_regexp_finding(node)
105
+ return
106
+ end
107
+
108
+ # Check for .match(str) without block
109
+ if node.name == :match && !block_attached?(node)
110
+ # Verify it has arguments (not just calling match on something)
111
+ return unless node.arguments&.arguments&.any?
112
+
113
+ add_regexp_finding(node)
114
+ end
115
+ end
116
+
117
+ def add_regexp_finding(node)
118
+ add_finding(
119
+ node,
120
+ message: "Use `.match?` instead of `=~` or `.match` when only checking for a match",
121
+ speedup: "Avoids creating MatchData object"
122
+ )
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: array.reverse.each { } -> array.reverse_each { }
6
+ #
7
+ # Calling .reverse.each creates an intermediate reversed array.
8
+ # Using .reverse_each iterates in reverse order without allocation.
9
+ class ReverseEach < Base
10
+ self.pattern_id = :reverse_each
11
+ self.optimization_type = :allocation
12
+
13
+ def visit_call_node(node)
14
+ super
15
+
16
+ # Look for: .each { } where receiver is .reverse
17
+ return unless node.name == :each && block_attached?(node)
18
+
19
+ receiver = node.receiver
20
+ return unless receiver.is_a?(Prism::CallNode) && receiver.name == :reverse
21
+
22
+ add_finding(
23
+ node,
24
+ message: "Use `.reverse_each { }` instead of `.reverse.each { }` to avoid intermediate array",
25
+ speedup: "Avoids intermediate array allocation"
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: array.reverse.first -> array.last
6
+ # array.reverse.first(n) -> array.last(n).reverse
7
+ #
8
+ # reverse.first reverses the entire array then takes the first element(s).
9
+ # Using last directly accesses the end of the array without creating an
10
+ # intermediate reversed array.
11
+ class ReverseFirst < Base
12
+ self.pattern_id = :reverse_first
13
+ self.optimization_type = :allocation
14
+
15
+ def visit_call_node(node)
16
+ super
17
+
18
+ # Look for: .first or .first(n) where receiver is .reverse
19
+ return unless node.name == :first
20
+
21
+ receiver = node.receiver
22
+ return unless receiver.is_a?(Prism::CallNode) && receiver.name == :reverse
23
+
24
+ if node.arguments.nil?
25
+ add_finding(
26
+ node,
27
+ message: "Use `.last` instead of `.reverse.first` to avoid reversing entire array",
28
+ speedup: "Avoids reversing entire array"
29
+ )
30
+ else
31
+ add_finding(
32
+ node,
33
+ message: "Use `.last(n).reverse` instead of `.reverse.first(n)` to avoid reversing entire array",
34
+ speedup: "Avoids reversing entire array"
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: array.select { }.count -> array.count { }
6
+ #
7
+ # select { }.count creates a temporary array of matches then counts it.
8
+ # count { } counts matches directly without intermediate allocation.
9
+ class SelectCount < Base
10
+ self.pattern_id = :select_count
11
+ self.optimization_type = :allocation
12
+
13
+ def visit_call_node(node)
14
+ super
15
+
16
+ # Look for: .count/.size/.length (no args) where receiver is .select with block
17
+ return unless %i[count size length].include?(node.name)
18
+ return unless node.arguments.nil?
19
+
20
+ receiver = node.receiver
21
+ return unless receiver.is_a?(Prism::CallNode)
22
+ return unless receiver.name == :select && block_attached?(receiver)
23
+
24
+ add_finding(
25
+ node,
26
+ message: "Use `.count { }` instead of `.select { }.#{node.name}` to avoid intermediate array",
27
+ speedup: "Counts directly without allocating intermediate array"
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: array.select { }.first -> array.detect { } / array.find { }
6
+ #
7
+ # select.first iterates the entire array building a new array, then takes first.
8
+ # detect/find stops iteration as soon as a match is found.
9
+ class SelectFirst < Base
10
+ self.pattern_id = :select_first
11
+ self.optimization_type = :allocation
12
+
13
+ def visit_call_node(node)
14
+ super
15
+
16
+ # Look for: .first where receiver is .select with a block
17
+ return unless node.name == :first && node.arguments.nil?
18
+
19
+ receiver = node.receiver
20
+ return unless receiver.is_a?(Prism::CallNode)
21
+ return unless receiver.name == :select && block_attached?(receiver)
22
+
23
+ add_finding(
24
+ node,
25
+ message: "Use `.detect { }` or `.find { }` instead of `.select { }.first` to stop at first match",
26
+ speedup: "Avoids iterating entire array and intermediate array allocation"
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: array.select { }.map { } -> array.filter_map { }
6
+ #
7
+ # select.map creates an intermediate array from select, then maps over it.
8
+ # filter_map combines both operations in a single pass, avoiding the
9
+ # intermediate allocation.
10
+ class SelectMap < Base
11
+ self.pattern_id = :select_map
12
+ self.optimization_type = :allocation
13
+
14
+ def visit_call_node(node)
15
+ super
16
+
17
+ # Look for: .map { } where receiver is .select { }
18
+ return unless node.name == :map && block_attached?(node)
19
+
20
+ receiver = node.receiver
21
+ return unless receiver.is_a?(Prism::CallNode)
22
+ return unless receiver.name == :select && block_attached?(receiver)
23
+
24
+ add_finding(
25
+ node,
26
+ message: "Use `.filter_map { }` instead of `.select { }.map { }` to avoid intermediate array",
27
+ speedup: "Avoids intermediate array"
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: array.shuffle.first -> array.sample
6
+ #
7
+ # shuffle.first shuffles the entire array then takes the first element.
8
+ # sample directly selects a random element without creating intermediate array.
9
+ class ShuffleFirst < Base
10
+ self.pattern_id = :shuffle_first
11
+ self.optimization_type = :allocation
12
+
13
+ def visit_call_node(node)
14
+ super
15
+
16
+ # Look for: .first where receiver is .shuffle
17
+ return unless node.name == :first && node.arguments.nil?
18
+
19
+ receiver = node.receiver
20
+ return unless receiver.is_a?(Prism::CallNode) && receiver.name == :shuffle
21
+
22
+ add_finding(
23
+ node,
24
+ message: "Use `.sample` instead of `.shuffle.first` to avoid shuffling entire array",
25
+ speedup: "Avoids O(n) shuffle and intermediate array allocation"
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Patterns
5
+ # Pattern: str[n, str.length] -> str[n..]
6
+ #
7
+ # Endless range syntax avoids the length calculation.
8
+ #
9
+ # From sqids-ruby commit 9413b68
10
+ class SliceWithLength < Base
11
+ self.pattern_id = :slice_with_length
12
+ self.optimization_type = :cpu
13
+
14
+ def visit_call_node(node)
15
+ super
16
+
17
+ # Look for: receiver[offset, receiver.length] pattern
18
+ return unless node.name == :[] && node.arguments
19
+
20
+ args = node.arguments.arguments
21
+ return unless args&.length == 2
22
+
23
+ first_arg = args[0]
24
+ second_arg = args[1]
25
+
26
+ # Check if second arg is receiver.length or receiver.size
27
+ return unless second_arg.is_a?(Prism::CallNode)
28
+ return unless %i[length size].include?(second_arg.name)
29
+ return unless nodes_match?(node.receiver, second_arg.receiver)
30
+
31
+ add_finding(
32
+ node,
33
+ message: "Use endless range `[#{first_arg.location.slice}..]` instead of `[#{first_arg.location.slice}, #{second_arg.location.slice}]`",
34
+ speedup: "Minor, avoids length calculation"
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ # Simple check if two nodes represent the same variable/expression
41
+ def nodes_match?(node1, node2)
42
+ return false unless node1.instance_of?(node2.class)
43
+
44
+ node1.location.slice == node2.location.slice
45
+ end
46
+ end
47
+ end
48
+ end