ruby_routes 2.5.0 → 2.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -23
  3. data/lib/ruby_routes/constant.rb +18 -3
  4. data/lib/ruby_routes/lru_strategies/hit_strategy.rb +1 -1
  5. data/lib/ruby_routes/node.rb +14 -87
  6. data/lib/ruby_routes/radix_tree/finder.rb +75 -47
  7. data/lib/ruby_routes/radix_tree/inserter.rb +2 -55
  8. data/lib/ruby_routes/radix_tree/traversal_strategy/base.rb +18 -0
  9. data/lib/ruby_routes/radix_tree/traversal_strategy/generic_loop.rb +25 -0
  10. data/lib/ruby_routes/radix_tree/traversal_strategy/unrolled.rb +45 -0
  11. data/lib/ruby_routes/radix_tree/traversal_strategy.rb +26 -0
  12. data/lib/ruby_routes/radix_tree.rb +12 -62
  13. data/lib/ruby_routes/route/check_helpers.rb +3 -3
  14. data/lib/ruby_routes/route/constraint_validator.rb +22 -1
  15. data/lib/ruby_routes/route/matcher.rb +11 -0
  16. data/lib/ruby_routes/route/param_support.rb +9 -8
  17. data/lib/ruby_routes/route/path_builder.rb +11 -6
  18. data/lib/ruby_routes/route/path_generation.rb +5 -1
  19. data/lib/ruby_routes/route/small_lru.rb +43 -2
  20. data/lib/ruby_routes/route/validation_helpers.rb +6 -36
  21. data/lib/ruby_routes/route.rb +35 -56
  22. data/lib/ruby_routes/route_set/cache_helpers.rb +29 -13
  23. data/lib/ruby_routes/route_set/collection_helpers.rb +8 -10
  24. data/lib/ruby_routes/route_set.rb +34 -59
  25. data/lib/ruby_routes/router/build_helpers.rb +1 -7
  26. data/lib/ruby_routes/router/builder.rb +12 -12
  27. data/lib/ruby_routes/router/http_helpers.rb +7 -48
  28. data/lib/ruby_routes/router/resource_helpers.rb +23 -37
  29. data/lib/ruby_routes/router/scope_helpers.rb +26 -14
  30. data/lib/ruby_routes/router.rb +28 -29
  31. data/lib/ruby_routes/segment.rb +3 -3
  32. data/lib/ruby_routes/segments/base_segment.rb +8 -0
  33. data/lib/ruby_routes/segments/static_segment.rb +3 -1
  34. data/lib/ruby_routes/strategies/base.rb +18 -0
  35. data/lib/ruby_routes/strategies/hash_based_strategy.rb +33 -0
  36. data/lib/ruby_routes/strategies/hybrid_strategy.rb +70 -0
  37. data/lib/ruby_routes/strategies/radix_tree_strategy.rb +24 -0
  38. data/lib/ruby_routes/strategies.rb +5 -0
  39. data/lib/ruby_routes/utility/key_builder_utility.rb +4 -26
  40. data/lib/ruby_routes/utility/method_utility.rb +11 -11
  41. data/lib/ruby_routes/utility/path_utility.rb +18 -7
  42. data/lib/ruby_routes/version.rb +1 -1
  43. data/lib/ruby_routes.rb +3 -1
  44. metadata +11 -2
  45. data/lib/ruby_routes/string_extensions.rb +0 -65
@@ -6,6 +6,7 @@ require_relative 'router/http_helpers'
6
6
  require_relative 'router/resource_helpers'
7
7
  require_relative 'constant'
8
8
  require_relative 'route_set'
9
+
9
10
  module RubyRoutes
10
11
  # RubyRoutes::Router
11
12
  #
@@ -64,15 +65,28 @@ module RubyRoutes
64
65
 
65
66
  # Initialize the router.
66
67
  #
68
+ # @param strategy [Class] The matching strategy to use.
67
69
  # @param definition_block [Proc] The block to define routes.
68
- def initialize(&definition_block)
69
- @route_set = RouteSet.new
70
+ def initialize(strategy: Strategies::HybridStrategy, &definition_block)
71
+ @route_set = RouteSet.new(strategy: strategy)
70
72
  @route_utils = RubyRoutes::Utility::RouteUtility.new(@route_set)
71
73
  @scope_stack = []
