Module: Philiprehberger::NaturalSort

Defined in:
lib/philiprehberger/natural_sort.rb,
lib/philiprehberger/natural_sort/version.rb,
lib/philiprehberger/natural_sort/comparator.rb

Defined Under Namespace

Modules: ArrayRefinement Classes: Error

Constant Summary collapse

VERSION =
'0.5.0'

Class Method Summary collapse

Class Method Details

.collate(a, b, case_sensitive: false) ⇒ Integer

Spaceship-style comparator returning -1, 0, or 1.

Suitable for use with Array#sort:

array.sort { |a, b| NaturalSort.collate(a, b) }

Parameters:

  • a (String, nil)

    first string

  • b (String, nil)

    second string

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Returns:

  • (Integer)

    -1, 0, or 1



179
180
181
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 179

def self.collate(a, b, case_sensitive: false)
  compare(a, b, case_sensitive: case_sensitive)
end

.comparator(case_sensitive: false) ⇒ Proc

Returns a Proc suitable for use with Array#sort.

Parameters:

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Returns:

  • (Proc)

    a comparison proc returning -1, 0, or 1



71
72
73
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 71

def self.comparator(case_sensitive: false)
  ->(a, b) { compare(a, b, case_sensitive: case_sensitive) }
end

.compare(a, b, case_sensitive: false) ⇒ Integer

Compares two strings using natural sort order.

Parameters:

  • a (String, nil)

    first string

  • b (String, nil)

    second string

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Returns:

  • (Integer)

    -1, 0, or 1



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 23

def self.compare(a, b, case_sensitive: false)
  return 0 if a.nil? && b.nil?
  return -1 if a.nil?
  return 1 if b.nil?

  a_str = a.to_s
  b_str = b.to_s

  tokens_a = tokenize(a_str, case_sensitive: case_sensitive)
  tokens_b = tokenize(b_str, case_sensitive: case_sensitive)

  max_len = [tokens_a.length, tokens_b.length].max

  max_len.times do |i|
    chunk_a = tokens_a[i]
    chunk_b = tokens_b[i]

    # Shorter token list comes first
    return -1 if chunk_a.nil?
    return 1 if chunk_b.nil?

    # Both numeric
    if chunk_a.is_a?(Integer) && chunk_b.is_a?(Integer)
      cmp = chunk_a <=> chunk_b
      return cmp unless cmp.zero?

      next
    end

    # Both strings
    if chunk_a.is_a?(String) && chunk_b.is_a?(String)
      cmp = chunk_a <=> chunk_b
      return cmp unless cmp.zero?

      next
    end

    # Mixed: numbers sort before strings
    return chunk_a.is_a?(Integer) ? -1 : 1
  end

  0
end

.group_by_prefix(array, case_sensitive: false) ⇒ Hash<String, Array<String>>

Splits each string at the first digit boundary, groups by the non-numeric prefix. Each group’s values are naturally sorted.

Parameters:

  • array (Array<String>)

    the array to group

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Returns:

  • (Hash<String, Array<String>>)

    prefix => naturally sorted values



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 189

def self.group_by_prefix(array, case_sensitive: false)
  groups = {}

  array.each do |str|
    s = str.to_s
    match = s.match(/\A([^\d]*)/)
    prefix = match ? match[1] : ''

    groups[prefix] ||= []
    groups[prefix] << str
  end

  groups.each_value do |values|
    values.replace(sort(values, case_sensitive: case_sensitive))
  end

  groups
end

.max(array, case_sensitive: false) ⇒ String?

Finds the naturally largest element without full sort.

Parameters:

  • array (Array<String, nil>)

    the array to search

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Returns:

  • (String, nil)

    the naturally largest element, or nil for empty arrays



132
133
134
135
136
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 132

def self.max(array, case_sensitive: false)
  return nil if array.empty?

  array.max { |a, b| compare(a, b, case_sensitive: case_sensitive) }
end

.min(array, case_sensitive: false) ⇒ String?

Finds the naturally smallest element without full sort.

Parameters:

  • array (Array<String, nil>)

    the array to search

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Returns:

  • (String, nil)

    the naturally smallest element, or nil for empty arrays



121
122
123
124
125
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 121

def self.min(array, case_sensitive: false)
  return nil if array.empty?

  array.min { |a, b| compare(a, b, case_sensitive: case_sensitive) }
end

