keycase 1.2.0 → 1.3.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.
@@ -1,14 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
- require_relative "support/transformer"
3
+ require_relative "support/structure_keys"
6
4
  require_relative "support/tokenizer"
7
5
 
8
6
  module Keycase
9
7
  module SnakeCase
8
+ def self.convert_string(str, upcase: false)
9
+ words = Keycase::Support::Tokenizer.words(str)
10
+ converter = upcase ? :upcase : :downcase
11
+ words.map(&converter).join("_")
12
+ end
13
+
14
+ def self.convert(value, upcase: false)
15
+ case value
16
+ when String then convert_string(value, upcase: upcase)
17
+ when Symbol then convert_string(value.to_s, upcase: upcase).to_sym
18
+ else value
19
+ end
20
+ end
21
+
22
+ def self.convert_keys(structure, options = {})
23
+ upcase = options.fetch(:upcase, false)
24
+ Keycase::Support::StructureKeys.transform(structure, options) do |key|
25
+ convert(key, upcase: upcase)
26
+ end
27
+ end
28
+
10
29
  refine Object do
11
- def to_snake_case
30
+ def to_snake_case(_options = {})
12
31
  self
13
32
  end
14
33
 
@@ -18,43 +37,43 @@ module Keycase
18
37
  end
19
38
 
20
39
  refine String do
21
- def to_snake_case
22
- Keycase::Support::Tokenizer.words(self).map do |word|
23
- word.downcase
24
- end.join("_")
40
+ def to_snake_case(upcase: false)
41
+ Keycase::SnakeCase.convert_string(self, upcase: upcase)
25
42
  end
26
43
  end
27
44
 
28
45
  refine Symbol do
29
- def to_snake_case
30
- to_s.to_snake_case.to_sym
46
+ def to_snake_case(upcase: false)
47
+ Keycase::SnakeCase.convert(self, upcase: upcase)
31
48
  end
32
49
  end
33
50
 
34
51
  refine Array do
35
52
  def with_snake_case_keys(options = {})
36
- Keycase::Support::Transformer.transform_array(
37
- self,
38
- ::Set.new,
39
- 0,
40
- options[:max_depth]
41
- ) do |key|
42
- key.to_snake_case
43
- end
53
+ Keycase::SnakeCase.convert_keys(self, options)
44
54
  end
45
55
  end
46
56
 
47
57
  refine Hash do
48
58
  def with_snake_case_keys(options = {})
49
- Keycase::Support::Transformer.transform_hash(
50
- self,
51
- ::Set.new,
52
- 0,
53
- options[:max_depth]
54
- ) do |key|
55
- key.to_snake_case
56
- end
59
+ Keycase::SnakeCase.convert_keys(self, options)
57
60
  end
58
61
  end
62
+
63
+ refine Struct do
64
+ def with_snake_case_keys(options = {})
65
+ Keycase::SnakeCase.convert_keys(self, options)
66
+ end
67
+ end
68
+ end
69
+
70
+ class << self
71
+ def snake_case(value, upcase: false)
72
+ SnakeCase.convert(value, upcase: upcase)
73
+ end
74
+
75
+ def with_snake_case_keys(value, options = {})
76
+ SnakeCase.convert_keys(value, options)
77
+ end
59
78
  end