72
74
  @concerns = {}
75
+ @frozen = false
73
76
  instance_eval(&definition_block) if definition_block
74
77
  end
75
78
 
79
+ # Add a route to the route set.
80
+ #
81
+ # @param path [String] The route path.
82
+ # @param options [Hash] The route options.
83
+ # @return [void]
84
+ def add_route(path, options = {})
85
+ ensure_unfrozen!
86
+ scoped_options = apply_scope(path, options)
87
+ @route_utils.define(scoped_options[:path], scoped_options.except(:path))
88
+ end
89
+
76
90
  # Build a finalized router.
77
91
  #
78
92
  # @param definition_block [Proc] The block to define routes.
@@ -88,12 +102,7 @@ module RubyRoutes
88
102
  return self if @frozen
89
103
 
90
104
  @frozen = true
91
- if @route_set.respond_to?(:finalize!)
92
- @route_set.finalize!
93
- else
94
- @route_set.freeze
95
- end
96
- @route_utils.freeze if @route_utils.respond_to?(:freeze)
105
+ @route_set.freeze
97
106
  @scope_stack.freeze
98
107
  @concerns.freeze
99
108
  self
@@ -155,9 +164,9 @@ module RubyRoutes
155
164
  # @return [Router] self.
156
165
  def namespace(namespace_name, options = {}, &block)
157
166
  ensure_unfrozen!
158
- push_scope({ path: "/#{namespace_name}", module: namespace_name }.merge(options)) do
159
- instance_eval(&block) if block
160
- end
167
+ scoped_options = { path: "/#{namespace_name}", module: namespace_name }.merge(options)
168
+ scope(scoped_options, &block)
169
+ self
161
170
  end
162
171
 
163
172
  # Define a scope.
@@ -169,26 +178,16 @@ module RubyRoutes
169
178
  ensure_unfrozen!
170
179
  scope_entry = options_or_path.is_a?(String) ? { path: options_or_path } : options_or_path
171
180
  push_scope(scope_entry) { instance_eval(&block) if block }
181
+ self
172
182
  end
173
183
 
174
- # Define constraints.
175
- #
176
- # @param constraints_hash [Hash] The constraints for the scope.
177
- # @param block [Proc] The block for nested routes.
178
- # @return [Router] self.
179
- def constraints(constraints_hash = {}, &block)
180
- ensure_unfrozen!
181
- push_scope(constraints: constraints_hash) { instance_eval(&block) if block }
182
- end
183
-
184
- # Define defaults.
185
- #
186
- # @param defaults_hash [Hash] The default values for the scope.
187
- # @param block [Proc] The block for nested routes.
188
- # @return [Router] self.
189
- def defaults(defaults_hash = {}, &block)
190
- ensure_unfrozen!
191
- push_scope(defaults: defaults_hash) { instance_eval(&block) if block }
184
+ # Metaprogram `constraints` and `defaults` for DRYness.
185
+ %i[constraints defaults].each do |scope_type|
186
+ define_method(scope_type) do |options_hash = {}, &block|
187
+ ensure_unfrozen!
188
+ push_scope(scope_type => options_hash) { instance_eval(&block) if block }
189
+ self
190
+ end
192
191
  end
193
192
 
194
193
  # ---- Concerns ----------------------------------------------------------
@@ -23,15 +23,15 @@ module RubyRoutes
23
23
  class Segment
24
24
  # Build an appropriate segment instance for the provided token.
25
25
  #
26
- # @param text [String, Symbol, #to_s] raw segment token
26
+ # @param segment_token [String, Symbol, #to_s] raw segment token
27
27
  # @return [RubyRoutes::Segments::BaseSegment]
28
28
  #
29
29
  # @example
30
30
  # Segment.for(":id") # => DynamicSegment
31
31
  # Segment.for("*files") # => WildcardSegment
32
32
  # Segment.for("users") # => StaticSegment
33
- def self.for(text)
34
- segment_text = text.to_s
33
+ def self.for(segment_token)
34
+ segment_text = segment_token.to_s
35
35
  segment_key = segment_text.empty? ? :default : segment_text.getbyte(0)
36
36
  segment_class = RubyRoutes::Constant::SEGMENTS[segment_key] || RubyRoutes::Constant::SEGMENTS[:default]