.natural_key(str, case_sensitive: false) ⇒ Array

Returns a sort key array usable with Ruby’s built-in sort_by, min_by, max_by, etc.

The key is an array of [type_flag, value] pairs where type_flag ensures correct ordering between numeric and string chunks (numbers sort before strings).

Parameters:

  • str (String, nil)

    the string to generate a key for

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Returns:

  • (Array)

    a comparable sort key



146
147
148
149
150
151
152
153
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 146

def self.natural_key(str, case_sensitive: false)
  return [[-1, '']] if str.nil?

  tokens = tokenize(str.to_s, case_sensitive: case_sensitive)
  tokens.map do |chunk|
    chunk.is_a?(Integer) ? [0, chunk] : [1, chunk]
  end
end

.sort(array, case_sensitive: false, reverse: false, ignore_case: false) ⇒ Array<String, nil>

Sorts an array of strings in natural order.

Parameters:

  • array (Array<String, nil>)

    the array to sort

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

  • reverse (Boolean) (defaults to: false)

    when true, reverses the natural order

  • ignore_case (Boolean) (defaults to: false)

    when true, downcases sort keys before comparison

Returns:

  • (Array<String, nil>)

    a new sorted array



82
83
84
85
86
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 82

def self.sort(array, case_sensitive: false, reverse: false, ignore_case: false)
  effective_case_sensitive = ignore_case ? false : case_sensitive
  result = array.sort { |a, b| compare(a, b, case_sensitive: effective_case_sensitive) }
  reverse ? result.reverse : result
end

.sort_by(array, case_sensitive: false, reverse: false, ignore_case: false) {|element| ... } ⇒ Array

Sorts an array by the natural order of values returned by the block.

Parameters:

  • array (Array)

    the array to sort

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

  • reverse (Boolean) (defaults to: false)

    when true, reverses the natural order

  • ignore_case (Boolean) (defaults to: false)

    when true, downcases sort keys before comparison

Yields:

  • (element)

    block that returns the string to compare

Returns:

  • (Array)

    a new sorted array



96
97
98
99
100
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 96

def self.sort_by(array, case_sensitive: false, reverse: false, ignore_case: false, &block)
  effective_case_sensitive = ignore_case ? false : case_sensitive
  result = array.sort { |a, b| compare(block.call(a), block.call(b), case_sensitive: effective_case_sensitive) }
  reverse ? result.reverse : result
end

.sort_by_stable(array, case_sensitive: false) {|element| ... } ⇒ Array

Stable sort by block result, preserving original order for equal elements.

Parameters:

  • array (Array)

    the array to sort

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Yields:

  • (element)

    block that returns the string to compare

Returns:

  • (Array)

    a new sorted array with stable ordering



161
162
163
164
165
166
167
168
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 161

def self.sort_by_stable(array, case_sensitive: false, &block)
  array.each_with_index
       .sort_by do |element, index|
    key_str = block.call(element)
    [natural_key(key_str, case_sensitive: case_sensitive), index]
  end
       .map(&:first)
end

.sort_stable(array, case_sensitive: false) ⇒ Array<String, nil>

Stable sort that preserves original order for equal elements.

Parameters:

  • array (Array<String, nil>)

    the array to sort

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Returns:

  • (Array<String, nil>)

    a new sorted array with stable ordering



107
108
109
110
111
112
113
114
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 107

def self.sort_stable(array, case_sensitive: false)
  array.each_with_index
       .sort_by do |element, index|
    [tokenize(element.nil? ? '' : element.to_s, case_sensitive: case_sensitive), element.nil? ? 0 : 1,
     index]
  end
       .map(&:first)
end

.tokenize(str, case_sensitive: false) ⇒ Array<String, Integer>

Splits a string into chunks of text and numbers for natural comparison.

Parameters:

  • str (String)

    the string to tokenize

  • case_sensitive (Boolean) (defaults to: false)

    whether text comparison is case-sensitive

Returns:

  • (Array<String, Integer>)

    alternating text and numeric chunks



10
11
12
13
14
15
# File 'lib/philiprehberger/natural_sort/comparator.rb', line 10

def self.tokenize(str, case_sensitive: false)
  normalized = case_sensitive ? str : str.downcase
  normalized.scan(/\d+|[^\d]+/).map do |chunk|
    chunk.match?(/\A\d+\z/) ? chunk.to_i : chunk
  end
end