jekyll-relationships 0.1.0.alpha

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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/lib/jekyll-relationships/configuration/debug_setting.rb +117 -0
  3. data/lib/jekyll-relationships/configuration/defaults.rb +54 -0
  4. data/lib/jekyll-relationships/configuration/frontmatter.rb +102 -0
  5. data/lib/jekyll-relationships/configuration/hash_utilities.rb +55 -0
  6. data/lib/jekyll-relationships/configuration/multiple_settings.rb +101 -0
  7. data/lib/jekyll-relationships/configuration/parser.rb +551 -0
  8. data/lib/jekyll-relationships/configuration/prune_rule_settings.rb +101 -0
  9. data/lib/jekyll-relationships/configuration/prune_settings.rb +82 -0
  10. data/lib/jekyll-relationships/configuration/tree_frontmatter.rb +244 -0
  11. data/lib/jekyll-relationships/configuration/tree_settings.rb +74 -0
  12. data/lib/jekyll-relationships/configuration.rb +250 -0
  13. data/lib/jekyll-relationships/debug_logger.rb +104 -0
  14. data/lib/jekyll-relationships/definitions/configured_relationship.rb +61 -0
  15. data/lib/jekyll-relationships/definitions/normal_relationship.rb +51 -0
  16. data/lib/jekyll-relationships/definitions/prune_rule.rb +67 -0
  17. data/lib/jekyll-relationships/definitions/tree_relationship.rb +48 -0
  18. data/lib/jekyll-relationships/documents/registry.rb +136 -0
  19. data/lib/jekyll-relationships/engine/raw_path_state.rb +217 -0
  20. data/lib/jekyll-relationships/engine/relationship_state.rb +251 -0
  21. data/lib/jekyll-relationships/engine/session.rb +187 -0
  22. data/lib/jekyll-relationships/engine/write_back.rb +154 -0
  23. data/lib/jekyll-relationships/engine.rb +228 -0
  24. data/lib/jekyll-relationships/errors.rb +30 -0
  25. data/lib/jekyll-relationships/generators/relationships.rb +27 -0
  26. data/lib/jekyll-relationships/pruning/normal_graph.rb +119 -0
  27. data/lib/jekyll-relationships/pruning/rule_pruner.rb +67 -0
  28. data/lib/jekyll-relationships/pruning/tree_phase.rb +351 -0
  29. data/lib/jekyll-relationships/pruning/tree_provenance.rb +32 -0
  30. data/lib/jekyll-relationships/references/accumulator.rb +185 -0
  31. data/lib/jekyll-relationships/references/template.rb +222 -0
  32. data/lib/jekyll-relationships/resolvers/base.rb +207 -0
  33. data/lib/jekyll-relationships/run_logger.rb +101 -0
  34. data/lib/jekyll-relationships/support/data_path.rb +194 -0
  35. data/lib/jekyll-relationships/support/frontmatter_path.rb +217 -0
  36. data/lib/jekyll-relationships/support/hash_deep_merge.rb +63 -0
  37. data/lib/jekyll-relationships/support/placeholders.rb +21 -0
  38. data/lib/jekyll-relationships/support/string_array.rb +157 -0
  39. data/lib/jekyll-relationships/trees/edge_builder.rb +196 -0
  40. data/lib/jekyll-relationships/trees/graph.rb +454 -0
  41. data/lib/jekyll-relationships/version.rb +11 -0
  42. data/lib/jekyll-relationships.rb +34 -0
  43. data/readme.md +509 -0
  44. metadata +189 -0
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+ module Relationships
6
+
7
+ module Support
8
+
9
+ # Reads and writes dot-separated frontmatter paths on plain nested hashes.
10
+ #
11
+ # This wrapper keeps all path mutation logic in one place so the rest of the
12
+ # engine can stay focused on relationship behaviour rather than hash surgery.
13
+ class DataPath
14
+ # Captures one concrete leaf reached by a dotted frontmatter path.
15
+ #
16
+ # Each match remembers the exact container and key so callers can update the
17
+ # original structure surgically without reshaping nearby hashes or arrays.
18
+ class Match
19
+ attr_reader :parent, :key, :value
20
+
21
+ # Builds one concrete leaf match.
22
+ def initialize(parent:, key:, value:)
23
+ @parent = parent
24
+ @key = key
25
+ @value = value
26
+ end
27
+
28
+ # Replaces the matched leaf in place.
29
+ def write(value)
30
+ @parent[@key] = value
31
+ @value = value
32
+ end
33
+ end
34
+
35
+ # Reports the raw value gathered from a path together with its concrete
36
+ # source leaves.
37
+ #
38
+ # `array_traversed?` distinguishes a simple hash path from one that expanded
39
+ # through array items and therefore cannot safely receive one consolidated
40
+ # write-back value.
41
+ class ReadResult
42
+ attr_reader :matches
43
+
44
+ # Builds one read result from the collected concrete matches.
45
+ def initialize(matches:, array_traversed:)
46
+ @matches = matches
47
+ @array_traversed = array_traversed
48
+ end
49
+
50
+ # Reassembles the public read value from the concrete matches.
51
+ def value
52
+ values = @matches.map(&:value)
53
+ return nil if values.empty?
54
+ return values.first if values.length == 1
55
+
56
+ values
57
+ end
58
+
59
+ # Returns true when the path resolved to at least one concrete leaf.
60
+ def present?
61
+ !@matches.empty?
62
+ end
63
+
64
+ # Returns true when path traversal expanded through an array.
65
+ def array_traversed?
66
+ @array_traversed
67
+ end
68
+ end
69
+
70
+ TraversalNode = Struct.new(:parent, :key, :value)
71
+
72
+ # Builds one reusable accessor.
73
+ def initialize
74
+ @reader = Jekyll::Plugins::Relationships::Support::FrontmatterPath.new
75
+ end
76
+
77
+ # Reads one value together with its concrete leaf matches.
78
+ def read_result(data, path)
79
+ return ReadResult.new(matches: [], array_traversed: false) if blank_path?(path)
80
+
81
+ matches, array_traversed = locate_matches(data, path)
82
+ ReadResult.new(matches: matches, array_traversed: array_traversed)
83
+ end
84
+
85
+ # Reads one value from a nested frontmatter hash.
86
+ def read(data, path)
87
+ read_result(data, path).value
88
+ end
89
+
90
+ # Writes one value to a nested frontmatter hash, creating hashes as needed.
91
+ #
92
+ # Existing non-hash ancestors are treated as errors so write-back never
93
+ # silently replaces arrays or scalar values with new hashes.
94
+ def write(data, path, value)
95
+ raise ArgumentError, 'Cannot write to a blank frontmatter path.' if blank_path?(path)
96
+
97
+ segments = Jekyll::Plugins::Relationships::Support::FrontmatterPath.split_path(path)
98
+ current_hash = data
99
+
100
+ segments[0..-2].each_with_index do |segment, segment_index|
101
+ next_hash = Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(current_hash, segment)
102
+ if next_hash.nil?
103
+ next_hash = {}
104
+ current_hash[segment] = next_hash
105
+ elsif !next_hash.is_a?(Hash)
106
+ failing_path = segments.first(segment_index + 1).join(@reader.separator.to_s)
107
+ raise ResolutionError, "Cannot write frontmatter path `#{path}` because `#{failing_path}` resolves to a #{next_hash.class}, not a hash."
108
+ end
109
+ current_hash = next_hash
110
+ end
111
+
112
+ current_hash[segments.last] = value
113
+ end
114
+
115
+ private
116
+
117
+ # Finds the concrete leaves currently matched by one path.
118
+ def locate_matches(data, path)
119
+ return [[], false] unless data.is_a?(Hash)
120
+
121
+ segments = Jekyll::Plugins::Relationships::Support::FrontmatterPath.split_path(path, @reader.separator)
122
+ return [[], false] if segments.empty?
123
+
124
+ nodes = [TraversalNode.new(nil, nil, data)]
125
+ array_traversed = false
126
+
127
+ segments.each_with_index do |_, segment_index|
128
+ requested_key_path = segments.first(segment_index + 1).join(@reader.separator.to_s)
129
+ next_nodes = []
130
+ last_segment = segment_index == segments.length - 1
131
+
132
+ nodes.each do |node|
133
+ if node.value.is_a?(Array)
134
+ array_traversed = true
135
+ next_nodes.concat(descend_array(node.value))
136
+ next
137
+ end
138
+ next unless node.value.is_a?(Hash)
139
+
140
+ resolved_key = Jekyll::Plugins::Relationships::Support::FrontmatterPath.resolve_hash_key(
141
+ node.value,
142
+ requested_key_path,
143
+ @reader.equivalent_lookup,
144
+ separator: @reader.separator
145
+ )
146
+ next if resolved_key.nil?
147
+
148
+ child_value = Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(node.value, resolved_key)
149
+ next if child_value.nil?
150
+
151
+ if child_value.is_a?(Array) && !last_segment
152
+ array_traversed = true
153
+ next_nodes.concat(descend_array(child_value))
154
+ next
155
+ end
156
+
157
+ next_nodes << TraversalNode.new(node.value, resolved_key, child_value)
158
+ end
159
+
160
+ nodes = next_nodes
161
+ break if nodes.empty?
162
+ end
163
+
164
+ [
165
+ nodes.map { |node| Match.new(parent: node.parent, key: node.key, value: node.value) },
166
+ array_traversed
167
+ ]
168
+ end
169
+
170
+ # Applies the configured array-expansion rule while keeping exact indices.
171
+ def descend_array(array)
172
+ case @reader.arrays
173
+ when :first
174
+ return [] if array.empty?
175
+
176
+ [TraversalNode.new(array, 0, array.first)]
177
+ else
178
+ array.each_with_index.map do |entry, index|
179
+ TraversalNode.new(array, index, entry)
180
+ end
181
+ end
182
+ end
183
+
184
+ # Treats nil and empty strings as unset paths.
185
+ def blank_path?(path)
186
+ path.nil? || path.to_s.strip.empty?
187
+ end
188
+ end
189
+
190
+ end
191
+
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+ module Relationships
6
+
7
+ module Support
8
+
9
+ # Traverses frontmatter-style nested hashes using configurable path,
10
+ # array, and equivalent-key rules.
11
+ #
12
+ # Use one instance per syntax configuration, then call `with` for scoped
13
+ # variations such as template-level separator overrides.
14
+ class FrontmatterPath
15
+
16
+ DEFAULT_SEPARATOR = '.'
17
+ DEFAULT_ARRAYS = :expand
18
+ UNSET = Object.new.freeze
19
+
20
+ attr_reader :separator, :arrays, :equivalent_lookup
21
+
22
+ # Splits one configured path into non-blank segments.
23
+ def self.split_path(path, separator = DEFAULT_SEPARATOR)
24
+ path.to_s.split(separator.to_s).map(&:strip).reject(&:empty?)
25
+ end
26
+
27
+ # Normalises equivalent-key definitions into a path-aware lookup table.
28
+ #
29
+ # Keys are stored against their full requested path so aliases can be
30
+ # scoped to one nested level only.
31
+ def self.build_equivalent_lookup(raw_equivalents, split_delimiter: StringArray::DEFAULT_DELIMITER)
32
+ return {} if raw_equivalents == false || raw_equivalents.nil?
33
+
34
+ string_array = Jekyll::Plugins::Relationships::Support::StringArray.new(delimiter: split_delimiter)
35
+ lookup = {}
36
+ groups = raw_equivalents.is_a?(Array) ? raw_equivalents : [raw_equivalents]
37
+
38
+ groups.each do |group|
39
+ keys = if group.is_a?(Array)
40
+ group.flat_map { |entry| string_array.interpret(entry, split: -1, flatten: true) }
41
+ else
42
+ string_array.interpret(group, split: -1, flatten: true)
43
+ end
44
+ keys = keys.map { |entry| entry.to_s.strip }.reject(&:empty?).uniq
45
+ next if keys.length < 2
46
+
47
+ keys.each { |key| lookup[key] = keys }
48
+ end
49
+
50
+ lookup
51
+ end
52
+
53
+ # Reads a hash entry using exact, string, or symbol lookup.
54
+ def self.read_hash(hash, key)
55
+ return hash[key] if hash.key?(key)
56
+
57
+ string_key = key.to_s
58
+ return hash[string_key] if hash.key?(string_key)
59
+
60
+ symbol_key = string_key.to_sym
61
+ return hash[symbol_key] if hash.key?(symbol_key)
62
+
63
+ nil
64
+ end
65
+
66
+ # Resolves the effective key present in one hash for the requested
67
+ # path-so-far.
68
+ def self.resolve_hash_key(hash, requested_key_path, equivalent_lookup, separator: DEFAULT_SEPARATOR)
69
+ string_key_path = requested_key_path.to_s.strip
70
+ return nil if string_key_path.empty?
71
+
72
+ group = equivalent_lookup[string_key_path] || [string_key_path]
73
+ candidate_segments = group.map { |candidate_path| split_path(candidate_path, separator).last }.reject(&:empty?).uniq
74
+
75
+ candidate_segments.reverse_each do |candidate|
76
+ return candidate if hash.key?(candidate)
77
+
78
+ symbol_candidate = candidate.to_sym
79
+ return symbol_candidate if hash.key?(symbol_candidate)
80
+ end
81
+
82
+ nil
83
+ end
84
+
85
+ # Builds a frontmatter-path helper for one default configuration.
86
+ def initialize(separator: DEFAULT_SEPARATOR, arrays: DEFAULT_ARRAYS, equivalents: nil, equivalent_lookup: UNSET)
87
+ @separator = normalise_separator(separator)
88
+ @arrays = normalise_array_mode(arrays)
89
+ @equivalent_lookup = equivalent_lookup.equal?(UNSET) ? self.class.build_equivalent_lookup(equivalents) : normalise_equivalent_lookup(equivalent_lookup)
90
+ end
91
+
92
+ # Builds a clone with selected configuration overrides.
93
+ def with(separator: UNSET, arrays: UNSET, equivalents: UNSET, equivalent_lookup: UNSET)
94
+ resolved_separator = separator.equal?(UNSET) ? @separator : separator
95
+ resolved_arrays = arrays.equal?(UNSET) ? @arrays : arrays
96
+
97
+ if !equivalent_lookup.equal?(UNSET)
98
+ return self.class.new(
99
+ separator: resolved_separator,
100
+ arrays: resolved_arrays,
101
+ equivalent_lookup: equivalent_lookup
102
+ )
103
+ end
104
+
105
+ if !equivalents.equal?(UNSET)
106
+ return self.class.new(
107
+ separator: resolved_separator,
108
+ arrays: resolved_arrays,
109
+ equivalents: equivalents
110
+ )
111
+ end
112
+
113
+ self.class.new(
114
+ separator: resolved_separator,
115
+ arrays: resolved_arrays,
116
+ equivalent_lookup: @equivalent_lookup
117
+ )
118
+ end
119
+
120
+ # Traverses one data structure and returns:
121
+ # - `nil` when the path is missing
122
+ # - one raw terminal value when one match is found
123
+ # - an array of raw terminal values when many matches are found
124
+ def traverse(data, path, separator: UNSET, arrays: UNSET, equivalents: UNSET, equivalent_lookup: UNSET)
125
+ active_separator = separator.equal?(UNSET) ? @separator : normalise_separator(separator)
126
+ active_arrays = arrays.equal?(UNSET) ? @arrays : normalise_array_mode(arrays)
127
+ active_lookup = resolve_lookup(equivalents, equivalent_lookup)
128
+
129
+ traverse_internal(data, path, separator: active_separator, arrays: active_arrays, equivalent_lookup: active_lookup)
130
+ end
131
+
132
+ private
133
+
134
+ # Walks the object graph using the effective traversal configuration.
135
+ def traverse_internal(data, path, separator:, arrays:, equivalent_lookup:)
136
+ return nil unless data.is_a?(Hash)
137
+
138
+ segments = self.class.split_path(path, separator)
139
+ return nil if segments.empty?
140
+
141
+ nodes = [data]
142
+ segments.each_with_index do |_, segment_index|
143
+ next_nodes = []
144
+ requested_key_path = segments.first(segment_index + 1).join(separator.to_s)
145
+
146
+ nodes.each do |node|
147
+ if node.is_a?(Array)
148
+ next_nodes.concat(descend_array(node, arrays))
149
+ next
150
+ end
151
+ next unless node.is_a?(Hash)
152
+
153
+ resolved_key = self.class.resolve_hash_key(node, requested_key_path, equivalent_lookup, separator: separator)
154
+ next if resolved_key.nil?
155
+
156
+ next_nodes << self.class.read_hash(node, resolved_key)
157
+ end
158
+
159
+ nodes = if segment_index == segments.length - 1
160
+ next_nodes.compact
161
+ else
162
+ next_nodes.flatten(1).compact
163
+ end
164
+ break if nodes.empty?
165
+ end
166
+
167
+ return nil if nodes.empty?
168
+ return nodes.first if nodes.length == 1
169
+
170
+ nodes
171
+ end
172
+
173
+ # Applies the configured array traversal mode to one intermediate array.
174
+ def descend_array(array, arrays)
175
+ case arrays
176
+ when :first
177
+ array.empty? ? [] : [array.first]
178
+ else
179
+ array
180
+ end
181
+ end
182
+
183
+ # Normalises one separator override.
184
+ def normalise_separator(raw_separator)
185
+ separator = raw_separator.to_s
186
+ separator.empty? ? DEFAULT_SEPARATOR : separator
187
+ end
188
+
189
+ # Normalises one configured array traversal mode.
190
+ def normalise_array_mode(raw_arrays)
191
+ mode = raw_arrays.to_s.strip.downcase
192
+ mode = DEFAULT_ARRAYS.to_s if mode.empty?
193
+
194
+ return :expand if %w[expand all].include?(mode)
195
+ return :first if mode == 'first'
196
+
197
+ raise ArgumentError, "Unsupported array traversal mode '#{raw_arrays}'."
198
+ end
199
+
200
+ # Accepts only hash lookups for prebuilt equivalent-key data.
201
+ def normalise_equivalent_lookup(raw_lookup)
202
+ raw_lookup.is_a?(Hash) ? raw_lookup : {}
203
+ end
204
+
205
+ # Resolves one optional lookup override against the instance default.
206
+ def resolve_lookup(raw_equivalents, raw_equivalent_lookup)
207
+ return normalise_equivalent_lookup(raw_equivalent_lookup) unless raw_equivalent_lookup.equal?(UNSET)
208
+ return self.class.build_equivalent_lookup(raw_equivalents) unless raw_equivalents.equal?(UNSET)
209
+
210
+ @equivalent_lookup
211
+ end
212
+ end
213
+
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+ module Relationships
6
+
7
+ module Support
8
+ module_function
9
+
10
+ # Deep-merges two hashes without mutating either input.
11
+ #
12
+ # Values from `right_hash` win over values from `left_hash`. Nested hashes are
13
+ # merged recursively, while all other values are treated as leaf replacements.
14
+ def hash_deep_merge(left_hash, right_hash)
15
+ left = stringify_hash(left_hash)
16
+ right = stringify_hash(right_hash)
17
+
18
+ left.each_with_object({}) do |(key, value), merged|
19
+ right_value = fetch_hash_value(right, key)
20
+
21
+ merged[key] = if value.is_a?(Hash) && right_value.is_a?(Hash)
22
+ hash_deep_merge(value, right_value)
23
+ elsif hash_key?(right, key)
24
+ right_value
25
+ else
26
+ value
27
+ end
28
+ end.tap do |merged|
29
+ right.each do |key, value|
30
+ next if merged.key?(key)
31
+
32
+ merged[key] = value
33
+ end
34
+ end
35
+ end
36
+
37
+ # Returns true when a string- or symbol-keyed hash contains the given key.
38
+ def hash_key?(hash, key)
39
+ return false unless hash.is_a?(Hash)
40
+
41
+ hash.key?(key) || hash.key?(key.to_s) || hash.key?(key.to_sym)
42
+ end
43
+
44
+ # Reads one value from a string- or symbol-keyed hash.
45
+ def fetch_hash_value(hash, key)
46
+ return nil unless hash.is_a?(Hash)
47
+
48
+ Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(hash, key)
49
+ end
50
+
51
+ # Returns one shallow string-keyed copy of a hash.
52
+ def stringify_hash(hash)
53
+ return {} unless hash.is_a?(Hash)
54
+
55
+ hash.each_with_object({}) do |(key, value), stringified|
56
+ stringified[key.to_s] = value
57
+ end
58
+ end
59
+
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+ module Relationships
6
+
7
+ module Support
8
+
9
+ # Stores the literal placeholder tokens used in relationship configuration.
10
+ module Placeholders
11
+ KEY = '<key>'.freeze
12
+ COLLECTION = '<collection>'.freeze
13
+ PAGE = '<page>'.freeze
14
+ COUNT = '<count>'.freeze
15
+ end
16
+
17
+ end
18
+
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+ module Relationships
6
+
7
+ module Support
8
+
9
+ # Interprets loose config/frontmatter values as arrays.
10
+ #
11
+ # Use one instance per delimiter configuration, then call `with` to derive
12
+ # a nearby variant without restating the full configuration.
13
+ class StringArray
14
+
15
+ DEFAULT_DELIMITER = ','
16
+ UNSET = Object.new.freeze
17
+
18
+ attr_reader :delimiter
19
+
20
+ # Builds a string-array helper for one default delimiter.
21
+ def initialize(delimiter: DEFAULT_DELIMITER)
22
+ @delimiter = self.class.normalise_delimiter(delimiter, DEFAULT_DELIMITER)
23
+ end
24
+
25
+ # Builds a clone with selected configuration overrides.
26
+ def with(delimiter: UNSET)
27
+ self.class.new(
28
+ delimiter: delimiter.equal?(UNSET) ? @delimiter : delimiter
29
+ )
30
+ end
31
+
32
+ # Normalises one configured delimiter.
33
+ #
34
+ # Blank or unsupported values fall back to `default_delimiter`.
35
+ # `false` disables string splitting.
36
+ def self.normalise_delimiter(raw_delimiter, default_delimiter = DEFAULT_DELIMITER)
37
+ return false if raw_delimiter == false
38
+ return default_delimiter unless raw_delimiter.is_a?(String)
39
+
40
+ delimiter = raw_delimiter
41
+ return false if delimiter.strip.downcase == 'false'
42
+
43
+ delimiter.empty? ? default_delimiter : delimiter
44
+ end
45
+
46
+ # Interprets a scalar or array as an array with configurable string
47
+ # splitting depth.
48
+ #
49
+ # `split` modes:
50
+ # - `true` / `0`: split only a top-level string
51
+ # - `false`: never split strings, only wrap
52
+ # - positive integer: split strings found at that array depth
53
+ # - `-1`: split strings at any depth
54
+ #
55
+ # `flatten: true` promotes split values into the surrounding array.
56
+ # `flatten: false` keeps split values nested at the point they arose.
57
+ def interpret(value, split: true, flatten: false, delimiter: UNSET)
58
+ active_delimiter = effective_delimiter(delimiter)
59
+ active_split_depth = normalise_split_depth(split)
60
+ interpret_value(value, split_depth: active_split_depth, flatten: !!flatten, current_level: 0, delimiter: active_delimiter)
61
+ end
62
+
63
+ private
64
+
65
+ # Interprets one value at one traversal depth.
66
+ def interpret_value(value, split_depth:, flatten:, current_level:, delimiter:)
67
+ return [] if value.nil?
68
+ return interpret_array(value, split_depth: split_depth, flatten: flatten, current_level: current_level, delimiter: delimiter) if value.is_a?(Array)
69
+ return interpret_string(value, split_depth: split_depth, current_level: current_level, delimiter: delimiter) if value.is_a?(String)
70
+
71
+ [value]
72
+ end
73
+
74
+ # Interprets one array and recursively flattens nested array structure
75
+ # while respecting string-splitting depth.
76
+ def interpret_array(array, split_depth:, flatten:, current_level:, delimiter:)
77
+ array.flatten(1).compact.each_with_object([]) do |entry, interpreted|
78
+ if entry.is_a?(Array)
79
+ interpreted.concat(
80
+ interpret_array(
81
+ entry,
82
+ split_depth: split_depth,
83
+ flatten: flatten,
84
+ current_level: current_level + 1,
85
+ delimiter: delimiter
86
+ )
87
+ )
88
+ next
89
+ end
90
+
91
+ if entry.is_a?(String)
92
+ string_value = interpret_string(entry, split_depth: split_depth, current_level: current_level + 1, delimiter: delimiter)
93
+ if should_split_string?(split_depth, current_level + 1) && !flatten && string_value.length > 1
94
+ interpreted << string_value
95
+ else
96
+ interpreted.concat(string_value)
97
+ end
98
+ next
99
+ end
100
+
101
+ interpreted << entry
102
+ end
103
+ end
104
+
105
+ # Interprets one string according to the configured split depth.
106
+ def interpret_string(value, split_depth:, current_level:, delimiter:)
107
+ return [value] unless should_split_string?(split_depth, current_level)
108
+
109
+ split_string(value, delimiter)
110
+ end
111
+
112
+ # Splits one string with the active delimiter, trimming and discarding
113
+ # blank entries.
114
+ def split_string(value, delimiter)
115
+ if delimiter == false
116
+ entry = value.to_s.strip
117
+ return [] if entry.empty?
118
+
119
+ return [entry]
120
+ end
121
+
122
+ split_pattern = Regexp.new(Regexp.escape(delimiter.to_s))
123
+ value.to_s.split(split_pattern, -1).map(&:strip).reject(&:empty?)
124
+ end
125
+
126
+ # Resolves whether strings should be split at the current array depth.
127
+ def should_split_string?(split_depth, current_level)
128
+ return false if split_depth == false
129
+ return true if split_depth == -1
130
+
131
+ current_level == split_depth
132
+ end
133
+
134
+ # Normalises the configured split depth.
135
+ def normalise_split_depth(raw_split)
136
+ return false if raw_split == false
137
+ return 0 if raw_split == true
138
+ return raw_split if raw_split.is_a?(Integer)
139
+
140
+ return raw_split.to_i if raw_split.is_a?(String) && raw_split.strip.match?(/\A-?\d+\z/)
141
+
142
+ raise ArgumentError, "Unsupported string-array split depth '#{raw_split}'."
143
+ end
144
+
145
+ # Resolves one optional delimiter override against the instance
146
+ # default.
147
+ def effective_delimiter(raw_override)
148
+ return @delimiter if raw_override.equal?(UNSET)
149
+
150
+ self.class.normalise_delimiter(raw_override, @delimiter)
151
+ end
152
+ end
153
+
154
+ end
155
+ end
156
+ end
157
+ end