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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +198 -7
  4. data/README.md +208 -39
  5. data/exe/ast-merge-recipe +366 -0
  6. data/lib/ast/merge/conflict_resolver_base.rb +8 -1
  7. data/lib/ast/merge/content_match_refiner.rb +278 -0
  8. data/lib/ast/merge/debug_logger.rb +2 -1
  9. data/lib/ast/merge/detector/base.rb +193 -0
  10. data/lib/ast/merge/detector/fenced_code_block.rb +227 -0
  11. data/lib/ast/merge/detector/mergeable.rb +369 -0
  12. data/lib/ast/merge/detector/toml_frontmatter.rb +82 -0
  13. data/lib/ast/merge/detector/yaml_frontmatter.rb +82 -0
  14. data/lib/ast/merge/merge_result_base.rb +4 -1
  15. data/lib/ast/merge/navigable_statement.rb +630 -0
  16. data/lib/ast/merge/partial_template_merger.rb +432 -0
  17. data/lib/ast/merge/recipe/config.rb +198 -0
  18. data/lib/ast/merge/recipe/preset.rb +171 -0
  19. data/lib/ast/merge/recipe/runner.rb +254 -0
  20. data/lib/ast/merge/recipe/script_loader.rb +181 -0
  21. data/lib/ast/merge/recipe.rb +26 -0
  22. data/lib/ast/merge/rspec/dependency_tags.rb +252 -0
  23. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +3 -2
  24. data/lib/ast/merge/rspec.rb +33 -2
  25. data/lib/ast/merge/smart_merger_base.rb +86 -3
  26. data/lib/ast/merge/version.rb +1 -1
  27. data/lib/ast/merge.rb +10 -6
  28. data/sig/ast/merge.rbs +389 -2
  29. data.tar.gz.sig +0 -0
  30. metadata +60 -16
  31. metadata.gz.sig +0 -0
  32. data/lib/ast/merge/fenced_code_block_detector.rb +0 -313
  33. data/lib/ast/merge/region.rb +0 -124
  34. data/lib/ast/merge/region_detector_base.rb +0 -114
  35. data/lib/ast/merge/region_mergeable.rb +0 -364
  36. data/lib/ast/merge/toml_frontmatter_detector.rb +0 -88
  37. 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