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.
- checksums.yaml +7 -0
- data/.standard.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/Rakefile +10 -0
- data/examples/.hone/harness.rb +41 -0
- data/examples/README.md +22 -0
- data/examples/allocation_patterns.rb +66 -0
- data/examples/cpu_patterns.rb +50 -0
- data/examples/jit_patterns.rb +69 -0
- data/exe/hone +7 -0
- data/lib/hone/adapters/base.rb +35 -0
- data/lib/hone/adapters/fasterer.rb +38 -0
- data/lib/hone/adapters/rubocop_performance.rb +85 -0
- data/lib/hone/analyzer.rb +258 -0
- data/lib/hone/cli.rb +247 -0
- data/lib/hone/config.rb +93 -0
- data/lib/hone/correlator.rb +250 -0
- data/lib/hone/exit_codes.rb +10 -0
- data/lib/hone/finding.rb +64 -0
- data/lib/hone/finding_filter.rb +57 -0
- data/lib/hone/formatters/base.rb +25 -0
- data/lib/hone/formatters/filterable.rb +31 -0
- data/lib/hone/formatters/github.rb +71 -0
- data/lib/hone/formatters/json.rb +75 -0
- data/lib/hone/formatters/junit.rb +154 -0
- data/lib/hone/formatters/sarif.rb +179 -0
- data/lib/hone/formatters/tsv.rb +49 -0
- data/lib/hone/harness.rb +57 -0
- data/lib/hone/harness_generator.rb +128 -0
- data/lib/hone/harness_runner.rb +172 -0
- data/lib/hone/method_map.rb +140 -0
- data/lib/hone/patterns/README.md +174 -0
- data/lib/hone/patterns/array_compact.rb +105 -0
- data/lib/hone/patterns/array_include_set.rb +34 -0
- data/lib/hone/patterns/base.rb +90 -0
- data/lib/hone/patterns/block_to_proc.rb +109 -0
- data/lib/hone/patterns/bsearch_vs_find.rb +80 -0
- data/lib/hone/patterns/chars_map_ord.rb +42 -0
- data/lib/hone/patterns/chars_to_variable.rb +136 -0
- data/lib/hone/patterns/chars_to_variable_tainted.rb +136 -0
- data/lib/hone/patterns/constant_regexp.rb +74 -0
- data/lib/hone/patterns/count_vs_size.rb +35 -0
- data/lib/hone/patterns/divmod.rb +92 -0
- data/lib/hone/patterns/dynamic_ivar.rb +44 -0
- data/lib/hone/patterns/dynamic_ivar_get.rb +33 -0
- data/lib/hone/patterns/each_with_index.rb +116 -0
- data/lib/hone/patterns/each_with_object.rb +63 -0
- data/lib/hone/patterns/flatten_once.rb +28 -0
- data/lib/hone/patterns/gsub_to_tr.rb +48 -0
- data/lib/hone/patterns/hash_each_key.rb +41 -0
- data/lib/hone/patterns/hash_each_value.rb +31 -0
- data/lib/hone/patterns/hash_keys_include.rb +30 -0
- data/lib/hone/patterns/hash_merge_bang.rb +33 -0
- data/lib/hone/patterns/hash_values_include.rb +31 -0
- data/lib/hone/patterns/inject_sum.rb +48 -0
- data/lib/hone/patterns/kernel_loop.rb +27 -0
- data/lib/hone/patterns/lazy_ivar.rb +39 -0
- data/lib/hone/patterns/map_compact.rb +32 -0
- data/lib/hone/patterns/map_flatten.rb +31 -0
- data/lib/hone/patterns/map_select_chain.rb +32 -0
- data/lib/hone/patterns/parallel_assignment.rb +127 -0
- data/lib/hone/patterns/positive_predicate.rb +27 -0
- data/lib/hone/patterns/range_include.rb +34 -0
- data/lib/hone/patterns/redundant_string_chars.rb +82 -0
- data/lib/hone/patterns/regexp_match.rb +126 -0
- data/lib/hone/patterns/reverse_each.rb +30 -0
- data/lib/hone/patterns/reverse_first.rb +40 -0
- data/lib/hone/patterns/select_count.rb +32 -0
- data/lib/hone/patterns/select_first.rb +31 -0
- data/lib/hone/patterns/select_map.rb +32 -0
- data/lib/hone/patterns/shuffle_first.rb +30 -0
- data/lib/hone/patterns/slice_with_length.rb +48 -0
- data/lib/hone/patterns/sort_by_first.rb +31 -0
- data/lib/hone/patterns/sort_by_last.rb +31 -0
- data/lib/hone/patterns/sort_first.rb +52 -0
- data/lib/hone/patterns/sort_last.rb +30 -0
- data/lib/hone/patterns/sort_reverse.rb +53 -0
- data/lib/hone/patterns/string_casecmp.rb +54 -0
- data/lib/hone/patterns/string_chars_each.rb +56 -0
- data/lib/hone/patterns/string_concat_in_loop.rb +116 -0
- data/lib/hone/patterns/string_delete_prefix.rb +53 -0
- data/lib/hone/patterns/string_delete_suffix.rb +53 -0
- data/lib/hone/patterns/string_empty.rb +64 -0
- data/lib/hone/patterns/string_end_with.rb +81 -0
- data/lib/hone/patterns/string_shovel.rb +75 -0
- data/lib/hone/patterns/string_start_with.rb +80 -0
- data/lib/hone/patterns/taint_tracking_base.rb +230 -0
- data/lib/hone/patterns/times_map.rb +38 -0
- data/lib/hone/patterns/uniq_by.rb +32 -0
- data/lib/hone/patterns/yield_vs_block.rb +72 -0
- data/lib/hone/profilers/base.rb +162 -0
- data/lib/hone/profilers/factory.rb +31 -0
- data/lib/hone/profilers/memory_profiler.rb +213 -0
- data/lib/hone/profilers/stackprof.rb +99 -0
- data/lib/hone/profilers/vernier.rb +147 -0
- data/lib/hone/reporter.rb +371 -0
- data/lib/hone/scanner.rb +75 -0
- data/lib/hone/suggestion_generator.rb +23 -0
- data/lib/hone/version.rb +5 -0
- data/lib/hone.rb +108 -0
- data/logo.png +0 -0
- data/sig/hone.rbs +4 -0
- metadata +176 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: array.sort_by { }.first -> array.min_by { }
|
|
6
|
+
#
|
|
7
|
+
# sort_by { block }.first sorts the entire array then takes the first element.
|
|
8
|
+
# min_by { block } directly finds the minimum without creating intermediate array.
|
|
9
|
+
class SortByFirst < Base
|
|
10
|
+
self.pattern_id = :sort_by_first
|
|
11
|
+
self.optimization_type = :allocation
|
|
12
|
+
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
super
|
|
15
|
+
|
|
16
|
+
# Look for: .first where receiver is .sort_by { block }
|
|
17
|
+
return unless node.name == :first && node.arguments.nil?
|
|
18
|
+
|
|
19
|
+
receiver = node.receiver
|
|
20
|
+
return unless receiver.is_a?(Prism::CallNode) && receiver.name == :sort_by
|
|
21
|
+
return unless block_attached?(receiver)
|
|
22
|
+
|
|
23
|
+
add_finding(
|
|
24
|
+
node,
|
|
25
|
+
message: "Use `.min_by { }` instead of `.sort_by { }.first` to avoid sorting entire array",
|
|
26
|
+
speedup: "Avoids sorting entire array"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: array.sort_by { }.last -> array.max_by { }
|
|
6
|
+
#
|
|
7
|
+
# sort_by { block }.last sorts the entire array then takes the last element.
|
|
8
|
+
# max_by { block } directly finds the maximum without creating intermediate array.
|
|
9
|
+
class SortByLast < Base
|
|
10
|
+
self.pattern_id = :sort_by_last
|
|
11
|
+
self.optimization_type = :allocation
|
|
12
|
+
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
super
|
|
15
|
+
|
|
16
|
+
# Look for: .last where receiver is .sort_by { block }
|
|
17
|
+
return unless node.name == :last && node.arguments.nil?
|
|
18
|
+
|
|
19
|
+
receiver = node.receiver
|
|
20
|
+
return unless receiver.is_a?(Prism::CallNode) && receiver.name == :sort_by
|
|
21
|
+
return unless block_attached?(receiver)
|
|
22
|
+
|
|
23
|
+
add_finding(
|
|
24
|
+
node,
|
|
25
|
+
message: "Use `.max_by { }` instead of `.sort_by { }.last` to avoid sorting entire array",
|
|
26
|
+
speedup: "Avoids sorting entire array"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: array.sort.first -> array.min, array.sort.last -> array.max
|
|
6
|
+
#
|
|
7
|
+
# sort.first/last sorts the entire array O(n log n) then takes one element.
|
|
8
|
+
# min/max finds the element in a single O(n) pass without sorting.
|
|
9
|
+
class SortFirst < Base
|
|
10
|
+
self.pattern_id = :sort_first
|
|
11
|
+
self.optimization_type = :allocation
|
|
12
|
+
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
super
|
|
15
|
+
|
|
16
|
+
return unless %i[first last].include?(node.name) && node.arguments.nil?
|
|
17
|
+
|
|
18
|
+
receiver = node.receiver
|
|
19
|
+
return unless receiver.is_a?(Prism::CallNode)
|
|
20
|
+
|
|
21
|
+
case [receiver.name, node.name]
|
|
22
|
+
when [:sort, :first]
|
|
23
|
+
add_finding(
|
|
24
|
+
node,
|
|
25
|
+
message: "Use `.min` instead of `.sort.first` to find minimum in O(n) without sorting",
|
|
26
|
+
speedup: "O(n log n) sort to O(n) single pass, no intermediate array"
|
|
27
|
+
)
|
|
28
|
+
when [:sort, :last]
|
|
29
|
+
add_finding(
|
|
30
|
+
node,
|
|
31
|
+
message: "Use `.max` instead of `.sort.last` to find maximum in O(n) without sorting",
|
|
32
|
+
speedup: "O(n log n) sort to O(n) single pass, no intermediate array"
|
|
33
|
+
)
|
|
34
|
+
when [:sort_by, :first]
|
|
35
|
+
return unless block_attached?(receiver)
|
|
36
|
+
add_finding(
|
|
37
|
+
node,
|
|
38
|
+
message: "Use `.min_by { }` instead of `.sort_by { }.first` to find minimum in O(n)",
|
|
39
|
+
speedup: "O(n log n) sort to O(n) single pass, no intermediate array"
|
|
40
|
+
)
|
|
41
|
+
when [:sort_by, :last]
|
|
42
|
+
return unless block_attached?(receiver)
|
|
43
|
+
add_finding(
|
|
44
|
+
node,
|
|
45
|
+
message: "Use `.max_by { }` instead of `.sort_by { }.last` to find maximum in O(n)",
|
|
46
|
+
speedup: "O(n log n) sort to O(n) single pass, no intermediate array"
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: array.sort.last -> array.max
|
|
6
|
+
#
|
|
7
|
+
# sort.last sorts the entire array then takes the last element.
|
|
8
|
+
# max directly finds the maximum without creating intermediate array.
|
|
9
|
+
class SortLast < Base
|
|
10
|
+
self.pattern_id = :sort_last
|
|
11
|
+
self.optimization_type = :allocation
|
|
12
|
+
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
super
|
|
15
|
+
|
|
16
|
+
# Look for: .last or .last(n) where receiver is .sort
|
|
17
|
+
return unless node.name == :last
|
|
18
|
+
|
|
19
|
+
receiver = node.receiver
|
|
20
|
+
return unless receiver.is_a?(Prism::CallNode) && receiver.name == :sort
|
|
21
|
+
|
|
22
|
+
add_finding(
|
|
23
|
+
node,
|
|
24
|
+
message: "Use `.max` instead of `.sort.last` to avoid sorting entire array",
|
|
25
|
+
speedup: "Avoids sorting entire array"
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: array.sort.reverse -> array.sort { |a, b| b <=> a }
|
|
6
|
+
#
|
|
7
|
+
# Calling .sort.reverse creates an intermediate sorted array, then
|
|
8
|
+
# reverses it. Sorting with a descending comparator avoids the
|
|
9
|
+
# intermediate array allocation.
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# # Bad - creates intermediate array
|
|
13
|
+
# array.sort.reverse
|
|
14
|
+
#
|
|
15
|
+
# # Good - sorts in descending order directly
|
|
16
|
+
# array.sort { |a, b| b <=> a }
|
|
17
|
+
#
|
|
18
|
+
# Note: For sort_by, use: array.sort_by { |x| -x.value } for numeric values
|
|
19
|
+
class SortReverse < Base
|
|
20
|
+
self.pattern_id = :sort_reverse
|
|
21
|
+
self.optimization_type = :allocation
|
|
22
|
+
|
|
23
|
+
def visit_call_node(node)
|
|
24
|
+
super
|
|
25
|
+
|
|
26
|
+
# Look for: .reverse where receiver is .sort or .sort_by
|
|
27
|
+
return unless node.name == :reverse
|
|
28
|
+
|
|
29
|
+
receiver = node.receiver
|
|
30
|
+
return unless receiver.is_a?(Prism::CallNode)
|
|
31
|
+
|
|
32
|
+
case receiver.name
|
|
33
|
+
when :sort
|
|
34
|
+
# .sort.reverse -> .sort { |a, b| b <=> a }
|
|
35
|
+
add_finding(
|
|
36
|
+
node,
|
|
37
|
+
message: "Use `.sort { |a, b| b <=> a }` instead of `.sort.reverse` to avoid intermediate array",
|
|
38
|
+
speedup: "Avoids creating intermediate sorted array"
|
|
39
|
+
)
|
|
40
|
+
when :sort_by
|
|
41
|
+
# .sort_by { }.reverse -> consider negating the sort key
|
|
42
|
+
return unless block_attached?(receiver)
|
|
43
|
+
|
|
44
|
+
add_finding(
|
|
45
|
+
node,
|
|
46
|
+
message: "Consider negating the sort key in `.sort_by` instead of calling `.reverse`",
|
|
47
|
+
speedup: "Avoids creating intermediate sorted array"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: str.downcase == other.downcase -> str.casecmp?(other)
|
|
6
|
+
#
|
|
7
|
+
# casecmp? performs case-insensitive comparison without creating
|
|
8
|
+
# intermediate lowercase/uppercase strings, reducing allocations.
|
|
9
|
+
class StringCasecmp < Base
|
|
10
|
+
self.pattern_id = :string_casecmp
|
|
11
|
+
self.optimization_type = :allocation
|
|
12
|
+
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
super
|
|
15
|
+
|
|
16
|
+
return unless node.name == :==
|
|
17
|
+
|
|
18
|
+
# The receiver should be a call to downcase or upcase
|
|
19
|
+
receiver = node.receiver
|
|
20
|
+
return unless case_conversion_call?(receiver)
|
|
21
|
+
|
|
22
|
+
# The argument should also be a call to downcase or upcase
|
|
23
|
+
args = node.arguments&.arguments
|
|
24
|
+
return unless args&.size == 1
|
|
25
|
+
|
|
26
|
+
arg = args[0]
|
|
27
|
+
return unless case_conversion_call?(arg)
|
|
28
|
+
|
|
29
|
+
# Both should use the same case conversion method
|
|
30
|
+
receiver_method = receiver.name
|
|
31
|
+
arg_method = arg.name
|
|
32
|
+
return unless receiver_method == arg_method
|
|
33
|
+
|
|
34
|
+
method_name = (receiver_method == :downcase) ? "downcase" : "upcase"
|
|
35
|
+
|
|
36
|
+
add_finding(
|
|
37
|
+
node,
|
|
38
|
+
message: "Use `.casecmp?(other)` instead of `.#{method_name} == other.#{method_name}`",
|
|
39
|
+
speedup: "Avoids creating intermediate lowercase strings"
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def case_conversion_call?(node)
|
|
46
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
47
|
+
|
|
48
|
+
(node.name == :downcase || node.name == :upcase) &&
|
|
49
|
+
node.arguments.nil? &&
|
|
50
|
+
!block_attached?(node)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Detects `str.chars.each { }` which should be `str.each_char { }`
|
|
6
|
+
#
|
|
7
|
+
# chars.each creates an intermediate array of single-character strings.
|
|
8
|
+
# each_char iterates directly without allocation.
|
|
9
|
+
#
|
|
10
|
+
# @example Bad
|
|
11
|
+
# str.chars.each { |c| puts c }
|
|
12
|
+
#
|
|
13
|
+
# @example Good
|
|
14
|
+
# str.each_char { |c| puts c }
|
|
15
|
+
#
|
|
16
|
+
class StringCharsEach < Base
|
|
17
|
+
self.pattern_id = :string_chars_each
|
|
18
|
+
self.optimization_type = :allocation
|
|
19
|
+
|
|
20
|
+
def visit_call_node(node)
|
|
21
|
+
if chained_chars_each?(node)
|
|
22
|
+
replacement = suggest_replacement(node)
|
|
23
|
+
add_finding(
|
|
24
|
+
node,
|
|
25
|
+
message: "Use `#{replacement}` instead of `chars.each` to avoid intermediate array allocation",
|
|
26
|
+
speedup: "No intermediate array allocation"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def chained_chars_each?(node)
|
|
36
|
+
return false unless node.name == :each && has_block?(node)
|
|
37
|
+
|
|
38
|
+
receiver = node.receiver
|
|
39
|
+
return false unless receiver.is_a?(Prism::CallNode)
|
|
40
|
+
return false unless receiver.name == :chars
|
|
41
|
+
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def suggest_replacement(node)
|
|
46
|
+
receiver = node.receiver.receiver
|
|
47
|
+
receiver_src = receiver ? receiver.slice : "str"
|
|
48
|
+
"#{receiver_src}.each_char { ... }"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def has_block?(node)
|
|
52
|
+
node.block.is_a?(Prism::BlockNode) || node.block.is_a?(Prism::BlockArgumentNode)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: String concatenation (`+=`) inside a loop -> use `<<` or array join
|
|
6
|
+
#
|
|
7
|
+
# Each string `+=` operation creates a new string object, copying all previous
|
|
8
|
+
# content. In a loop, this leads to O(n^2) memory allocations and copies.
|
|
9
|
+
# Using `<<` mutates the string in place, avoiding allocations.
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# # Bad - O(n^2) allocations
|
|
13
|
+
# items.each { |item| result += item.to_s }
|
|
14
|
+
#
|
|
15
|
+
# # Good - O(n) with in-place mutation
|
|
16
|
+
# items.each { |item| result << item.to_s }
|
|
17
|
+
#
|
|
18
|
+
# # Good - collect and join once
|
|
19
|
+
# result = items.map(&:to_s).join
|
|
20
|
+
#
|
|
21
|
+
# Impact: Significant in tight loops, avoids O(n^2) string copying
|
|
22
|
+
class StringConcatInLoop < Base
|
|
23
|
+
self.pattern_id = :string_concat_in_loop
|
|
24
|
+
self.optimization_type = :allocation
|
|
25
|
+
|
|
26
|
+
LOOP_METHODS = %i[each each_with_index each_with_object map collect
|
|
27
|
+
times upto downto step loop].freeze
|
|
28
|
+
|
|
29
|
+
def initialize(file_path)
|
|
30
|
+
super
|
|
31
|
+
@in_loop = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Track when we enter/exit while loops
|
|
35
|
+
def visit_while_node(node)
|
|
36
|
+
with_context(:@in_loop, true) { super }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Track when we enter/exit until loops
|
|
40
|
+
def visit_until_node(node)
|
|
41
|
+
with_context(:@in_loop, true) { super }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Track when we enter/exit for loops
|
|
45
|
+
def visit_for_node(node)
|
|
46
|
+
with_context(:@in_loop, true) { super }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Handle block-based loops (.each, .times, .map, loop, etc.)
|
|
50
|
+
def visit_call_node(node)
|
|
51
|
+
if loop_method?(node) && node.block
|
|
52
|
+
with_context(:@in_loop, true) { super }
|
|
53
|
+
else
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Detect local variable += (e.g., str += "x")
|
|
59
|
+
def visit_local_variable_operator_write_node(node)
|
|
60
|
+
check_string_concat(node)
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Detect instance variable += (e.g., @str += "x")
|
|
65
|
+
def visit_instance_variable_operator_write_node(node)
|
|
66
|
+
check_string_concat(node)
|
|
67
|
+
super
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Detect class variable += (e.g., @@str += "x")
|
|
71
|
+
def visit_class_variable_operator_write_node(node)
|
|
72
|
+
check_string_concat(node)
|
|
73
|
+
super
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Detect global variable += (e.g., $str += "x")
|
|
77
|
+
def visit_global_variable_operator_write_node(node)
|
|
78
|
+
check_string_concat(node)
|
|
79
|
+
super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def loop_method?(node)
|
|
85
|
+
LOOP_METHODS.include?(node.name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_string_concat(node)
|
|
89
|
+
return unless @in_loop
|
|
90
|
+
return unless node.binary_operator == :+
|
|
91
|
+
return unless string_value?(node.value)
|
|
92
|
+
|
|
93
|
+
add_finding(
|
|
94
|
+
node,
|
|
95
|
+
message: "Use `<<` instead of `+=` for string concatenation in loops to avoid allocations",
|
|
96
|
+
speedup: "Significant in tight loops, avoids O(n^2) string copying"
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def string_value?(node)
|
|
101
|
+
case node
|
|
102
|
+
when Prism::StringNode, Prism::InterpolatedStringNode
|
|
103
|
+
true
|
|
104
|
+
when Prism::CallNode
|
|
105
|
+
# Method calls that likely return strings (e.g., to_s, inspect, to_str)
|
|
106
|
+
%i[to_s to_str inspect].include?(node.name)
|
|
107
|
+
else
|
|
108
|
+
# For other node types (variables, etc.), we can't be sure
|
|
109
|
+
# but += with + operator is most commonly used for strings
|
|
110
|
+
# We'll be conservative and only match known string types
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: str.sub(/^prefix/, '') -> str.delete_prefix('prefix')
|
|
6
|
+
#
|
|
7
|
+
# delete_prefix is a specialized method that avoids the regex engine overhead.
|
|
8
|
+
# It's approximately 2x faster for this common use case.
|
|
9
|
+
class StringDeletePrefix < Base
|
|
10
|
+
self.pattern_id = :string_delete_prefix
|
|
11
|
+
self.optimization_type = :cpu
|
|
12
|
+
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
super
|
|
15
|
+
|
|
16
|
+
return unless node.name == :sub
|
|
17
|
+
|
|
18
|
+
args = node.arguments&.arguments
|
|
19
|
+
return unless args&.size == 2
|
|
20
|
+
|
|
21
|
+
first_arg = args[0]
|
|
22
|
+
second_arg = args[1]
|
|
23
|
+
|
|
24
|
+
# Check if first arg is a regex starting with ^
|
|
25
|
+
return unless first_arg.is_a?(Prism::RegularExpressionNode)
|
|
26
|
+
return unless second_arg.is_a?(Prism::StringNode) && second_arg.content.empty?
|
|
27
|
+
|
|
28
|
+
pattern = first_arg.content
|
|
29
|
+
return unless pattern.start_with?("^")
|
|
30
|
+
|
|
31
|
+
# Extract the literal prefix (after ^)
|
|
32
|
+
prefix = pattern[1..]
|
|
33
|
+
|
|
34
|
+
# Only suggest for simple literal prefixes (no regex metacharacters)
|
|
35
|
+
return unless simple_literal?(prefix)
|
|
36
|
+
|
|
37
|
+
add_finding(
|
|
38
|
+
node,
|
|
39
|
+
message: "Use `.delete_prefix('#{prefix}')` instead of `.sub(/^#{prefix}/, '')`",
|
|
40
|
+
speedup: "Avoids regex engine overhead"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def simple_literal?(str)
|
|
47
|
+
# Check that the string doesn't contain regex metacharacters
|
|
48
|
+
# that would change its meaning
|
|
49
|
+
!str.match?(/[.+*?\[\](){}|\\$]/)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: str.sub(/suffix$/, '') -> str.delete_suffix('suffix')
|
|
6
|
+
#
|
|
7
|
+
# delete_suffix is a specialized method that avoids the regex engine overhead.
|
|
8
|
+
# It's approximately 2x faster for this common use case.
|
|
9
|
+
class StringDeleteSuffix < Base
|
|
10
|
+
self.pattern_id = :string_delete_suffix
|
|
11
|
+
self.optimization_type = :cpu
|
|
12
|
+
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
super
|
|
15
|
+
|
|
16
|
+
return unless node.name == :sub
|
|
17
|
+
|
|
18
|
+
args = node.arguments&.arguments
|
|
19
|
+
return unless args&.size == 2
|
|
20
|
+
|
|
21
|
+
first_arg = args[0]
|
|
22
|
+
second_arg = args[1]
|
|
23
|
+
|
|
24
|
+
# Check if first arg is a regex ending with $
|
|
25
|
+
return unless first_arg.is_a?(Prism::RegularExpressionNode)
|
|
26
|
+
return unless second_arg.is_a?(Prism::StringNode) && second_arg.content.empty?
|
|
27
|
+
|
|
28
|
+
pattern = first_arg.content
|
|
29
|
+
return unless pattern.end_with?("$")
|
|
30
|
+
|
|
31
|
+
# Extract the literal suffix (before $)
|
|
32
|
+
suffix = pattern[0..-2]
|
|
33
|
+
|
|
34
|
+
# Only suggest for simple literal suffixes (no regex metacharacters)
|
|
35
|
+
return unless simple_literal?(suffix)
|
|
36
|
+
|
|
37
|
+
add_finding(
|
|
38
|
+
node,
|
|
39
|
+
message: "Use `.delete_suffix('#{suffix}')` instead of `.sub(/#{suffix}$/, '')`",
|
|
40
|
+
speedup: "Avoids regex engine overhead"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def simple_literal?(str)
|
|
47
|
+
# Check that the string doesn't contain regex metacharacters
|
|
48
|
+
# that would change its meaning
|
|
49
|
+
!str.match?(/[.+*?\[\](){}|\\^]/)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: str.length == 0 or str.size == 0 -> str.empty?
|
|
6
|
+
#
|
|
7
|
+
# Comparing length/size to 0 is less idiomatic than using empty?.
|
|
8
|
+
# empty? is the Ruby way to check for emptiness.
|
|
9
|
+
#
|
|
10
|
+
# @example Bad
|
|
11
|
+
# str.length == 0
|
|
12
|
+
# str.size == 0
|
|
13
|
+
# 0 == str.length
|
|
14
|
+
# 0 == str.size
|
|
15
|
+
#
|
|
16
|
+
# @example Good
|
|
17
|
+
# str.empty?
|
|
18
|
+
class StringEmpty < Base
|
|
19
|
+
self.pattern_id = :string_empty
|
|
20
|
+
self.optimization_type = :cpu
|
|
21
|
+
|
|
22
|
+
def visit_call_node(node)
|
|
23
|
+
super
|
|
24
|
+
|
|
25
|
+
return unless length_or_size_equals_zero?(node)
|
|
26
|
+
|
|
27
|
+
add_finding(
|
|
28
|
+
node,
|
|
29
|
+
message: "Use `empty?` instead of comparing `length`/`size` to 0",
|
|
30
|
+
speedup: "Minor, but more idiomatic"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Detects: str.length == 0 or str.size == 0 or 0 == str.length or 0 == str.size
|
|
37
|
+
def length_or_size_equals_zero?(node)
|
|
38
|
+
return false unless node.name == :==
|
|
39
|
+
return false unless node.arguments&.arguments&.size == 1
|
|
40
|
+
|
|
41
|
+
receiver = node.receiver
|
|
42
|
+
arg = node.arguments.arguments[0]
|
|
43
|
+
|
|
44
|
+
# Check: receiver.length/size == 0 or 0 == receiver.length/size
|
|
45
|
+
(length_or_size_call?(receiver) && integer_zero?(arg)) ||
|
|
46
|
+
(integer_zero?(receiver) && length_or_size_call?(arg))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def length_or_size_call?(node)
|
|
50
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
51
|
+
|
|
52
|
+
%i[length size].include?(node.name) && no_arguments?(node)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def no_arguments?(node)
|
|
56
|
+
node.arguments.nil? || node.arguments.arguments.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def integer_zero?(node)
|
|
60
|
+
node.is_a?(Prism::IntegerNode) && node.value == 0
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: str[-1] == 'x' or str.match?(/x$/) -> str.end_with?('x')
|
|
6
|
+
#
|
|
7
|
+
# Indexing at position -1 for comparison or using regex with $ anchor
|
|
8
|
+
# is less clear and potentially slower than using end_with?.
|
|
9
|
+
#
|
|
10
|
+
# @example Bad
|
|
11
|
+
# str[-1] == 'x'
|
|
12
|
+
# str.match?(/foo$/)
|
|
13
|
+
#
|
|
14
|
+
# @example Good
|
|
15
|
+
# str.end_with?('x')
|
|
16
|
+
# str.end_with?('foo')
|
|
17
|
+
class StringEndWith < Base
|
|
18
|
+
self.pattern_id = :string_end_with
|
|
19
|
+
self.optimization_type = :cpu
|
|
20
|
+
|
|
21
|
+
def visit_call_node(node)
|
|
22
|
+
super
|
|
23
|
+
|
|
24
|
+
if index_negative_one_comparison?(node)
|
|
25
|
+
add_finding(
|
|
26
|
+
node,
|
|
27
|
+
message: "Use `end_with?` instead of `str[-1] == ...` for cleaner code",
|
|
28
|
+
speedup: "Cleaner and avoids substring/regex overhead"
|
|
29
|
+
)
|
|
30
|
+
elsif regex_end_anchor?(node)
|
|
31
|
+
add_finding(
|
|
32
|
+
node,
|
|
33
|
+
message: "Use `end_with?` instead of `match?(/...$/)` to avoid regex overhead",
|
|
34
|
+
speedup: "Cleaner and avoids substring/regex overhead"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Detects: str[-1] == 'x' or 'x' == str[-1]
|
|
42
|
+
def index_negative_one_comparison?(node)
|
|
43
|
+
return false unless node.name == :==
|
|
44
|
+
return false unless node.arguments&.arguments&.size == 1
|
|
45
|
+
|
|
46
|
+
receiver = node.receiver
|
|
47
|
+
arg = node.arguments.arguments[0]
|
|
48
|
+
|
|
49
|
+
# Check receiver[-1] == arg or arg == receiver[-1]
|
|
50
|
+
index_at_negative_one?(receiver) || index_at_negative_one?(arg)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Detects: str.match?(/...$/)
|
|
54
|
+
def regex_end_anchor?(node)
|
|
55
|
+
return false unless node.name == :match?
|
|
56
|
+
return false unless node.arguments&.arguments&.size == 1
|
|
57
|
+
|
|
58
|
+
arg = node.arguments.arguments[0]
|
|
59
|
+
return false unless arg.is_a?(Prism::RegularExpressionNode)
|
|
60
|
+
|
|
61
|
+
# Check if regex ends with $ anchor (and $ is not escaped)
|
|
62
|
+
content = arg.content
|
|
63
|
+
content.end_with?("$") && !content.end_with?("\\$")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if node is a [] call with index -1
|
|
67
|
+
def index_at_negative_one?(node)
|
|
68
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
69
|
+
return false unless node.name == :[]
|
|
70
|
+
return false unless node.arguments&.arguments&.size == 1
|
|
71
|
+
|
|
72
|
+
index_arg = node.arguments.arguments[0]
|
|
73
|
+
integer_negative_one?(index_arg)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def integer_negative_one?(node)
|
|
77
|
+
node.is_a?(Prism::IntegerNode) && node.value == -1
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|