37
37
  segment_class.new(segment_text)
@@ -21,6 +21,14 @@ module RubyRoutes
21
21
  # @param raw_segment_text [String, Symbol, nil]
22
22
  def initialize(raw_segment_text = nil)
23
23
  @raw_text = raw_segment_text.to_s if raw_segment_text
24
+ @param_name = nil
25
+ end
26
+
27
+ # Get the parameter name for this segment (if any).
28
+ #
29
+ # @return [String, nil]
30
+ def param_name
31
+ @param_name
24
32
  end
25
33
 
26
34
  # Indicates whether this segment is a wildcard (greedy) segment.
@@ -23,10 +23,12 @@ module RubyRoutes
23
23
  #
24
24
  # @api internal
25
25
  class StaticSegment < BaseSegment
26
+ attr_reader :literal_text
27
+
26
28
  # @param raw_segment_text [String] literal segment token
27
29
  def initialize(raw_segment_text)
28
30
  super(raw_segment_text)
29
- @literal_text = raw_segment_text
31
+ @literal_text = raw_segment_text.freeze
30
32
  end
31
33
 
32
34
  # Ensure a static child node for this literal under +parent_node+.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRoutes
4
+ module Strategies
5
+ # Base
6
+ #
7
+ # Defines the interface for matching strategies.
8
+ module Base
9
+ def add(route)
10
+ raise NotImplementedError, "#{self.class.name} must implement #add"
11
+ end
12
+
13
+ def find(path, http_method)
14
+ raise NotImplementedError, "#{self.class.name} must implement #find"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RubyRoutes
6
+ module Strategies
7
+ # HashBasedStrategy
8
+ #
9
+ # A simple hash-based lookup strategy for route matching.
10
+ class HashBasedStrategy
11
+ include Base
12
+
13
+ def initialize
14
+ @routes = {}
15
+ end
16
+
17
+ def add(route)
18
+ route.methods.each do |method|
19
+ key = "#{method.upcase}::#{route.path&.downcase}"
20
+ @routes[key] = route
21
+ end
22
+ end
23
+
24
+ def find(path, http_method)
25
+ key = "#{http_method.upcase}::#{path&.downcase}"
26
+ route = @routes[key]
27
+ return nil unless route
28
+
29
+ [route, {}]
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../radix_tree'
5
+
6
+ module RubyRoutes
7
+ module Strategies
8
+ # HybridStrategy: Combines hash-based lookup for static routes with
9
+ # radix tree lookup for dynamic routes.
10
+ #
11
+ # Performance optimization:
12
+ # - Static routes: O(1) hash lookup
13
+ # - Dynamic routes: O(path length) radix tree traversal
14
+ # - Automatic classification based on route pattern
15
+ class HybridStrategy
16
+ include Base
17
+
18
+ def initialize
19
+ @static_routes = {}
20
+ @dynamic_routes = RadixTree.new
21
+ end
22
+
23
+ # Add a route to the appropriate storage based on whether it's static or dynamic
24
+ #
25
+ # @param route [Route] The route to add
26
+ def add(route)
27
+ if static_route?(route.path)
28
+ @static_routes[route.path] ||= {}
29
+ route.methods.each do |method|
30
+ @static_routes[route.path][method] = route
31
+ end
32
+ else
33
+ # Extract path, methods, and handler from route for RadixTree
34
+ @dynamic_routes.add(route.path, route.methods, route)
35
+ end
36
+ end
37
+
38
+ # Find a route for the given path and method
39
+ #
40
+ # @param path [String] The request path
41
+ # @param method [String] The HTTP method
42
+ # @return [Array<Route, Hash>, nil] [route, params] or nil if not found
43
+ def find(path, method)
44
+ # Try static routes first (fastest path)
45
+ if @static_routes.key?(path) && @static_routes[path].key?(method)
46
+ route = @static_routes[path][method]
47
+ return [route, {}]
48
+ end
49
+
50
+ # Fall back to dynamic routes
51
+ result = @dynamic_routes.find(path, method)
52
+
53
+ # RadixTree returns [nil, {}] when no route found, convert to nil
54
+ return nil if result && result.first.nil?
55
+
56
+ result
57
+ end
58
+
59
+ private
60
+
61
+ # Determine if a route path is static (no parameters)
62
+ #
63
+ # @param path [String] The route path
64
+ # @return [Boolean] true if static, false if dynamic
65
+ def static_route?(path)
66
+ !path.include?(':') && !path.include?('*')
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../radix_tree'
4
+
5
+ module RubyRoutes
6
+ module Strategies
7
+ # RadixTreeStrategy
8
+ #
9
+ # Encapsulates RadixTree-based route matching.
10
+ class RadixTreeStrategy
11
+ def initialize
12
+ @radix_tree = RadixTree.new
13
+ end
14
+
15
+ def add(route)
16
+ @radix_tree.add(route.path, route.methods, route)
17
+ end
18
+
19
+ def find(path, http_method)
20
+ @radix_tree.find(path, http_method)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'strategies/base'
4
+ require_relative 'strategies/radix_tree_strategy'
5
+ require_relative 'strategies/hash_based_strategy'
@@ -49,40 +49,18 @@ module RubyRoutes
49
49
  # - Records it in the nested pool.
