ast-merge 1.0.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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +46 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/ast/merge/ast_node.rb +87 -0
- data/lib/ast/merge/comment/block.rb +195 -0
- data/lib/ast/merge/comment/empty.rb +78 -0
- data/lib/ast/merge/comment/line.rb +138 -0
- data/lib/ast/merge/comment/parser.rb +278 -0
- data/lib/ast/merge/comment/style.rb +282 -0
- data/lib/ast/merge/comment.rb +36 -0
- data/lib/ast/merge/conflict_resolver_base.rb +399 -0
- data/lib/ast/merge/debug_logger.rb +271 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
- data/lib/ast/merge/file_analyzable.rb +307 -0
- data/lib/ast/merge/freezable.rb +82 -0
- data/lib/ast/merge/freeze_node_base.rb +434 -0
- data/lib/ast/merge/match_refiner_base.rb +312 -0
- data/lib/ast/merge/match_score_base.rb +135 -0
- data/lib/ast/merge/merge_result_base.rb +169 -0
- data/lib/ast/merge/merger_config.rb +258 -0
- data/lib/ast/merge/node_typing.rb +373 -0
- data/lib/ast/merge/region.rb +124 -0
- data/lib/ast/merge/region_detector_base.rb +114 -0
- data/lib/ast/merge/region_mergeable.rb +364 -0
- data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
- data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
- data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
- data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
- data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
- data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
- data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
- data/lib/ast/merge/rspec/shared_examples.rb +26 -0
- data/lib/ast/merge/rspec.rb +4 -0
- data/lib/ast/merge/section_typing.rb +303 -0
- data/lib/ast/merge/smart_merger_base.rb +417 -0
- data/lib/ast/merge/text/conflict_resolver.rb +161 -0
- data/lib/ast/merge/text/file_analysis.rb +168 -0
- data/lib/ast/merge/text/line_node.rb +142 -0
- data/lib/ast/merge/text/merge_result.rb +42 -0
- data/lib/ast/merge/text/section.rb +93 -0
- data/lib/ast/merge/text/section_splitter.rb +397 -0
- data/lib/ast/merge/text/smart_merger.rb +141 -0
- data/lib/ast/merge/text/word_node.rb +86 -0
- data/lib/ast/merge/text.rb +35 -0
- data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
- data/lib/ast/merge/version.rb +12 -0
- data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
- data/lib/ast/merge.rb +165 -0
- data/lib/ast-merge.rb +4 -0
- data/sig/ast/merge.rbs +195 -0
- data.tar.gz.sig +0 -0
- metadata +326 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Represents a detected region within a document.
|
|
6
|
+
#
|
|
7
|
+
# Regions are portions of a document that can be handled by a specialized
|
|
8
|
+
# merger. For example, YAML frontmatter in a Markdown file, or a Ruby code
|
|
9
|
+
# block that should be merged using a Ruby-aware merger.
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a region for YAML frontmatter
|
|
12
|
+
# Region.new(
|
|
13
|
+
# type: :yaml_frontmatter,
|
|
14
|
+
# content: "title: My Doc\nversion: 1.0\n",
|
|
15
|
+
# start_line: 1,
|
|
16
|
+
# end_line: 4,
|
|
17
|
+
# delimiters: ["---", "---"],
|
|
18
|
+
# metadata: { format: :yaml }
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# @example Creating a region for a Ruby code block
|
|
22
|
+
# Region.new(
|
|
23
|
+
# type: :ruby_code_block,
|
|
24
|
+
# content: "def hello\n puts 'world'\nend\n",
|
|
25
|
+
# start_line: 5,
|
|
26
|
+
# end_line: 9,
|
|
27
|
+
# delimiters: ["```ruby", "```"],
|
|
28
|
+
# metadata: { language: "ruby" }
|
|
29
|
+
# )
|
|
30
|
+
#
|
|
31
|
+
# @api public
|
|
32
|
+
Region = Struct.new(
|
|
33
|
+
# @return [Symbol] The type of region (e.g., :yaml_frontmatter, :ruby_code_block)
|
|
34
|
+
:type,
|
|
35
|
+
|
|
36
|
+
# @return [String] The raw string content of this region (inner content, without delimiters)
|
|
37
|
+
:content,
|
|
38
|
+
|
|
39
|
+
# @return [Integer] 1-indexed start line in the original document
|
|
40
|
+
:start_line,
|
|
41
|
+
|
|
42
|
+
# @return [Integer] 1-indexed end line in the original document
|
|
43
|
+
:end_line,
|
|
44
|
+
|
|
45
|
+
# @return [Array<String>, nil] Delimiter strings to reconstruct the region
|
|
46
|
+
# ["```ruby", "```"] - [opening_delimiter, closing_delimiter]
|
|
47
|
+
:delimiters,
|
|
48
|
+
|
|
49
|
+
# @return [Hash, nil] Optional metadata for detector-specific information
|
|
50
|
+
# (e.g., { language: "ruby" }, { format: :yaml })
|
|
51
|
+
:metadata,
|
|
52
|
+
keyword_init: true,
|
|
53
|
+
) do
|
|
54
|
+
# Returns the line range covered by this region.
|
|
55
|
+
#
|
|
56
|
+
# @return [Range] The range from start_line to end_line (inclusive)
|
|
57
|
+
# @example
|
|
58
|
+
# region.line_range # => 1..4
|
|
59
|
+
def line_range
|
|
60
|
+
start_line..end_line
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns the number of lines this region spans.
|
|
64
|
+
#
|
|
65
|
+
# @return [Integer] The number of lines
|
|
66
|
+
# @example
|
|
67
|
+
# region.line_count # => 4
|
|
68
|
+
def line_count
|
|
69
|
+
end_line - start_line + 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Reconstructs the full region text including delimiters.
|
|
73
|
+
#
|
|
74
|
+
# @return [String] The complete region with start and end delimiters
|
|
75
|
+
# @example
|
|
76
|
+
# region.full_text
|
|
77
|
+
# # => "```ruby\ndef hello\n puts 'world'\nend\n```"
|
|
78
|
+
def full_text
|
|
79
|
+
return content if delimiters.nil? || delimiters.empty?
|
|
80
|
+
|
|
81
|
+
opening = delimiters[0] || ""
|
|
82
|
+
closing = delimiters[1] || ""
|
|
83
|
+
"#{opening}\n#{content}#{closing}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Checks if this region overlaps with the given line number.
|
|
87
|
+
#
|
|
88
|
+
# @param line [Integer] The line number to check (1-indexed)
|
|
89
|
+
# @return [Boolean] true if the line is within this region
|
|
90
|
+
def contains_line?(line)
|
|
91
|
+
line_range.cover?(line)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Checks if this region overlaps with another region.
|
|
95
|
+
#
|
|
96
|
+
# @param other [Region] Another region to check for overlap
|
|
97
|
+
# @return [Boolean] true if the regions overlap
|
|
98
|
+
def overlaps?(other)
|
|
99
|
+
line_range.cover?(other.start_line) ||
|
|
100
|
+
line_range.cover?(other.end_line) ||
|
|
101
|
+
other.line_range.cover?(start_line)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns a short string representation of the region.
|
|
105
|
+
#
|
|
106
|
+
# @return [String] A concise string describing the region
|
|
107
|
+
def to_s
|
|
108
|
+
"Region<#{type}:#{start_line}-#{end_line}>"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns a detailed human-readable representation of the region.
|
|
112
|
+
#
|
|
113
|
+
# @return [String] A string describing the region with truncated content
|
|
114
|
+
def inspect
|
|
115
|
+
truncated = if content && content.length > 30
|
|
116
|
+
"#{content[0, 30]}..."
|
|
117
|
+
else
|
|
118
|
+
content.inspect
|
|
119
|
+
end
|
|
120
|
+
"#{self} #{truncated}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Base class for region detection.
|
|
6
|
+
#
|
|
7
|
+
# Region detectors identify portions of a document that should be handled
|
|
8
|
+
# by a specialized merger. For example, detecting YAML frontmatter in a
|
|
9
|
+
# Markdown file, or Ruby code blocks that should be merged with Prism.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses must implement:
|
|
12
|
+
# - {#region_type} - Returns the type symbol for detected regions
|
|
13
|
+
# - {#detect_all} - Finds all regions of this type in a document
|
|
14
|
+
#
|
|
15
|
+
# @example Implementing a custom detector
|
|
16
|
+
# class MyBlockDetector < Ast::Merge::RegionDetectorBase
|
|
17
|
+
# def region_type
|
|
18
|
+
# :my_block
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# def detect_all(source)
|
|
22
|
+
# # Return array of Region structs
|
|
23
|
+
# []
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @abstract Subclass and implement {#region_type} and {#detect_all}
|
|
28
|
+
# @api public
|
|
29
|
+
class RegionDetectorBase
|
|
30
|
+
# Returns the type symbol for regions detected by this detector.
|
|
31
|
+
#
|
|
32
|
+
# This symbol is used to identify the region type in the Region struct
|
|
33
|
+
# and for matching regions between template and destination documents.
|
|
34
|
+
#
|
|
35
|
+
# @return [Symbol] The region type (e.g., :yaml_frontmatter, :ruby_code_block)
|
|
36
|
+
# @abstract Subclasses must implement this method
|
|
37
|
+
def region_type
|
|
38
|
+
raise NotImplementedError, "#{self.class}#region_type must be implemented"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Detects all regions of this type in the given source.
|
|
42
|
+
#
|
|
43
|
+
# @param source [String] The full document content to scan
|
|
44
|
+
# @return [Array<Region>] All detected regions, sorted by start_line
|
|
45
|
+
# @abstract Subclasses must implement this method
|
|
46
|
+
#
|
|
47
|
+
# @example Return value structure
|
|
48
|
+
# [
|
|
49
|
+
# Region.new(
|
|
50
|
+
# type: :yaml_frontmatter,
|
|
51
|
+
# content: "title: My Doc\n",
|
|
52
|
+
# start_line: 1,
|
|
53
|
+
# end_line: 3,
|
|
54
|
+
# delimiters: { start: "---\n", end: "---\n" },
|
|
55
|
+
# metadata: { format: :yaml }
|
|
56
|
+
# )
|
|
57
|
+
# ]
|
|
58
|
+
def detect_all(source)
|
|
59
|
+
raise NotImplementedError, "#{self.class}#detect_all must be implemented"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Whether to strip delimiters from content before passing to merger.
|
|
63
|
+
#
|
|
64
|
+
# When true (default), only the inner content is passed to the region's
|
|
65
|
+
# merger. The delimiters are stored separately and reattached after merging.
|
|
66
|
+
#
|
|
67
|
+
# When false, the full content including delimiters is passed to the merger,
|
|
68
|
+
# which must then handle the delimiters itself.
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean] true if delimiters should be stripped (default: true)
|
|
71
|
+
def strip_delimiters?
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A human-readable name for this detector.
|
|
76
|
+
#
|
|
77
|
+
# Used in error messages and debugging output.
|
|
78
|
+
#
|
|
79
|
+
# @return [String] The detector name
|
|
80
|
+
def name
|
|
81
|
+
self.class.name || "AnonymousDetector"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns a string representation of this detector.
|
|
85
|
+
#
|
|
86
|
+
# @return [String] A description of the detector
|
|
87
|
+
def inspect
|
|
88
|
+
"#<#{name} region_type=#{region_type}>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
protected
|
|
92
|
+
|
|
93
|
+
# Helper to build a Region struct with common defaults.
|
|
94
|
+
#
|
|
95
|
+
# @param type [Symbol] The region type
|
|
96
|
+
# @param content [String] The inner content (without delimiters)
|
|
97
|
+
# @param start_line [Integer] 1-indexed start line
|
|
98
|
+
# @param end_line [Integer] 1-indexed end line
|
|
99
|
+
# @param delimiters [Hash, nil] { start: String, end: String }
|
|
100
|
+
# @param metadata [Hash, nil] Additional metadata
|
|
101
|
+
# @return [Region] A new Region struct
|
|
102
|
+
def build_region(type:, content:, start_line:, end_line:, delimiters: nil, metadata: nil)
|
|
103
|
+
Region.new(
|
|
104
|
+
type: type,
|
|
105
|
+
content: content,
|
|
106
|
+
start_line: start_line,
|
|
107
|
+
end_line: end_line,
|
|
108
|
+
delimiters: delimiters,
|
|
109
|
+
metadata: metadata || {},
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
##
|
|
6
|
+
# Mixin for adding region support to SmartMerger classes.
|
|
7
|
+
#
|
|
8
|
+
# This module provides functionality for detecting and handling regions
|
|
9
|
+
# within documents that should be merged with different strategies.
|
|
10
|
+
# Regions are portions of a document (like YAML frontmatter or fenced
|
|
11
|
+
# code blocks) that may require specialized merging.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic region configuration
|
|
14
|
+
# class SmartMerger
|
|
15
|
+
# include RegionMergeable
|
|
16
|
+
#
|
|
17
|
+
# def initialize(template, dest, regions: [], region_placeholder: nil)
|
|
18
|
+
# @template_content = template
|
|
19
|
+
# @dest_content = dest
|
|
20
|
+
# setup_regions(regions: regions, region_placeholder: region_placeholder)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example With YAML frontmatter regions
|
|
25
|
+
# merger = SmartMerger.new(
|
|
26
|
+
# template,
|
|
27
|
+
# dest,
|
|
28
|
+
# regions: [
|
|
29
|
+
# {
|
|
30
|
+
# detector: YamlFrontmatterDetector.new,
|
|
31
|
+
# merger_class: SomeYamlMerger,
|
|
32
|
+
# merger_options: { preserve_order: true }
|
|
33
|
+
# }
|
|
34
|
+
# ]
|
|
35
|
+
# )
|
|
36
|
+
#
|
|
37
|
+
# @example With nested regions (code blocks in markdown)
|
|
38
|
+
# merger = SmartMerger.new(
|
|
39
|
+
# template,
|
|
40
|
+
# dest,
|
|
41
|
+
# regions: [
|
|
42
|
+
# {
|
|
43
|
+
# detector: FencedCodeBlockDetector.ruby,
|
|
44
|
+
# merger_class: Prism::Merge::SmartMerger,
|
|
45
|
+
# regions: [...] # Nested regions!
|
|
46
|
+
# }
|
|
47
|
+
# ]
|
|
48
|
+
# )
|
|
49
|
+
#
|
|
50
|
+
module RegionMergeable
|
|
51
|
+
# Default placeholder prefix for extracted regions
|
|
52
|
+
DEFAULT_PLACEHOLDER_PREFIX = "<<<AST_MERGE_REGION_"
|
|
53
|
+
DEFAULT_PLACEHOLDER_SUFFIX = ">>>"
|
|
54
|
+
|
|
55
|
+
##
|
|
56
|
+
# Configuration for a single region type.
|
|
57
|
+
#
|
|
58
|
+
# @attr detector [RegionDetectorBase] Detector instance for finding regions
|
|
59
|
+
# @attr merger_class [Class, nil] Merger class for merging region content (nil to skip merging)
|
|
60
|
+
# @attr merger_options [Hash] Options to pass to the region merger
|
|
61
|
+
# @attr regions [Array<Hash>] Nested region configurations (recursive)
|
|
62
|
+
#
|
|
63
|
+
RegionConfig = Struct.new(:detector, :merger_class, :merger_options, :regions, keyword_init: true) do
|
|
64
|
+
def initialize(detector:, merger_class: nil, merger_options: {}, regions: [])
|
|
65
|
+
super(
|
|
66
|
+
detector: detector,
|
|
67
|
+
merger_class: merger_class,
|
|
68
|
+
merger_options: merger_options || {},
|
|
69
|
+
regions: regions || [],
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
##
|
|
75
|
+
# Extracted region with its content and placeholder.
|
|
76
|
+
#
|
|
77
|
+
# @attr region [Region] The detected region
|
|
78
|
+
# @attr config [RegionConfig] The configuration that matched this region
|
|
79
|
+
# @attr placeholder [String] The placeholder used in the document
|
|
80
|
+
# @attr merged_content [String, nil] The merged content (set after merging)
|
|
81
|
+
#
|
|
82
|
+
ExtractedRegion = Struct.new(:region, :config, :placeholder, :merged_content, keyword_init: true)
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# Set up region handling for this merger instance.
|
|
86
|
+
#
|
|
87
|
+
# @param regions [Array<Hash>] Array of region configurations
|
|
88
|
+
# @param region_placeholder [String, nil] Custom placeholder prefix (optional)
|
|
89
|
+
# @raise [ArgumentError] if regions configuration is invalid
|
|
90
|
+
#
|
|
91
|
+
def setup_regions(regions:, region_placeholder: nil)
|
|
92
|
+
@region_configs = build_region_configs(regions)
|
|
93
|
+
@region_placeholder_prefix = region_placeholder || DEFAULT_PLACEHOLDER_PREFIX
|
|
94
|
+
@extracted_template_regions = []
|
|
95
|
+
@extracted_dest_regions = []
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
##
|
|
99
|
+
# Check if this merger has region configurations.
|
|
100
|
+
#
|
|
101
|
+
# @return [Boolean] true if regions are configured
|
|
102
|
+
#
|
|
103
|
+
def regions_configured?
|
|
104
|
+
@region_configs && !@region_configs.empty?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# Extract regions from the template content, replacing with placeholders.
|
|
109
|
+
#
|
|
110
|
+
# @param content [String] Template content
|
|
111
|
+
# @return [String] Content with regions replaced by placeholders
|
|
112
|
+
# @raise [PlaceholderCollisionError] if content contains placeholder text
|
|
113
|
+
#
|
|
114
|
+
def extract_template_regions(content)
|
|
115
|
+
return content unless regions_configured?
|
|
116
|
+
|
|
117
|
+
extract_regions(content, @extracted_template_regions)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
##
|
|
121
|
+
# Extract regions from the destination content, replacing with placeholders.
|
|
122
|
+
#
|
|
123
|
+
# @param content [String] Destination content
|
|
124
|
+
# @return [String] Content with regions replaced by placeholders
|
|
125
|
+
# @raise [PlaceholderCollisionError] if content contains placeholder text
|
|
126
|
+
#
|
|
127
|
+
def extract_dest_regions(content)
|
|
128
|
+
return content unless regions_configured?
|
|
129
|
+
|
|
130
|
+
extract_regions(content, @extracted_dest_regions)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
##
|
|
134
|
+
# Merge extracted regions and substitute them back into the merged content.
|
|
135
|
+
#
|
|
136
|
+
# @param merged_content [String] The merged content with placeholders
|
|
137
|
+
# @return [String] Content with placeholders replaced by merged regions
|
|
138
|
+
#
|
|
139
|
+
def substitute_merged_regions(merged_content)
|
|
140
|
+
return merged_content unless regions_configured?
|
|
141
|
+
|
|
142
|
+
result = merged_content
|
|
143
|
+
|
|
144
|
+
# Process regions in reverse order of extraction to handle nested placeholders
|
|
145
|
+
# We need to merge template and dest regions by their placeholder index
|
|
146
|
+
merge_and_substitute_regions(result)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
##
|
|
152
|
+
# Build RegionConfig objects from configuration hashes.
|
|
153
|
+
#
|
|
154
|
+
# @param configs [Array<Hash>] Array of configuration hashes
|
|
155
|
+
# @return [Array<RegionConfig>] Array of RegionConfig objects
|
|
156
|
+
#
|
|
157
|
+
def build_region_configs(configs)
|
|
158
|
+
return [] if configs.nil? || configs.empty?
|
|
159
|
+
|
|
160
|
+
configs.map do |config|
|
|
161
|
+
case config
|
|
162
|
+
when RegionConfig
|
|
163
|
+
config
|
|
164
|
+
when Hash
|
|
165
|
+
RegionConfig.new(
|
|
166
|
+
detector: config[:detector],
|
|
167
|
+
merger_class: config[:merger_class],
|
|
168
|
+
merger_options: config[:merger_options] || {},
|
|
169
|
+
regions: config[:regions] || [],
|
|
170
|
+
)
|
|
171
|
+
else
|
|
172
|
+
raise ArgumentError, "Invalid region config: #{config.inspect}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
##
|
|
178
|
+
# Extract regions from content, replacing with placeholders.
|
|
179
|
+
#
|
|
180
|
+
# @param content [String] Content to process
|
|
181
|
+
# @param storage [Array<ExtractedRegion>] Array to store extracted regions
|
|
182
|
+
# @return [String] Content with placeholders
|
|
183
|
+
#
|
|
184
|
+
def extract_regions(content, storage)
|
|
185
|
+
validate_no_placeholder_collision!(content)
|
|
186
|
+
|
|
187
|
+
result = content
|
|
188
|
+
region_index = storage.size
|
|
189
|
+
|
|
190
|
+
@region_configs.each do |config|
|
|
191
|
+
regions = config.detector.detect_all(result)
|
|
192
|
+
|
|
193
|
+
# Process regions in reverse order to maintain correct positions
|
|
194
|
+
regions.sort_by { |r| -r.start_line }.each do |region|
|
|
195
|
+
placeholder = build_placeholder(region_index)
|
|
196
|
+
region_index += 1
|
|
197
|
+
|
|
198
|
+
extracted = ExtractedRegion.new(
|
|
199
|
+
region: region,
|
|
200
|
+
config: config,
|
|
201
|
+
placeholder: placeholder,
|
|
202
|
+
merged_content: nil,
|
|
203
|
+
)
|
|
204
|
+
storage.unshift(extracted) # Add to front since we process in reverse
|
|
205
|
+
|
|
206
|
+
# Replace the region with the placeholder
|
|
207
|
+
result = replace_region_with_placeholder(result, region, placeholder)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
storage.sort_by! { |e| placeholder_index(e.placeholder) }
|
|
212
|
+
result
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
##
|
|
216
|
+
# Validate that the content doesn't contain placeholder text.
|
|
217
|
+
#
|
|
218
|
+
# @param content [String] Content to validate
|
|
219
|
+
# @raise [PlaceholderCollisionError] if placeholder is found
|
|
220
|
+
#
|
|
221
|
+
def validate_no_placeholder_collision!(content)
|
|
222
|
+
return if content.nil? || content.empty?
|
|
223
|
+
|
|
224
|
+
if content.include?(@region_placeholder_prefix)
|
|
225
|
+
raise PlaceholderCollisionError, @region_placeholder_prefix
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
##
|
|
230
|
+
# Build a placeholder string for a given index.
|
|
231
|
+
#
|
|
232
|
+
# @param index [Integer] The region index
|
|
233
|
+
# @return [String] The placeholder string
|
|
234
|
+
#
|
|
235
|
+
def build_placeholder(index)
|
|
236
|
+
"#{@region_placeholder_prefix}#{index}#{DEFAULT_PLACEHOLDER_SUFFIX}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
##
|
|
240
|
+
# Extract the index from a placeholder string.
|
|
241
|
+
#
|
|
242
|
+
# @param placeholder [String] The placeholder string
|
|
243
|
+
# @return [Integer] The extracted index
|
|
244
|
+
#
|
|
245
|
+
def placeholder_index(placeholder)
|
|
246
|
+
placeholder.match(/#{Regexp.escape(@region_placeholder_prefix)}(\d+)/)[1].to_i
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
##
|
|
250
|
+
# Replace a region in content with a placeholder.
|
|
251
|
+
#
|
|
252
|
+
# @param content [String] The content
|
|
253
|
+
# @param region [Region] The region to replace
|
|
254
|
+
# @param placeholder [String] The placeholder to insert
|
|
255
|
+
# @return [String] Content with region replaced
|
|
256
|
+
#
|
|
257
|
+
def replace_region_with_placeholder(content, region, placeholder)
|
|
258
|
+
lines = content.lines
|
|
259
|
+
# Region line numbers are 1-indexed
|
|
260
|
+
start_idx = region.start_line - 1
|
|
261
|
+
end_idx = region.end_line - 1
|
|
262
|
+
|
|
263
|
+
# Replace the region lines with the placeholder
|
|
264
|
+
before = lines[0...start_idx]
|
|
265
|
+
after = lines[(end_idx + 1)..]
|
|
266
|
+
|
|
267
|
+
# Preserve the newline style
|
|
268
|
+
newline = content.include?("\r\n") ? "\r\n" : "\n"
|
|
269
|
+
placeholder_line = "#{placeholder}#{newline}"
|
|
270
|
+
|
|
271
|
+
(before + [placeholder_line] + (after || [])).join
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
##
|
|
275
|
+
# Merge and substitute regions back into the merged content.
|
|
276
|
+
#
|
|
277
|
+
# @param content [String] Merged content with placeholders
|
|
278
|
+
# @return [String] Content with merged regions substituted
|
|
279
|
+
#
|
|
280
|
+
def merge_and_substitute_regions(content)
|
|
281
|
+
result = content
|
|
282
|
+
|
|
283
|
+
# Build a mapping of placeholder index to extracted regions from both sources
|
|
284
|
+
template_by_idx = @extracted_template_regions.each_with_object({}) do |e, h|
|
|
285
|
+
h[placeholder_index(e.placeholder)] = e
|
|
286
|
+
end
|
|
287
|
+
dest_by_idx = @extracted_dest_regions.each_with_object({}) do |e, h|
|
|
288
|
+
h[placeholder_index(e.placeholder)] = e
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Find all placeholder indices in the merged content
|
|
292
|
+
all_indices = (template_by_idx.keys + dest_by_idx.keys).uniq.sort
|
|
293
|
+
|
|
294
|
+
all_indices.each do |idx|
|
|
295
|
+
template_extracted = template_by_idx[idx]
|
|
296
|
+
dest_extracted = dest_by_idx[idx]
|
|
297
|
+
placeholder = build_placeholder(idx)
|
|
298
|
+
|
|
299
|
+
merged_region_content = merge_region(template_extracted, dest_extracted)
|
|
300
|
+
result = result.gsub(placeholder, merged_region_content) if merged_region_content
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
result
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
##
|
|
307
|
+
# Merge a region from template and destination.
|
|
308
|
+
#
|
|
309
|
+
# @param template_extracted [ExtractedRegion, nil] Template region
|
|
310
|
+
# @param dest_extracted [ExtractedRegion, nil] Destination region
|
|
311
|
+
# @return [String, nil] Merged region content, or nil if no content
|
|
312
|
+
#
|
|
313
|
+
def merge_region(template_extracted, dest_extracted)
|
|
314
|
+
config = template_extracted&.config || dest_extracted&.config
|
|
315
|
+
return unless config
|
|
316
|
+
|
|
317
|
+
template_region = template_extracted&.region
|
|
318
|
+
dest_region = dest_extracted&.region
|
|
319
|
+
|
|
320
|
+
# Get the full text (including delimiters) for each region
|
|
321
|
+
template_text = template_region&.full_text || ""
|
|
322
|
+
dest_text = dest_region&.full_text || ""
|
|
323
|
+
|
|
324
|
+
# If no merger class, prefer destination content (preserve customizations)
|
|
325
|
+
unless config.merger_class
|
|
326
|
+
return dest_text.empty? ? template_text : dest_text
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Extract just the content (without delimiters) for merging
|
|
330
|
+
template_content = template_region&.content || ""
|
|
331
|
+
dest_content = dest_region&.content || ""
|
|
332
|
+
|
|
333
|
+
# Build merger options, including nested regions if configured
|
|
334
|
+
merger_options = config.merger_options.dup
|
|
335
|
+
merger_options[:regions] = config.regions unless config.regions.empty?
|
|
336
|
+
|
|
337
|
+
# Create the merger and merge the region content
|
|
338
|
+
merger = config.merger_class.new(template_content, dest_content, **merger_options)
|
|
339
|
+
merged_content = merger.merge
|
|
340
|
+
|
|
341
|
+
# Reconstruct with delimiters
|
|
342
|
+
reconstruct_region_with_delimiters(template_region || dest_region, merged_content)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
##
|
|
346
|
+
# Reconstruct a region with its delimiters around the merged content.
|
|
347
|
+
#
|
|
348
|
+
# @param region [Region] The original region (for delimiter info)
|
|
349
|
+
# @param content [String] The merged content
|
|
350
|
+
# @return [String] Full region text with delimiters
|
|
351
|
+
#
|
|
352
|
+
def reconstruct_region_with_delimiters(region, content)
|
|
353
|
+
return content unless region&.delimiters
|
|
354
|
+
|
|
355
|
+
opening, closing = region.delimiters
|
|
356
|
+
|
|
357
|
+
# Ensure content ends with newline if it doesn't
|
|
358
|
+
normalized_content = content.end_with?("\n") ? content : "#{content}\n"
|
|
359
|
+
|
|
360
|
+
"#{opening}\n#{normalized_content}#{closing}\n"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|