60
79
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycase
4
+ module Support
5
+ module Acronyms
6
+ module_function
7
+
8
+ def index(list)
9
+ return {} if list.nil?
10
+
11
+ Array(list).each_with_object({}) do |item, memo|
12
+ s = item.to_s
13
+ next if s.empty?
14
+
15
+ memo[s.downcase] = s
16
+ end
17
+ end
18
+
19
+ def segment(word, acronyms_by_downcase)
20
+ acronyms_by_downcase[word.downcase] || word.capitalize
21
+ end
22
+
23
+ def leading_acronym?(first_word, acronyms_by_downcase)
24
+ return false unless first_word
25
+
26
+ acronyms_by_downcase.key?(first_word.downcase)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycase
4
+ module Support
5
+ module StructInstantiation
6
+ KEYWORD_INIT_IVAR = :@keycase_keyword_init
7
+
8
+ module_function
9
+
10
+ def struct_keyword_init_available?
11
+ return @struct_keyword_init_available if defined?(@struct_keyword_init_available)
12
+
13
+ available = false
14
+ begin
15
+ Struct.new(:x, keyword_init: true)
16
+ available = true
17
+ rescue TypeError
18
+ available = false
19
+ end
20
+ @struct_keyword_init_available = available
21
+ end
22
+
23
+ def mark_keyword_init!(klass)
24
+ klass.instance_variable_set(KEYWORD_INIT_IVAR, true)
25
+ end
26
+
27
+ def struct_keyword_init?(klass)
28
+ return false unless struct_keyword_init_available?
29
+
30
+ return true if klass.instance_variable_defined?(KEYWORD_INIT_IVAR)
31
+
32
+ klass.respond_to?(:keyword_init?) && klass.keyword_init?
33
+ end
34
+
35
+ def build_struct_class(*members, keyword_init:)
36
+ if keyword_init && struct_keyword_init_available?
37
+ Struct.new(*members, keyword_init: true).tap do |klass|
38
+ mark_keyword_init!(klass)
39
+ end
40
+ else
41
+ Struct.new(*members)
42
+ end
43
+ end
44
+
45
+ def each_member_pair(structure)
46
+ # Struct member accessors can hide the methods used by conversion.
47
+ ::Struct.instance_method(:each_pair).bind(structure).to_enum(:call)
48
+ end
49
+
50
+ def rebuild(original, pairs)
51
+ klass = Object.instance_method(:class).bind(original).call
52
+ original_members = each_member_pair(original).map(&:first)
53
+ new_members = pairs.keys
54
+
55
+ # Older Rubies cannot identify keyword_init on Struct classes created by callers.
56
+ return copy_struct(original, pairs, klass) if new_members == original_members
57
+
58
+ target_klass = build_struct_class(*new_members, keyword_init: struct_keyword_init?(klass))
59
+ instantiate(target_klass, pairs, new_members)
60
+ end
61
+
62
+ def instantiate(klass, pairs, member_order)
63
+ instance = klass.allocate
64
+ # A converted member named initialize can hide Struct's initializer.
65
+ initializer = ::Struct.instance_method(:initialize).bind(instance)
66
+ if struct_keyword_init?(klass)
67
+ initializer.call(**pairs)
68
+ else
69
+ values = member_order.map do |member|
70
+ pairs[member]
71
+ end
72
+ initializer.call(*values)
73
+ end
74
+ instance
75
+ end
76
+
77
+ def copy_struct(original, pairs, klass)
78
+ copy = duplicate_struct(original, klass)
79
+ writer = ::Struct.instance_method(:[]=).bind(copy)
80
+ pairs.each do |member, value|
81
+ writer.call(member, value)
82
+ end
83
+ copy
84
+ end
85
+
86
+ def duplicate_struct(original, klass)
87
+ return Object.instance_method(:dup).bind(original).call unless copy_hooks_shadowed?(klass)
88
+
89
+ # dup dispatches copy hooks, which member accessors can hide.
90
+ copy = Class.instance_method(:allocate).bind(klass).call
91
+ ::Struct.instance_method(:initialize_copy).bind(copy).call(original)
92
+ copy_instance_variables(original, copy)
93
+ copy
94
+ end
95
+
96
+ def copy_hooks_shadowed?(klass)
97
+ (%i[initialize_dup initialize_copy] & klass.members).any? do |hook|
98
+ klass.instance_method(hook).arity.zero?
99
+ end
100
+ end
101
+
102
+ def copy_instance_variables(original, copy)
103
+ reader = Object.instance_method(:instance_variable_get).bind(original)
104
+ writer = Object.instance_method(:instance_variable_set).bind(copy)
105
+ Object.instance_method(:instance_variables).bind(original).call.each do |name|
106
+ writer.call(name, reader.call(name))
107
+ end
108
+ end
109
+ private_class_method :copy_struct, :duplicate_struct, :copy_hooks_shadowed?, :copy_instance_variables
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "transformer"
6
+
7
+ module Keycase
8
+ module Support
9
+ module StructureKeys
10
+ module_function
11
+
12
+ def transform(structure, options = {}, &key_converter)
13
+ opts = Transformer.enrich_options(options)
14
+ state = Transformer::State.new(::Set.new, 0, opts, :none)
15
+
16
+ Transformer.transform_value(structure, state, &key_converter)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -7,13 +7,9 @@ module Keycase
7
7
 