50
50
  # - Tracks insertion in a fixed ring; when full, overwrites oldest.
51
51
  #
52
- # @param http_method [String] The HTTP method (e.g., "GET").
53
52
  # @param request_path [String] The request path (e.g., "/users").
54
53
  # @return [String] A frozen canonical key.
55
54
  def fetch_request_key(http_method, request_path)
56
55
  @mutex.synchronize do
57
- method_key, path_key = prepare_keys(http_method, request_path)
58
-
56
+ method_key = http_method.freeze
57
+ path_key = request_path.to_s.freeze
59
58
  bucket = @request_key_pool[method_key] ||= {}
60
- return bucket[path_key] if bucket[path_key]
61
-
62
- handle_cache_miss(bucket, method_key, path_key)
59
+ bucket[path_key] || create_and_cache_key(bucket, method_key, path_key)
63
60
  end
64
61
  end
65
62
 
66
- private
67
-
68
- # Prepare keys by freezing them if necessary.
69
- #
70
- # @param http_method [String] The HTTP method.
71
- # @param request_path [String] The request path.
72
- # @return [Array<String>] An array containing the frozen method and path keys.
73
- def prepare_keys(http_method, request_path)
74
- method_key = http_method.frozen? ? http_method : http_method.dup.freeze
75
- path_key = request_path.frozen? ? request_path : request_path.dup.freeze
76
- [method_key, path_key]
77
- end
78
-
79
- # Handle a cache miss by creating a composite key and updating the ring buffer.
80
- #
81
- # @param bucket [Hash] The bucket for the method key.
82
- # @param method_key [String] The HTTP method key.
83
- # @param path_key [String] The path key.
84
- # @return [String] The composite key.
85
- def handle_cache_miss(bucket, method_key, path_key)
63
+ def create_and_cache_key(bucket, method_key, path_key)
86
64
  composite = "#{method_key}:#{path_key}".freeze
87
65
  bucket[path_key] = composite
88
66
  handle_ring_buffer(method_key, path_key)
@@ -42,7 +42,7 @@ module RubyRoutes
42
42
  # Now uses SmallLru for LRU eviction instead of simple clearing.
43
43
  #
44
44
  # @return [SmallLru]
45
- METHOD_CACHE = RubyRoutes::Route::SmallLru.new(RubyRoutes::Constant::METHOD_CACHE_MAX_SIZE)
45
+ METHOD_CACHE = RubyRoutes::Route::SmallLru.new(RubyRoutes::Constant::CACHE_SIZE)
46
46
 
47
47
  # Normalize an HTTP method‑like input to a canonical uppercase `String`.
48
48
  #
@@ -69,16 +69,20 @@ module RubyRoutes
69
69
 
70
70
  private
71
71
 
72
+ def cache_normalized_method(input_string)
73
+ return input_string if already_upper_ascii?(input_string)
74
+
75
+ # Use SmallLru for LRU eviction, freeze key to prevent mutation
76
+ key = input_string.dup.freeze
77
+ METHOD_CACHE.get(key) || METHOD_CACHE.set(key, ascii_upcase(input_string.dup).freeze)
78
+ end
79
+
72
80
  # Normalize a `String` HTTP method.
73
81
  #
74
82
  # @param method_input [String] The HTTP method input.
75
83
  # @return [String] The normalized HTTP method.
76
84
  def normalize_string_method(method_input)
