ast-merge 1.1.0 → 2.0.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +198 -7
- data/README.md +208 -39
- data/exe/ast-merge-recipe +366 -0
- data/lib/ast/merge/conflict_resolver_base.rb +8 -1
- data/lib/ast/merge/content_match_refiner.rb +278 -0
- data/lib/ast/merge/debug_logger.rb +2 -1
- data/lib/ast/merge/detector/base.rb +193 -0
- data/lib/ast/merge/detector/fenced_code_block.rb +227 -0
- data/lib/ast/merge/detector/mergeable.rb +369 -0
- data/lib/ast/merge/detector/toml_frontmatter.rb +82 -0
- data/lib/ast/merge/detector/yaml_frontmatter.rb +82 -0
- data/lib/ast/merge/merge_result_base.rb +4 -1
- data/lib/ast/merge/navigable_statement.rb +630 -0
- data/lib/ast/merge/partial_template_merger.rb +432 -0
- data/lib/ast/merge/recipe/config.rb +198 -0
- data/lib/ast/merge/recipe/preset.rb +171 -0
- data/lib/ast/merge/recipe/runner.rb +254 -0
- data/lib/ast/merge/recipe/script_loader.rb +181 -0
- data/lib/ast/merge/recipe.rb +26 -0
- data/lib/ast/merge/rspec/dependency_tags.rb +252 -0
- data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +3 -2
- data/lib/ast/merge/rspec.rb +33 -2
- data/lib/ast/merge/smart_merger_base.rb +86 -3
- data/lib/ast/merge/version.rb +1 -1
- data/lib/ast/merge.rb +10 -6
- data/sig/ast/merge.rbs +389 -2
- data.tar.gz.sig +0 -0
- metadata +60 -16
- metadata.gz.sig +0 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +0 -313
- data/lib/ast/merge/region.rb +0 -124
- data/lib/ast/merge/region_detector_base.rb +0 -114
- data/lib/ast/merge/region_mergeable.rb +0 -364
- data/lib/ast/merge/toml_frontmatter_detector.rb +0 -88
- data/lib/ast/merge/yaml_frontmatter_detector.rb +0 -88
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
# Detector namespace for region detection and merging functionality.
|
|
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 Ruby code
|
|
9
|
+
# blocks that should be merged with Prism.
|
|
10
|
+
#
|
|
11
|
+
# @example Detecting regions
|
|
12
|
+
# detector = Ast::Merge::Detector::FencedCodeBlock.ruby
|
|
13
|
+
# regions = detector.detect_all(markdown_content)
|
|
14
|
+
# regions.each do |region|
|
|
15
|
+
# puts "Found #{region.type} at lines #{region.start_line}-#{region.end_line}"
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @see Detector::Region Data struct for detected regions
|
|
19
|
+
# @see Detector::Base Base class for detectors
|
|
20
|
+
# @see Detector::Mergeable Mixin for region-aware merging
|
|
21
|
+
#
|
|
22
|
+
module Detector
|
|
23
|
+
# Represents a detected region within a document.
|
|
24
|
+
#
|
|
25
|
+
# Regions are portions of a document that can be handled by a specialized
|
|
26
|
+
# merger. For example, YAML frontmatter in a Markdown file, or a Ruby code
|
|
27
|
+
# block that should be merged using a Ruby-aware merger.
|
|
28
|
+
#
|
|
29
|
+
# @example Creating a region for YAML frontmatter
|
|
30
|
+
# Region.new(
|
|
31
|
+
# type: :yaml_frontmatter,
|
|
32
|
+
# content: "title: My Doc\nversion: 1.0\n",
|
|
33
|
+
# start_line: 1,
|
|
34
|
+
# end_line: 4,
|
|
35
|
+
# delimiters: ["---", "---"],
|
|
36
|
+
# metadata: { format: :yaml }
|
|
37
|
+
# )
|
|
38
|
+
#
|
|
39
|
+
# @api public
|
|
40
|
+
Region = Struct.new(
|
|
41
|
+
# @return [Symbol] The type of region (e.g., :yaml_frontmatter, :ruby_code_block)
|
|
42
|
+
:type,
|
|
43
|
+
|
|
44
|
+
# @return [String] The raw string content of this region (inner content, without delimiters)
|
|
45
|
+
:content,
|
|
46
|
+
|
|
47
|
+
# @return [Integer] 1-indexed start line in the original document
|
|
48
|
+
:start_line,
|
|
49
|
+
|
|
50
|
+
# @return [Integer] 1-indexed end line in the original document
|
|
51
|
+
:end_line,
|
|
52
|
+
|
|
53
|
+
# @return [Array<String>, nil] Delimiter strings to reconstruct the region
|
|
54
|
+
:delimiters,
|
|
55
|
+
|
|
56
|
+
# @return [Hash, nil] Optional metadata for detector-specific information
|
|
57
|
+
:metadata,
|
|
58
|
+
keyword_init: true,
|
|
59
|
+
) do
|
|
60
|
+
# Returns the line range covered by this region.
|
|
61
|
+
# @return [Range]
|
|
62
|
+
def line_range
|
|
63
|
+
start_line..end_line
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the number of lines this region spans.
|
|
67
|
+
# @return [Integer]
|
|
68
|
+
def line_count
|
|
69
|
+
end_line - start_line + 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Reconstructs the full region text including delimiters.
|
|
73
|
+
# @return [String]
|
|
74
|
+
def full_text
|
|
75
|
+
return content if delimiters.nil? || delimiters.empty?
|
|
76
|
+
|
|
77
|
+
opening = delimiters[0] || ""
|
|
78
|
+
closing = delimiters[1] || ""
|
|
79
|
+
"#{opening}\n#{content}#{closing}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Checks if this region contains the given line number.
|
|
83
|
+
# @param line [Integer] The line number to check (1-indexed)
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def contains_line?(line)
|
|
86
|
+
line_range.cover?(line)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Checks if this region overlaps with another region.
|
|
90
|
+
# @param other [Region] Another region
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def overlaps?(other)
|
|
93
|
+
line_range.cover?(other.start_line) ||
|
|
94
|
+
line_range.cover?(other.end_line) ||
|
|
95
|
+
other.line_range.cover?(start_line)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [String]
|
|
99
|
+
def to_s
|
|
100
|
+
"Region<#{type}:#{start_line}-#{end_line}>"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [String]
|
|
104
|
+
def inspect
|
|
105
|
+
truncated = if content && content.length > 30
|
|
106
|
+
"#{content[0, 30]}..."
|
|
107
|
+
else
|
|
108
|
+
content.inspect
|
|
109
|
+
end
|
|
110
|
+
"#{self} #{truncated}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Base class for region detection.
|
|
115
|
+
#
|
|
116
|
+
# Region detectors identify portions of a document that should be handled
|
|
117
|
+
# by a specialized merger.
|
|
118
|
+
#
|
|
119
|
+
# Subclasses must implement:
|
|
120
|
+
# - {#region_type} - Returns the type symbol for detected regions
|
|
121
|
+
# - {#detect_all} - Finds all regions of this type in a document
|
|
122
|
+
#
|
|
123
|
+
# @example Implementing a custom detector
|
|
124
|
+
# class MyBlockDetector < Ast::Merge::Detector::Base
|
|
125
|
+
# def region_type
|
|
126
|
+
# :my_block
|
|
127
|
+
# end
|
|
128
|
+
#
|
|
129
|
+
# def detect_all(source)
|
|
130
|
+
# # Return array of Region structs
|
|
131
|
+
# []
|
|
132
|
+
# end
|
|
133
|
+
# end
|
|
134
|
+
#
|
|
135
|
+
# @abstract Subclass and implement {#region_type} and {#detect_all}
|
|
136
|
+
# @api public
|
|
137
|
+
#
|
|
138
|
+
class Base
|
|
139
|
+
# Returns the type symbol for regions detected by this detector.
|
|
140
|
+
# @return [Symbol]
|
|
141
|
+
# @abstract
|
|
142
|
+
def region_type
|
|
143
|
+
raise NotImplementedError, "#{self.class}#region_type must be implemented"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Detects all regions of this type in the given source.
|
|
147
|
+
# @param _source [String] The full document content to scan
|
|
148
|
+
# @return [Array<Region>] All detected regions, sorted by start_line
|
|
149
|
+
# @abstract
|
|
150
|
+
def detect_all(_source)
|
|
151
|
+
raise NotImplementedError, "#{self.class}#detect_all must be implemented"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Whether to strip delimiters from content before passing to merger.
|
|
155
|
+
# @return [Boolean]
|
|
156
|
+
def strip_delimiters?
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A human-readable name for this detector.
|
|
161
|
+
# @return [String]
|
|
162
|
+
def name
|
|
163
|
+
self.class.name || "AnonymousDetector"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @return [String]
|
|
167
|
+
def inspect
|
|
168
|
+
"#<#{name} region_type=#{region_type}>"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
protected
|
|
172
|
+
|
|
173
|
+
# Helper to build a Region struct.
|
|
174
|
+
# @return [Region]
|
|
175
|
+
def build_region(type:, content:, start_line:, end_line:, delimiters: nil, metadata: nil)
|
|
176
|
+
Region.new(
|
|
177
|
+
type: type,
|
|
178
|
+
content: content,
|
|
179
|
+
start_line: start_line,
|
|
180
|
+
end_line: end_line,
|
|
181
|
+
delimiters: delimiters,
|
|
182
|
+
metadata: metadata || {},
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
autoload :FencedCodeBlock, "ast/merge/detector/fenced_code_block"
|
|
188
|
+
autoload :YamlFrontmatter, "ast/merge/detector/yaml_frontmatter"
|
|
189
|
+
autoload :TomlFrontmatter, "ast/merge/detector/toml_frontmatter"
|
|
190
|
+
autoload :Mergeable, "ast/merge/detector/mergeable"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ast
|
|
4
|
+
module Merge
|
|
5
|
+
module Detector
|
|
6
|
+
# Detects fenced code blocks with a specific language identifier.
|
|
7
|
+
#
|
|
8
|
+
# This detector finds Markdown-style fenced code blocks (using ``` or ~~~)
|
|
9
|
+
# that have a specific language identifier. It can be configured for any
|
|
10
|
+
# language: ruby, json, yaml, mermaid, etc.
|
|
11
|
+
#
|
|
12
|
+
# ## When to Use This Detector
|
|
13
|
+
#
|
|
14
|
+
# **Use FencedCodeBlock when:**
|
|
15
|
+
# - Working with raw Markdown text without parsing to AST
|
|
16
|
+
# - Quick extraction from strings without parser dependencies
|
|
17
|
+
# - Custom text processing requiring line-level precision
|
|
18
|
+
# - Operating on source text directly (e.g., linters, formatters)
|
|
19
|
+
#
|
|
20
|
+
# **Do NOT use FencedCodeBlock when:**
|
|
21
|
+
# - Working with parsed Markdown AST (use native code block nodes instead)
|
|
22
|
+
# - Integrating with markdown-merge's CodeBlockMerger (it uses native nodes)
|
|
23
|
+
# - Using tree_haver's unified Markdown backend API
|
|
24
|
+
#
|
|
25
|
+
# @example Detecting Ruby code blocks
|
|
26
|
+
# detector = FencedCodeBlock.new("ruby", aliases: ["rb"])
|
|
27
|
+
# regions = detector.detect_all(markdown_source)
|
|
28
|
+
#
|
|
29
|
+
# @example Using factory methods
|
|
30
|
+
# detector = FencedCodeBlock.ruby
|
|
31
|
+
# detector = FencedCodeBlock.yaml
|
|
32
|
+
# detector = FencedCodeBlock.json
|
|
33
|
+
#
|
|
34
|
+
# @api public
|
|
35
|
+
#
|
|
36
|
+
class FencedCodeBlock < Base
|
|
37
|
+
# @return [String] The primary language identifier
|
|
38
|
+
attr_reader :language
|
|
39
|
+
|
|
40
|
+
# @return [Array<String>] Alternative language identifiers
|
|
41
|
+
attr_reader :aliases
|
|
42
|
+
|
|
43
|
+
# Creates a new detector for the specified language.
|
|
44
|
+
#
|
|
45
|
+
# @param language [String, Symbol] The language identifier (e.g., "ruby", "json")
|
|
46
|
+
# @param aliases [Array<String, Symbol>] Alternative identifiers (e.g., ["rb"] for ruby)
|
|
47
|
+
def initialize(language, aliases: [])
|
|
48
|
+
super()
|
|
49
|
+
@language = language.to_s.downcase
|
|
50
|
+
@aliases = aliases.map { |a| a.to_s.downcase }
|
|
51
|
+
@all_identifiers = [@language] + @aliases
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Symbol] The region type (e.g., :ruby_code_block)
|
|
55
|
+
def region_type
|
|
56
|
+
:"#{@language}_code_block"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if a language identifier matches this detector.
|
|
60
|
+
#
|
|
61
|
+
# @param lang [String] The language identifier to check
|
|
62
|
+
# @return [Boolean] true if the language matches
|
|
63
|
+
def matches_language?(lang)
|
|
64
|
+
@all_identifiers.include?(lang.to_s.downcase)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Detects all fenced code blocks with the configured language.
|
|
68
|
+
#
|
|
69
|
+
# @param source [String] The full document content
|
|
70
|
+
# @return [Array<Region>] All detected code blocks, sorted by start_line
|
|
71
|
+
def detect_all(source)
|
|
72
|
+
return [] if source.nil? || source.empty?
|
|
73
|
+
|
|
74
|
+
regions = []
|
|
75
|
+
lines = source.lines
|
|
76
|
+
in_block = false
|
|
77
|
+
start_line = nil
|
|
78
|
+
content_lines = []
|
|
79
|
+
current_language = nil
|
|
80
|
+
fence_char = nil
|
|
81
|
+
fence_length = nil
|
|
82
|
+
indent = ""
|
|
83
|
+
|
|
84
|
+
lines.each_with_index do |line, idx|
|
|
85
|
+
line_num = idx + 1
|
|
86
|
+
|
|
87
|
+
if !in_block
|
|
88
|
+
# Match opening fence: ```lang or ~~~lang (optionally indented)
|
|
89
|
+
match = line.match(/^(\s*)(`{3,}|~{3,})(\w*)\s*$/)
|
|
90
|
+
if match
|
|
91
|
+
indent = match[1] || ""
|
|
92
|
+
fence = match[2]
|
|
93
|
+
lang = match[3].downcase
|
|
94
|
+
|
|
95
|
+
if @all_identifiers.include?(lang)
|
|
96
|
+
in_block = true
|
|
97
|
+
start_line = line_num
|
|
98
|
+
content_lines = []
|
|
99
|
+
current_language = lang
|
|
100
|
+
fence_char = fence[0]
|
|
101
|
+
fence_length = fence.length
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
elsif line.match?(/^#{Regexp.escape(indent)}#{Regexp.escape(fence_char)}{#{fence_length},}\s*$/)
|
|
105
|
+
# Match closing fence (must use same char, same indent, and at least same length)
|
|
106
|
+
opening_fence = "#{fence_char * fence_length}#{current_language}"
|
|
107
|
+
closing_fence = fence_char * fence_length
|
|
108
|
+
|
|
109
|
+
regions << build_region(
|
|
110
|
+
type: region_type,
|
|
111
|
+
content: content_lines.join,
|
|
112
|
+
start_line: start_line,
|
|
113
|
+
end_line: line_num,
|
|
114
|
+
delimiters: [opening_fence, closing_fence],
|
|
115
|
+
metadata: {language: current_language, indent: indent.empty? ? nil : indent},
|
|
116
|
+
)
|
|
117
|
+
in_block = false
|
|
118
|
+
start_line = nil
|
|
119
|
+
content_lines = []
|
|
120
|
+
current_language = nil
|
|
121
|
+
fence_char = nil
|
|
122
|
+
fence_length = nil
|
|
123
|
+
indent = ""
|
|
124
|
+
else
|
|
125
|
+
# Accumulate content lines (strip the indent if present)
|
|
126
|
+
content_lines << if indent.empty?
|
|
127
|
+
line
|
|
128
|
+
else
|
|
129
|
+
# Strip the common indent from content lines
|
|
130
|
+
line.sub(/^#{Regexp.escape(indent)}/, "")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Note: Unclosed blocks are ignored (no region created)
|
|
136
|
+
regions
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [String] A description of this detector
|
|
140
|
+
def inspect
|
|
141
|
+
aliases_str = @aliases.empty? ? "" : " aliases=#{@aliases.inspect}"
|
|
142
|
+
"#<#{self.class.name} language=#{@language}#{aliases_str}>"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class << self
|
|
146
|
+
# Creates a detector for Ruby code blocks.
|
|
147
|
+
# @return [FencedCodeBlock]
|
|
148
|
+
def ruby
|
|
149
|
+
new("ruby", aliases: ["rb"])
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Creates a detector for JSON code blocks.
|
|
153
|
+
# @return [FencedCodeBlock]
|
|
154
|
+
def json
|
|
155
|
+
new("json")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Creates a detector for YAML code blocks.
|
|
159
|
+
# @return [FencedCodeBlock]
|
|
160
|
+
def yaml
|
|
161
|
+
new("yaml", aliases: ["yml"])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Creates a detector for TOML code blocks.
|
|
165
|
+
# @return [FencedCodeBlock]
|
|
166
|
+
def toml
|
|
167
|
+
new("toml")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Creates a detector for Mermaid diagram blocks.
|
|
171
|
+
# @return [FencedCodeBlock]
|
|
172
|
+
def mermaid
|
|
173
|
+
new("mermaid")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Creates a detector for JavaScript code blocks.
|
|
177
|
+
# @return [FencedCodeBlock]
|
|
178
|
+
def javascript
|
|
179
|
+
new("javascript", aliases: ["js"])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Creates a detector for TypeScript code blocks.
|
|
183
|
+
# @return [FencedCodeBlock]
|
|
184
|
+
def typescript
|
|
185
|
+
new("typescript", aliases: ["ts"])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Creates a detector for Python code blocks.
|
|
189
|
+
# @return [FencedCodeBlock]
|
|
190
|
+
def python
|
|
191
|
+
new("python", aliases: ["py"])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Creates a detector for Bash/Shell code blocks.
|
|
195
|
+
# @return [FencedCodeBlock]
|
|
196
|
+
def bash
|
|
197
|
+
new("bash", aliases: ["sh", "shell", "zsh"])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Creates a detector for SQL code blocks.
|
|
201
|
+
# @return [FencedCodeBlock]
|
|
202
|
+
def sql
|
|
203
|
+
new("sql")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Creates a detector for HTML code blocks.
|
|
207
|
+
# @return [FencedCodeBlock]
|
|
208
|
+
def html
|
|
209
|
+
new("html")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Creates a detector for CSS code blocks.
|
|
213
|
+
# @return [FencedCodeBlock]
|
|
214
|
+
def css
|
|
215
|
+
new("css")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Creates a detector for Markdown code blocks (nested markdown).
|
|
219
|
+
# @return [FencedCodeBlock]
|
|
220
|
+
def markdown
|
|
221
|
+
new("markdown", aliases: ["md"])
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|