8
8
  def words(value)
9
9
  value
10
- .gsub(/(?<=[A-Z])(?=[A-Z][a-z])/) do |_|
11
- "_"
12
- end
13
- .gsub(/(?<=[0-9a-z])(?=[A-Z])/) do |_|
14
- "_"
15
- end
16
- .scan(/[0-9A-Za-z]+/)
10
+ .gsub(/(?<=\p{Lu})(?=\p{Lu}\p{Ll})/, "_")
11
+ .gsub(/(?<=[\p{Ll}\p{Nd}])(?=\p{Lu})/, "_")
12
+ .scan(/[\p{L}\p{N}]+/)
17
13
  end
18
14
  end
19
15
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycase
4
+ module Support
5
+ module Transformer
6
+ class << self
7
+ private
8
+
9
+ def keep_existing_key?(memo, new_key, original_key, on_collision)
10
+ return false unless memo.key?(new_key)
11
+
12
+ if on_collision == :raise
13
+ message = "Keycase detected a key collision: #{original_key.inspect} converted to " \
14
+ "#{new_key.inspect}, which already exists in the transformed structure"
15
+ raise KeyCollisionError, message
16
+ end
17
+
18
+ on_collision == :keep_first
19
+ end
20
+
21
+ def resolve_on_collision!(options)
22
+ mode = options.fetch(:on_collision, :raise)
23
+ return mode if %i[raise overwrite keep_first].include?(mode)
24
+
25
+ raise ArgumentError, "Keycase on_collision must be :raise, :overwrite, or :keep_first (got #{mode.inspect})"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycase
4
+ module Support
5
+ module Transformer
6
+ class << self
7
+ private
8
+
9
+ def convert_key?(key, options)
10
+ klasses = options[:__keycase_only_klasses]
11
+ return true if klasses.nil?
12
+
13
+ klasses.any? do |klass|
14
+ # Struct keys can hide is_a? with a member accessor.
15
+ klass === key # rubocop:disable Style/CaseEquality
16
+ end
17
+ end
18
+
19
+ def normalize_only_classes(only)
20
+ return nil if only.nil?
21
+
22
+ raise ArgumentError, "Keycase only must be an Array (got #{only.class})" unless only.is_a?(Array)
23
+
24
+ only.map do |spec|
25
+ normalize_only_spec(spec)
26
+ end
27
+ end
28
+
29
+ def normalize_only_spec(spec)
30
+ case spec
31
+ when Class
32
+ assert_only_allowed_class!(spec)
33
+ spec
34
+ when :string, "string" then String
35
+ when :symbol, "symbol" then Symbol
36
+ else
37
+ raise ArgumentError, "Keycase only: unsupported entry #{spec.inspect}"
38
+ end
39
+ end
40
+
41
+ def assert_only_allowed_class!(spec)
42
+ return if [String, Symbol].include?(spec)
43
+
44
+ raise ArgumentError,
45
+ "Keycase only allows String and Symbol as classes (got #{spec})"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycase
4
+ module Support
5
+ module Transformer
6
+ class << self
7
+ private
8
+
9
+ def build_transformed_struct(struct, state, on_collision, &key_converter)
10
+ pairs = build_transformed_members(
11
+ StructInstantiation.each_member_pair(struct), child_state(state, :struct), on_collision, &key_converter
12
+ )
13
+ StructInstantiation.rebuild(struct, pairs)
14
+ end
15
+
16
+ def build_transformed_members(members, child, on_collision, &key_converter)
17
+ result = members.is_a?(Hash) && members.compare_by_identity? ? {}.compare_by_identity : {}
18
+ members.each_with_object(result) do |(key, value), memo|
19
+ new_key = convert_member_key(key, child, &key_converter)
20
+ next if keep_existing_key?(memo, new_key, key, on_collision)
21
+
22
+ memo[new_key] = transform_value(value, child, &key_converter)
23
+ end
24
+ end
25
+
26
+ def convert_member_key(key, state, &key_converter)
27
+ return key unless convert_key?(key, state.options)
28
+
29
+ new_key = key_converter.call(key)
30
+ # Only Struct members prohibit empty names; nested Hash keys may be empty.
31
+ return key if state.parent_kind == :struct && new_key.to_s.empty?
32
+
33
+ new_key
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycase
4
+ module Support
5
+ module Transformer
6
+ class << self
7
+ private
8
+
9
+ def check_depth!(depth, max_depth)
10
+ return if max_depth.nil?
11
+
12
+ raise StructureTooDeepError, "Keycase nesting exceeds max_depth (#{max_depth})" if depth > max_depth
13
+ end
14
+
15
+ def visit_structure(structure, visiting, kind)
16
+ oid = Object.instance_method(:object_id).bind(structure).call
17
+ raise CircularStructureError, circular_reference_message(kind) if visiting.include?(oid)
18
+
19
+ visiting.add(oid)
20
+ begin
21
+ yield
22
+ ensure
23
+ visiting.delete(oid)
24
+ end
25
+ end
26
+
27
+ def circular_reference_message(kind)
28
+ "Keycase detected a circular reference in a #{kind}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,68 +1,82 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "struct_instantiation"
5
+ require_relative "transformer/visit"
6
+ require_relative "transformer/only_normalization"
7
+ require_relative "transformer/collision"
8
+ require_relative "transformer/structure_builders"
4
9
 