77
- return method_input if already_upper_ascii?(method_input)
78
-
79
- # Use SmallLru for LRU eviction, freeze key to prevent mutation
80
- key = method_input.dup.freeze
81
- METHOD_CACHE.get(key) || METHOD_CACHE.set(key, ascii_upcase(method_input.dup).freeze)
85
+ cache_normalized_method(method_input)
82
86
  end
83
87
 
84
88
  # Normalize a `Symbol` HTTP method.
@@ -98,11 +102,7 @@ module RubyRoutes
98
102
  # @return [String] The normalized HTTP method.
99
103
  def normalize_other_method(method_input)
100
104
  coerced = method_input.to_s
101
- return coerced if already_upper_ascii?(coerced)
102
-
103
- # Use SmallLru for LRU eviction, freeze key to prevent mutation
104
- key = coerced.dup.freeze
105
- METHOD_CACHE.get(key) || METHOD_CACHE.set(key, ascii_upcase(coerced.dup).freeze)
105
+ cache_normalized_method(coerced)
106
106
  end
107
107
 
108
108
  # Determine if a `String` consists solely of uppercase ASCII (`A–Z`) or non‑letters.
@@ -32,10 +32,11 @@ module RubyRoutes
32
32
  # normalize_path('/users') # => "/users"
33
33
  # normalize_path('/') # => "/"
34
34
  def normalize_path(raw_path)
35
- normalized_path = raw_path.to_s
36
- normalized_path = "/#{normalized_path}" unless normalized_path.start_with?('/')
37
- normalized_path = normalized_path[0..-2] if normalized_path.length > 1 && normalized_path.end_with?('/')
38
- normalized_path
35
+ return '/' if raw_path.nil? || raw_path.empty?
36
+
37
+ path = raw_path.start_with?('/') ? raw_path : "/#{raw_path}"
38
+ path = path.chomp('/') unless path == '/'
39
+ path
39
40
  end
40
41
 
41
42
  # Normalize HTTP method to uppercase String (fast path).
@@ -56,10 +57,20 @@ module RubyRoutes
56
57
  # split_path('/users/123?x=1') # => ["users", "123"]
57
58
  # split_path('/') # => []
58
59
  def split_path(raw_path)
