asciisourcerer 0.1.0 → 0.2.1

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.
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourcerer
4
+ module Sync
5
+ # Parses tagged regions from any text file, regardless of comment style
6
+ #
7
+ # Recognizes AsciiDoc `tag::`/`end::` markers in HTML comments, AsciiDoc line comments,
8
+ # and shell/Ruby/YAML comments.
9
+ # The trailing `[]` is optional.
10
+ # See the project README for the full tag-syntax reference.
11
+ module BlockParser
12
+ # A tagged region extracted from a file
13
+ #
14
+ # @!attribute tag [String] The tag name (e.g. `universal-agency`)
15
+ # @!attribute open_line [String] The complete opening marker line, including newline
16
+ # @!attribute content [String] Everything between the open and close markers
17
+ # @!attribute close_line [String] The complete closing marker line, including newline
18
+ Block = Struct.new(:tag, :open_line, :content, :close_line, keyword_init: true)
19
+
20
+ # Plain text in between (or around) tagged blocks
21
+ #
22
+ # @!attribute content [String] The raw text
23
+ TextSegment = Struct.new(:content, keyword_init: true)
24
+
25
+ # Raised when tag markers are structurally invalid
26
+ class ParseError < StandardError
27
+ end
28
+
29
+ # Default prefix that marks a block as canonical (managed by Sync/Cast).
30
+ DEFAULT_CANONICAL_PREFIX = 'universal-'
31
+
32
+ # Default opening tag marker template.
33
+ # `<tagged_block_name>` is the placeholder for the block name character class.
34
+ # A trailing `[]` is treated as optional in the compiled pattern.
35
+ DEFAULT_TAG_SYNTAX_START = 'tag::<tagged_block_name>[]'
36
+
37
+ # Default closing tag marker template.
38
+ DEFAULT_TAG_SYNTAX_END = 'end::<tagged_block_name>[]'
39
+
40
+ # Default comment-wrapper templates.
41
+ # `<tag_syntax>` is the placeholder for the compiled tag marker pattern.
42
+ # A space between the comment delimiter and `<tag_syntax>` compiles as `\s*`.
43
+ DEFAULT_COMMENT_SYNTAX_PATTERNS = [
44
+ '<!-- <tag_syntax> -->',
45
+ '// <tag_syntax>',
46
+ '# <tag_syntax>'
47
+ ].freeze
48
+
49
+ # Compile a tag marker template string into a plain regex fragment (no `\A` anchor).
50
+ #
51
+ # `<tagged_block_name>` is replaced with the `(?<tag>[\w-]+)` named capture group.
52
+ # A trailing `[]` in the template becomes `(?:\[\])?` (optional literal brackets).
53
+ #
54
+ # @param template [String] e.g. `'tag::<tagged_block_name>[]'`
55
+ # @return [String] regex source string
56
+ def self.tag_template_to_inner_regex template
57
+ parts = template.split('<tagged_block_name>', 2)
58
+ left = Regexp.escape(parts[0])
59
+ right = parts[1].to_s
60
+ suffix = right == '[]' ? '(?:\[\])?' : Regexp.escape(right)
61
+ "#{left}(?<tag>[\\w-]+)#{suffix}"
62
+ end
63
+
64
+ # Wrap a compiled inner-tag regex fragment with a comment-wrapper template.
65
+ #
66
+ # `<tag_syntax>` in `comment_template` is replaced by `inner_regex`.
67
+ # Adjacent literal spaces around `<tag_syntax>` are compiled as `\s*`.
68
+ # The result is anchored to `\A`.
69
+ #
70
+ # @param comment_template [String] e.g. `'<!-- <tag_syntax> -->'`
71
+ # @param inner_regex [String] regex source from {.tag_template_to_inner_regex}
72
+ # @return [String] full anchored regex source string
73
+ def self.comment_template_to_full_regex comment_template, inner_regex
74
+ halves = comment_template.split('<tag_syntax>', 2)
75
+ left_raw = halves[0]
76
+ right_raw = halves[1].to_s
77
+ left_trim = left_raw.rstrip
78
+ right_trim = right_raw.lstrip
79
+ left_re = Regexp.escape(left_trim) + (left_trim == left_raw ? '' : '\s*')
80
+ right_re = (right_trim == right_raw ? '' : '\s*') + Regexp.escape(right_trim)
81
+ "\\A#{left_re}#{inner_regex}#{right_re}"
82
+ end
83
+
84
+ # Compile template strings into a patterns array compatible with {.parse}.
85
+ #
86
+ # Each entry in the returned array is a `{open: Regexp, close: Regexp}` hash.
87
+ # This is the same shape as {DEFAULT_TAG_PATTERNS} and may be passed directly
88
+ # to {.parse} via the `tag_patterns:` keyword to avoid recompilation per call.
89
+ #
90
+ # @param tag_start [String] opening tag template (default {DEFAULT_TAG_SYNTAX_START})
91
+ # @param tag_end [String] closing tag template (default {DEFAULT_TAG_SYNTAX_END})
92
+ # @param comment_patterns [Array<String>] comment-wrapper templates
93
+ # (default {DEFAULT_COMMENT_SYNTAX_PATTERNS})
94
+ # @return [Array<Hash>]
95
+ def self.build_tag_patterns tag_start, tag_end, comment_patterns
96
+ open_inner = tag_template_to_inner_regex(tag_start)
97
+ close_inner = tag_template_to_inner_regex(tag_end)
98
+ comment_patterns.map do |cp|
99
+ {
100
+ open: Regexp.new(comment_template_to_full_regex(cp, open_inner)),
101
+ close: Regexp.new(comment_template_to_full_regex(cp, close_inner))
102
+ }
103
+ end
104
+ end
105
+
106
+ # Default compiled pattern set, built from the three DEFAULT_* template constants.
107
+ # Retained for backward compatibility; prefer the template constants for customisation.
108
+ DEFAULT_TAG_PATTERNS = build_tag_patterns(
109
+ DEFAULT_TAG_SYNTAX_START,
110
+ DEFAULT_TAG_SYNTAX_END,
111
+ DEFAULT_COMMENT_SYNTAX_PATTERNS).freeze
112
+
113
+ # Backward-compatible alias for {DEFAULT_TAG_PATTERNS}.
114
+ TAG_PATTERNS = DEFAULT_TAG_PATTERNS
115
+
116
+ # Parse a text string into an array of {TextSegment} and {Block} objects.
117
+ #
118
+ # The result is ordered and reconstructable: joining every element's
119
+ # serialized form reproduces the original text character-perfectly.
120
+ #
121
+ # Only blocks whose tag name starts with `canonical_prefix` are parsed as
122
+ # proper {Block} objects; all other tag markers (open and close) are
123
+ # treated as ordinary text.
124
+ # This makes the parser robust against files that use tag markers for unrelated
125
+ # purposes (e.g. AsciiDoc `include::` target regions or non-canonical project sections)
126
+ # regardless of whether those regions are properly closed or even nested.
127
+ #
128
+ # When a canonical block is open, every line is treated as content until
129
+ # the matching close marker appears (including any inner tag markers).
130
+ # Canonical blocks therefore cannot be nested.
131
+ #
132
+ # @param text [String] Full text of the file to parse
133
+ # @param canonical_prefix [String] Only tags starting with this prefix
134
+ # are parsed as managed {Block} objects (default {DEFAULT_CANONICAL_PREFIX}).
135
+ # @param tag_syntax_start [String] Opening tag template; used to build
136
+ # patterns when `tag_patterns:` is not given (default {DEFAULT_TAG_SYNTAX_START}).
137
+ # @param tag_syntax_end [String] Closing tag template (default {DEFAULT_TAG_SYNTAX_END}).
138
+ # @param comment_syntax_patterns [Array<String>] Comment-wrapper templates
139
+ # (default {DEFAULT_COMMENT_SYNTAX_PATTERNS}).
140
+ # @param tag_patterns [Array<Hash>, nil] Pre-compiled pattern set; skips template
141
+ # compilation when provided. Build once with {.build_tag_patterns} and reuse.
142
+ # @return [Array<TextSegment, Block>]
143
+ # @raise [ParseError] if a canonical tag is opened but never closed.
144
+ def self.parse text,
145
+ canonical_prefix: DEFAULT_CANONICAL_PREFIX,
146
+ tag_syntax_start: DEFAULT_TAG_SYNTAX_START,
147
+ tag_syntax_end: DEFAULT_TAG_SYNTAX_END,
148
+ comment_syntax_patterns: DEFAULT_COMMENT_SYNTAX_PATTERNS,
149
+ tag_patterns: nil
150
+ patterns = tag_patterns ||
151
+ build_tag_patterns(tag_syntax_start, tag_syntax_end, comment_syntax_patterns)
152
+ lines = text.lines
153
+ segments = []
154
+ text_acc = []
155
+ block_state = nil # nil or { tag:, open_line:, content_lines: [] }
156
+
157
+ lines.each do |line|
158
+ stripped = line.chomp
159
+
160
+ if block_state.nil?
161
+ tag = detect_open_tag(stripped, patterns)
162
+ if tag&.start_with?(canonical_prefix)
163
+ segments << TextSegment.new(content: text_acc.join) unless text_acc.empty?
164
+ text_acc = []
165
+ block_state = { tag: tag, open_line: line, content_lines: [] }
166
+ else
167
+ # Non-canonical open tags and all close tags at the top level are
168
+ # treated as ordinary text.
169
+ text_acc << line
170
+ end
171
+ else
172
+ close_tag = detect_close_tag(stripped, patterns)
173
+ if close_tag == block_state[:tag]
174
+ segments << Block.new(
175
+ tag: block_state[:tag],
176
+ open_line: block_state[:open_line],
177
+ content: block_state[:content_lines].join,
178
+ close_line: line)
179
+ block_state = nil
180
+ else
181
+ # Nested open tags or mismatched close tags: treat as block content
182
+ block_state[:content_lines] << line
183
+ end
184
+ end
185
+ end
186
+
187
+ raise ParseError, "Unclosed canonical tag '#{block_state[:tag]}'" if block_state
188
+
189
+ segments << TextSegment.new(content: text_acc.join) unless text_acc.empty?
190
+ segments
191
+ end
192
+
193
+ # Return the tag name if `stripped_line` is an opening tag marker, else nil.
194
+ #
195
+ # @param stripped_line [String] A single line with the trailing newline removed
196
+ # @param patterns [Array<Hash>] compiled pattern set from {.build_tag_patterns}
197
+ # @return [String, nil]
198
+ def self.detect_open_tag stripped_line, patterns
199
+ patterns.each do |p|
200
+ m = stripped_line.match(p[:open])
201
+ return m[:tag] if m
202
+ end
203
+ nil
204
+ end
205
+
206
+ # Return the tag name if `stripped_line` is a closing tag marker, else nil.
207
+ #
208
+ # @param stripped_line [String] A single line with the trailing newline removed
209
+ # @param patterns [Array<Hash>] compiled pattern set from {.build_tag_patterns}
210
+ # @return [String, nil]
211
+ def self.detect_close_tag stripped_line, patterns
212
+ patterns.each do |p|
213
+ m = stripped_line.match(p[:close])
214
+ return m[:tag] if m
215
+ end
216
+ nil
217
+ end
218
+
219
+ # Extract all canonical blocks (those whose tag name starts with
220
+ # `canonical_prefix`) as a Hash keyed by tag name.
221
+ #
222
+ # Because {.parse} already filters for canonical blocks when given the
223
+ # same `canonical_prefix`, this method is largely a deduplication check.
224
+ # It raises {ParseError} if more than one canonical block carries the same
225
+ # tag name, which would make synchronization ambiguous.
226
+ #
227
+ # @param segments [Array<TextSegment, Block>]
228
+ # @param canonical_prefix [String] Prefix that identifies managed blocks
229
+ # @return [Hash{String => Block}]
230
+ def self.extract_canonical segments, canonical_prefix: DEFAULT_CANONICAL_PREFIX
231
+ result = {}
232
+ segments.each do |s|
233
+ next unless s.is_a?(Block) && s.tag.start_with?(canonical_prefix)
234
+
235
+ raise ParseError, "Duplicate canonical block '#{s.tag}'" if result.key?(s.tag)
236
+
237
+ result[s.tag] = s
238
+ end
239
+ result
240
+ end
241
+
242
+ private_class_method :detect_open_tag, :detect_close_tag
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative 'block_parser'
5
+
6
+ module Sourcerer
7
+ module Sync
8
+ # Synchronizes canonical blocks from a prime template into one target file.
9
+ #
10
+ # See {Sourcerer::Sync} for the high-level interface and the project README
11
+ # for usage examples and a full description of the Sync/Cast model.
12
+ class Cast
13
+ # Returned by both {.sync} and {.init}.
14
+ #
15
+ # @!attribute target_path [String] Absolute path of the target file.
16
+ # @!attribute applied_changes [Array<String>] Tag names whose block
17
+ # content was replaced (empty on a dry run even when differences exist).
18
+ # @!attribute warnings [Array<String>] Non-fatal diagnostic messages.
19
+ # @!attribute errors [Array<String>] Fatal messages; file was not written.
20
+ # @!attribute diff [String, nil] Unified diff output when differences were
21
+ # detected (populated on dry runs and when changes were applied).
22
+ CastResult = Struct.new(
23
+ :target_path,
24
+ :applied_changes,
25
+ :warnings,
26
+ :errors,
27
+ :diff,
28
+ keyword_init: true)
29
+
30
+ # Synchronize canonical blocks from `prime_path` into `target_path`.
31
+ #
32
+ # @param prime_path [String] Path to the prime template file
33
+ # @param target_path [String] Path to the target file
34
+ # @param data [Hash] Liquid variables used when rendering block content
35
+ # @param canonical_prefix [String] Tag prefix that marks managed blocks
36
+ # @param tag_syntax_start [String] Opening tag marker template
37
+ # (see {BlockParser::DEFAULT_TAG_SYNTAX_START})
38
+ # @param tag_syntax_end [String] Closing tag marker template
39
+ # (see {BlockParser::DEFAULT_TAG_SYNTAX_END})
40
+ # @param comment_syntax_patterns [Array<String>] Comment-wrapper templates
41
+ # (see {BlockParser::DEFAULT_COMMENT_SYNTAX_PATTERNS})
42
+ # @param dry_run [Boolean] When true, compute the diff but do not write
43
+ # @return [CastResult]
44
+ def self.sync prime_path, target_path,
45
+ data: {},
46
+ canonical_prefix: BlockParser::DEFAULT_CANONICAL_PREFIX,
47
+ tag_syntax_start: BlockParser::DEFAULT_TAG_SYNTAX_START,
48
+ tag_syntax_end: BlockParser::DEFAULT_TAG_SYNTAX_END,
49
+ comment_syntax_patterns: BlockParser::DEFAULT_COMMENT_SYNTAX_PATTERNS,
50
+ dry_run: false
51
+ new(
52
+ prime_path, target_path,
53
+ data: data,
54
+ canonical_prefix: canonical_prefix,
55
+ tag_syntax_start: tag_syntax_start,
56
+ tag_syntax_end: tag_syntax_end,
57
+ comment_syntax_patterns: comment_syntax_patterns,
58
+ dry_run: dry_run).run_sync
59
+ end
60
+
61
+ # Bootstrap a new target file from the prime template.
62
+ #
63
+ # During init the entire prime is rendered through Liquid before writing;
64
+ # during sync only canonical block content is rendered.
65
+ # See the project README for a full description of init vs sync semantics.
66
+ #
67
+ # @param prime_path [String] Path to the prime template file
68
+ # @param target_path [String] Path to the target file to create
69
+ # @param data [Hash] Liquid variables used when rendering
70
+ # @param dry_run [Boolean] When true, return rendered content in `diff`
71
+ # but do not write.
72
+ # @return [CastResult]
73
+ def self.init prime_path, target_path, data: {}, dry_run: false
74
+ prime_text = File.read(prime_path)
75
+ rendered = data.empty? ? prime_text : render_liquid_string(prime_text, data)
76
+
77
+ unless dry_run
78
+ FileUtils.mkdir_p(File.dirname(File.expand_path(target_path)))
79
+ File.write(target_path, rendered)
80
+ end
81
+
82
+ CastResult.new(
83
+ target_path: target_path,
84
+ applied_changes: [],
85
+ warnings: [],
86
+ errors: [],
87
+ diff: dry_run ? rendered : nil)
88
+ end
89
+
90
+ # @api private
91
+ def initialize prime_path, target_path,
92
+ data:, canonical_prefix:,
93
+ tag_syntax_start:, tag_syntax_end:, comment_syntax_patterns:,
94
+ dry_run:
95
+ @prime_path = prime_path
96
+ @target_path = target_path
97
+ @data = data
98
+ @canonical_prefix = canonical_prefix
99
+ @tag_syntax_start = tag_syntax_start
100
+ @dry_run = dry_run
101
+ @tag_patterns = BlockParser.build_tag_patterns(
102
+ tag_syntax_start, tag_syntax_end, comment_syntax_patterns)
103
+ end
104
+
105
+ # @api private
106
+ def run_sync
107
+ prime_text = File.read(@prime_path)
108
+ target_text = File.read(@target_path)
109
+
110
+ prime_segments = BlockParser.parse(
111
+ prime_text,
112
+ canonical_prefix: @canonical_prefix,
113
+ tag_patterns: @tag_patterns)
114
+ target_segments = BlockParser.parse(
115
+ target_text,
116
+ canonical_prefix: @canonical_prefix,
117
+ tag_patterns: @tag_patterns)
118
+
119
+ prime_blocks = BlockParser.extract_canonical(prime_segments, canonical_prefix: @canonical_prefix)
120
+ target_blocks, errors = validate_target_canonical(target_segments)
121
+
122
+ if errors.any?
123
+ return CastResult.new(
124
+ target_path: @target_path,
125
+ applied_changes: [],
126
+ warnings: [],
127
+ errors: errors,
128
+ diff: nil)
129
+ end
130
+
131
+ warnings = collect_warnings(prime_blocks, target_blocks, target_text)
132
+ new_segments, applied_changes = apply_prime_blocks(target_segments, prime_blocks)
133
+
134
+ new_text = reconstruct(new_segments)
135
+ diff = generate_diff(target_text, new_text) if applied_changes.any? || @dry_run
136
+
137
+ File.write(@target_path, new_text) unless @dry_run
138
+
139
+ CastResult.new(
140
+ target_path: @target_path,
141
+ applied_changes: @dry_run ? [] : applied_changes,
142
+ warnings: warnings,
143
+ errors: [],
144
+ diff: diff)
145
+ end
146
+
147
+ # @api private
148
+ def self.render_liquid_string content, data
149
+ require_relative '../jekyll'
150
+ require_relative '../jekyll/liquid/filters'
151
+ require_relative '../jekyll/liquid/tags'
152
+ require 'liquid' unless defined?(Liquid::Template)
153
+ Sourcerer::Jekyll.initialize_liquid_runtime
154
+
155
+ template = Liquid::Template.parse(content)
156
+ template.render(data.transform_keys(&:to_s))
157
+ end
158
+
159
+ private
160
+
161
+ # Collect canonical blocks from target, raising errors for duplicates.
162
+ # Returns [hash_of_canonical_blocks, errors_array].
163
+ def validate_target_canonical target_segments
164
+ seen = {}
165
+ errors = []
166
+ target_segments.each do |s|
167
+ next unless s.is_a?(BlockParser::Block) && canonical?(s.tag)
168
+
169
+ if seen.key?(s.tag)
170
+ errors << "Duplicate canonical block '#{s.tag}' in target file"
171
+ else
172
+ seen[s.tag] = s
173
+ end
174
+ end
175
+ [seen, errors]
176
+ end
177
+
178
+ def collect_warnings prime_blocks, target_blocks, target_text
179
+ warnings = []
180
+
181
+ prime_blocks.each_key do |tag|
182
+ next if target_blocks.key?(tag)
183
+ next if alternate_exists?(tag, target_text)
184
+
185
+ warnings << "Prime canonical block '#{tag}' not found in target"
186
+ end
187
+
188
+ target_blocks.each_key do |tag|
189
+ warnings << "Target canonical block '#{tag}' not found in prime" unless prime_blocks.key?(tag)
190
+ end
191
+
192
+ warnings
193
+ end
194
+
195
+ def apply_prime_blocks target_segments, prime_blocks
196
+ applied_changes = []
197
+
198
+ new_segments = target_segments.map do |segment|
199
+ next segment unless segment.is_a?(BlockParser::Block) && canonical?(segment.tag)
200
+ next segment unless prime_blocks.key?(segment.tag)
201
+
202
+ prime_content = prime_blocks[segment.tag].content
203
+ rendered_content = render_content(prime_content)
204
+
205
+ if rendered_content == segment.content
206
+ segment
207
+ else
208
+ applied_changes << segment.tag
209
+ BlockParser::Block.new(
210
+ tag: segment.tag,
211
+ open_line: segment.open_line,
212
+ content: rendered_content,
213
+ close_line: segment.close_line)
214
+ end
215
+ end
216
+
217
+ [new_segments, applied_changes]
218
+ end
219
+
220
+ def reconstruct segments
221
+ segments.map do |s|
222
+ case s
223
+ when BlockParser::Block
224
+ "#{s.open_line}#{s.content}#{s.close_line}"
225
+ when BlockParser::TextSegment
226
+ s.content
227
+ end
228
+ end.join
229
+ end
230
+
231
+ def canonical? tag
232
+ tag.start_with?(@canonical_prefix)
233
+ end
234
+
235
+ def alternate_exists? canonical_tag, target_text
236
+ # Scan the raw target text for any tag marker that shares the suffix of
237
+ # the canonical tag but uses a different (non-canonical) prefix.
238
+ # Ex: `local-agency` is an alternate for `universal-agency`.
239
+ suffix = canonical_tag.delete_prefix(@canonical_prefix)
240
+ inner = BlockParser.tag_template_to_inner_regex(@tag_syntax_start)
241
+ scan_pat = Regexp.new(inner.gsub('(?<tag>', '('))
242
+ target_text.scan(scan_pat).flatten.any? do |found_tag|
243
+ found_tag.end_with?(suffix) && !found_tag.start_with?(@canonical_prefix)
244
+ end
245
+ end
246
+
247
+ def render_content content
248
+ return content if @data.empty?
249
+
250
+ self.class.render_liquid_string(content, @data)
251
+ end
252
+
253
+ def generate_diff old_text, new_text
254
+ return nil if old_text == new_text
255
+
256
+ require 'open3'
257
+ require 'tempfile'
258
+
259
+ result = nil
260
+ Tempfile.open(['cast_old', '.txt']) do |old_f|
261
+ old_f.write(old_text)
262
+ old_f.flush
263
+ Tempfile.open(['cast_new', '.txt']) do |new_f|
264
+ new_f.write(new_text)
265
+ new_f.flush
266
+ stdout, = Open3.capture2('diff', '-u', old_f.path, new_f.path)
267
+ result = stdout
268
+ end
269
+ end
270
+ result
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'sync/block_parser'
4
+ require_relative 'sync/cast'
5
+
6
+ module Sourcerer
7
+ # Canonical block synchronization and Liquid rendering for flat text files.
8
+ #
9
+ # @see Sourcerer::Sync::Cast The main orchestrator class.
10
+ # @see Sourcerer::Sync::BlockParser The file-agnostic block parser.
11
+ # @see https://github.com/DocOps/asciisourcerer Sync/Cast documentation
12
+ module Sync
13
+ # Synchronise canonical blocks from `prime_path` into `target_path`.
14
+ #
15
+ # @param prime_path [String]
16
+ # @param target_path [String]
17
+ # @param options [Hash] Passed through to {Cast.sync}.
18
+ # @return [Cast::CastResult]
19
+ def self.sync(prime_path, target_path, **)
20
+ Cast.sync(prime_path, target_path, **)
21
+ end
22
+
23
+ # Bootstrap a brand-new target file from the prime template.
24
+ #
25
+ # @param prime_path [String]
26
+ # @param target_path [String]
27
+ # @param options [Hash] Passed through to {Cast.init}.
28
+ # @return [Cast::CastResult]
29
+ def self.init(prime_path, target_path, **)
30
+ Cast.init(prime_path, target_path, **)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourcerer
4
+ module Util
5
+ # Merge a user-supplied list into a default list using +/- amendment tokens.
6
+ #
7
+ # Not required internally; callers must require this file explicitly.
8
+ module ListAmend
9
+ # Apply a custom list on top of a default list.
10
+ #
11
+ # @param default_list [Array<#to_s>] the baseline list of items
12
+ # @param custom_list [nil, String, Array<#to_s>] the user-supplied overrides
13
+ # @param normalize [nil, #call] optional normalizer for deduplication comparisons (e.g. +:downcase+.to_proc)
14
+ # @return [Array<String>]
15
+ #
16
+ # Behavior:
17
+ # - +nil+ / empty custom ⇒ return stringified +default_list+
18
+ # - custom with no +/- tokens ⇒ fixed-list mode: return stringified custom
19
+ # - custom with any +/- token ⇒ amendment mode:
20
+ # -slug removes slug from working set (or no-op)
21
+ # +slug adds slug if not already present
22
+ # bare treated as +slug
23
+ def self.apply default_list, custom_list, normalize: nil
24
+ tokens = parse_tokens(custom_list)
25
+ return default_list.map(&:to_s) if tokens.empty?
26
+
27
+ amendment_mode = tokens.any? { |t| t.start_with?('+', '-') }
28
+ return tokens.map(&:to_s) unless amendment_mode
29
+
30
+ working = default_list.map(&:to_s)
31
+ norm = normalize || ->(s) { s }
32
+
33
+ # Apply removals first, then additions in token order.
34
+ tokens.each do |token|
35
+ if token.start_with?('-')
36
+ slug = token[1..]
37
+ working.reject! { |item| norm.call(item) == norm.call(slug) }
38
+ else
39
+ slug = token.start_with?('+') ? token[1..] : token
40
+ working << slug unless working.any? { |item| norm.call(item) == norm.call(slug) }
41
+ end
42
+ end
43
+
44
+ working
45
+ end
46
+
47
+ # Normalize a raw custom_list value into an array of non-empty token strings.
48
+ def self.parse_tokens raw
49
+ case raw
50
+ when nil
51
+ []
52
+ when Array
53
+ raw.map(&:to_s).reject(&:empty?)
54
+ when String
55
+ raw.split(/[\s,]+/).reject(&:empty?)
56
+ else
57
+ Array(raw).map(&:to_s).reject(&:empty?)
58
+ end
59
+ end
60
+ private_class_method :parse_tokens
61
+ end
62
+ end
63
+ end