5
10
  module Keycase
6
11
  module Support
7
12
  module Transformer
13
+ State = Struct.new(:visiting, :depth, :options, :parent_kind)
14
+
8
15
  class << self
9
- def transform_hash(hash, visiting, depth, max_depth, &key_converter)
10
- check_depth!(depth, max_depth)
11
-
12
- oid = hash.object_id
13
- raise CircularStructureError, "Keycase detected a circular reference in a Hash" if visiting.include?(oid)
14
-
15
- visiting.add(oid)
16
- begin
17
- hash.each_with_object({}) do |(key, value), memo|
18
- new_key = key_converter.call(key)
19
- if memo.key?(new_key)
20
- message = "Keycase detected a key collision: #{key.inspect} converted to " \
21
- "#{new_key.inspect}, which already exists in the transformed hash"
22
- raise KeyCollisionError, message
23
- end
24
-
25
- memo[new_key] = transform_value(
26
- value,
27
- visiting,
28
- depth + 1,
29
- max_depth,
30
- &key_converter
31
- )
32
- end
33
- ensure
34
- visiting.delete(oid)
16
+ def enrich_options(options)
17
+ recursive = options.fetch(:recursive, true)
18
+ arrays = options.fetch(:arrays, true)
19
+ unless [true, false].include?(recursive)
20
+ raise ArgumentError, "Keycase recursive must be true or false (got #{recursive.inspect})"
21
+ end
22
+ unless [true, false].include?(arrays)
23
+ raise ArgumentError, "Keycase arrays must be true or false (got #{arrays.inspect})"
35
24
  end
25
+
26
+ options.merge(
27
+ recursive: recursive,
28
+ arrays: arrays,
29
+ __keycase_only_klasses: normalize_only_classes(options[:only])
30
+ )
36
31
  end
37
32
 
38
- def transform_array(array, visiting, depth, max_depth, &key_converter)
39
- check_depth!(depth, max_depth)
33
+ def transform_hash(hash, state, &key_converter)
34
+ return hash if state.options[:recursive] == false && state.parent_kind == :hash
40
35
 
41
- oid = array.object_id
42
- raise CircularStructureError, "Keycase detected a circular reference in an Array" if visiting.include?(oid)
36
+ on_collision = resolve_on_collision!(state.options)
37
+ check_depth!(state.depth, state.options[:max_depth])
43
38
 
44
- visiting.add(oid)
45
- begin
39
+ visit_structure(hash, state.visiting, "Hash") do
40
+ build_transformed_members(hash, child_state(state, :hash), on_collision, &key_converter)
41
+ end
42
+ end
43
+
44
+ def transform_array(array, state, &key_converter)
45
+ if state.options[:arrays] == false
46
+ return array.map do |element|
47
+ element
48
+ end
49
+ end
50
+
51
+ check_depth!(state.depth, state.options[:max_depth])
52
+
53
+ visit_structure(array, state.visiting, "Array") do
54
+ child = child_state(state, :array)
46
55
  array.map do |element|
47
- transform_value(
48
- element,
49
- visiting,
50
- depth + 1,
51
- max_depth,
52
- &key_converter
53
- )
56
+ transform_value(element, child, &key_converter)
54
57
  end
55
- ensure
56
- visiting.delete(oid)
57
58
  end