59
- path_without_query = raw_path.to_s.split(/[?#]/, 2).first
60
- return [] if path_without_query.nil? || path_without_query.empty?
60
+ return [] if raw_path == '/' || raw_path.empty?
61
+
62
+ # Strip query strings and fragments
63
+ path = raw_path.split(/[?#]/).first
61
64
 
62
- path_without_query.split('/').reject(&:empty?)
65
+ # Optimized trimming: avoid string allocations when possible
66
+ start_idx = path.start_with?('/') ? 1 : 0
67
+ end_idx = path.end_with?('/') ? -2 : -1
68
+
69
+ if start_idx == 0 && end_idx == -1
70
+ path.split('/').reject(&:empty?)
71
+ else
72
+ path[start_idx..end_idx].split('/').reject(&:empty?)
73
+ end
63
74
  end
64
75
 
65
76
  # Join path parts into a normalized absolute path.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyRoutes
4
- VERSION = '2.5.0'
4
+ VERSION = '2.6.0'
5
5
  end
data/lib/ruby_routes.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative 'ruby_routes/version'
4
4
  require_relative 'ruby_routes/constant'
5
- require_relative 'ruby_routes/string_extensions'
6
5
  require_relative 'ruby_routes/route'
7
6
  require_relative 'ruby_routes/route_set'
8
7
  require_relative 'ruby_routes/url_helpers'
@@ -10,6 +9,9 @@ require_relative 'ruby_routes/router'
10
9
  require_relative 'ruby_routes/radix_tree'
11
10
  require_relative 'ruby_routes/node'
12
11
  require_relative 'ruby_routes/router/builder'
12
+ require_relative 'ruby_routes/strategies/base'
13
+ require_relative 'ruby_routes/strategies/hash_based_strategy'
14
+ require_relative 'ruby_routes/strategies/hybrid_strategy'
13
15
 
14
16
  # RubyRoutes
15
17
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_routes
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.0
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yosef Benny Widyokarsono
@@ -83,9 +83,14 @@ files:
83
83
  - lib/ruby_routes/radix_tree.rb
84
84
  - lib/ruby_routes/radix_tree/finder.rb
85
85
  - lib/ruby_routes/radix_tree/inserter.rb
86
+ - lib/ruby_routes/radix_tree/traversal_strategy.rb
87
+ - lib/ruby_routes/radix_tree/traversal_strategy/base.rb
88
+ - lib/ruby_routes/radix_tree/traversal_strategy/generic_loop.rb
89
+ - lib/ruby_routes/radix_tree/traversal_strategy/unrolled.rb
86
90
  - lib/ruby_routes/route.rb
87
91
  - lib/ruby_routes/route/check_helpers.rb
88
92
  - lib/ruby_routes/route/constraint_validator.rb
93
+ - lib/ruby_routes/route/matcher.rb
89
94
  - lib/ruby_routes/route/param_support.rb
90
95
  - lib/ruby_routes/route/path_builder.rb
91
96
  - lib/ruby_routes/route/path_generation.rb
@@ -108,7 +113,11 @@ files:
108
113
  - lib/ruby_routes/segments/dynamic_segment.rb
109
114
  - lib/ruby_routes/segments/static_segment.rb
110
115
  - lib/ruby_routes/segments/wildcard_segment.rb
111
- - lib/ruby_routes/string_extensions.rb
116
+ - lib/ruby_routes/strategies.rb
117
+ - lib/ruby_routes/strategies/base.rb
118
+ - lib/ruby_routes/strategies/hash_based_strategy.rb
119
+ - lib/ruby_routes/strategies/hybrid_strategy.rb
120
+ - lib/ruby_routes/strategies/radix_tree_strategy.rb
112
121
  - lib/ruby_routes/url_helpers.rb
113
122
  - lib/ruby_routes/utility/inflector_utility.rb
114
123
  - lib/ruby_routes/utility/key_builder_utility.rb
@@ -1,65 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Minimal String inflection helpers.
4
- #
5
- # @note This is a very small, intentionally naive English inflector
6
- # covering only a few common pluralization patterns used inside the
7
- # routing DSL (e.g., resources / resource helpers). It is NOT a full
8
- # replacement for ActiveSupport::Inflector and should not be relied on
9
- # for general linguistic correctness.
10
- #
11
- # @note Supported patterns:
12
- # - Singularize:
13
- # * words ending in "ies" -> "y" (companies -> company)
14
- # * words ending in "s" -> strip trailing "s" (users -> user)
15
- # - Pluralize:
16
- # * words ending in "y" -> replace with "ies" (company -> companies)
17
- # * words ending in sh/ch/x -> append "es" (box -> boxes)
18
- # * words ending in "z" -> append "zes" (quiz -> quizzes) (simplified)
19
- # * words ending in "s" -> unchanged
20
- # * default -> append "s"
21
- #
22
- # @note Limitations:
23
- # - Does not handle irregular forms (person/people, child/children, etc.).
24
- # - Simplified handling of "z" endings (adds "zes" instead of "zzes").
25
- # - Case‑sensitive (expects lowercase ASCII).
26
- #
27
- # @api internal
28
- class String
29
- # Convert a plural form to a simplistic singular.
30
- #
31
- # @example Singularize a word
32
- # "companies".singularize # => "company"
33
- # "users".singularize # => "user"
34
- # "box".singularize # => "box" (unchanged)
35
- #
36
- # @return [String] Singularized form (may be the same object if no change is needed).
37
- def singularize
38
- case self
39
- when /ies$/
40
- sub(/ies$/, 'y')
41
- when /s$/
42
- sub(/s$/, '')
43
- else
44
- self
45
- end
46
- end
47
-
48
- # Convert a singular form to a simplistic plural.
49
- #
50
- # @example Pluralize a word
51
- # "company".pluralize # => "companies"
52
- # "box".pluralize # => "boxes"
53
- # "quiz".pluralize # => "quizzes"
54
- # "user".pluralize # => "users"
55
- #
56
- # @return [String] Pluralized form.
57
- def pluralize
58
- return self if end_with?('s')
59
- return sub(/y$/, 'ies') if end_with?('y')
60
- return "#{self}es" if match?(/sh$|ch$|x$/)
61
- return "#{self}zes" if end_with?('z')
62
-
63
- "#{self}s"
64
- end
65
- end