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,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