58
59
  end
59
60
 
60
- def transform_value(value, visiting, depth, max_depth, &key_converter)
61
+ def transform_struct(struct, state, &key_converter)
62
+ return struct if state.options[:recursive] == false && state.parent_kind == :struct
63
+
64
+ on_collision = resolve_on_collision!(state.options)
65
+ check_depth!(state.depth, state.options[:max_depth])
66
+
67
+ visit_structure(struct, state.visiting, "Struct") do
68
+ build_transformed_struct(struct, state, on_collision, &key_converter)
69
+ end
70
+ end
71
+
72
+ def transform_value(value, state, &key_converter)
61
73
  case value
62
74
  when Hash
63
- transform_hash(value, visiting, depth, max_depth, &key_converter)
75
+ transform_hash(value, state, &key_converter)
64
76
  when Array
65
- transform_array(value, visiting, depth, max_depth, &key_converter)
77
+ transform_array(value, state, &key_converter)
78
+ when ::Struct
79
+ transform_struct(value, state, &key_converter)
66
80
  else
67
81
  value
68
82
  end
@@ -70,10 +84,8 @@ module Keycase
70
84
 
71
85
  private
72
86
 
73
- def check_depth!(depth, max_depth)
74
- return if max_depth.nil?
75
-
76
- raise StructureTooDeepError, "Keycase nesting exceeds max_depth (#{max_depth})" if depth > max_depth
87
+ def child_state(state, parent_kind)
88
+ State.new(state.visiting, state.depth + 1, state.options, parent_kind)
77
89
  end
78
90
  end
79
91
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Keycase
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/keycase.rb CHANGED
@@ -1,15 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Recursively converts string/symbol case and hash keys (camelCase, snake_case, etc.).
4
+ #
5
+ # +Keycase+ is reopened in +lib/keycase/<case>.rb+; module functions (+Keycase.camel_case+ and
6
+ # friends) and refinement modules (+Keycase::CamelCase+, ...) live in those files, not here.
7
+ #
8
+ # Call module functions on +Keycase+ when +using Keycase::CamelCase+ (or another case module)
9
+ # is impractical, such as in a Rails initializer, another gem, or shared code. Refinements remain
10
+ # available for lexically scoped +to_*+ / +with_*_keys+ instance methods.
3
11
  require "keycase/version"
4
12
  require "keycase/support/errors"
5
13
  require "keycase/support/transformer"
6
14
  require "keycase/support/tokenizer"
7
15
  require "keycase/camel_case"
8
16
  require "keycase/kebab_case"
9
- require "keycase/pascal_case"
10
- require "keycase/screaming_snake_case"
11
17
  require "keycase/snake_case"
12
- require "keycase/train_case"
13
18
 
14
19
  module Keycase
15
20
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keycase
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - naoigcat
@@ -11,7 +11,7 @@ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: |2
13
13
  This gem converts the case of strings, symbols, and keys of hash recursively.
14
- The convertible cases are camelCase, PascalCase, snake_case, etc.
14
+ The convertible cases are camelCase, snake_case, kebab-case.
15
15
  email:
16
16
  - 17925623+naoigcat@users.noreply.github.com
17
17
  executables: []
@@ -24,13 +24,17 @@ files:
24
24
  - lib/keycase.rb
25
25
  - lib/keycase/camel_case.rb
26
26
  - lib/keycase/kebab_case.rb
27
- - lib/keycase/pascal_case.rb
28
- - lib/keycase/screaming_snake_case.rb
29
27
  - lib/keycase/snake_case.rb
28
+ - lib/keycase/support/acronyms.rb
30
29
  - lib/keycase/support/errors.rb
30
+ - lib/keycase/support/struct_instantiation.rb
31
+ - lib/keycase/support/structure_keys.rb
31
32
  - lib/keycase/support/tokenizer.rb
32
33
  - lib/keycase/support/transformer.rb
33
- - lib/keycase/train_case.rb
34
+ - lib/keycase/support/transformer/collision.rb
35
+ - lib/keycase/support/transformer/only_normalization.rb
36
+ - lib/keycase/support/transformer/structure_builders.rb
37
+ - lib/keycase/support/transformer/visit.rb
34
38
  - lib/keycase/version.rb
35
39
  homepage: https://github.com/naoigcat/ruby-keycase
36
40
  licenses: