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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +46 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +852 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/ast/merge/ast_node.rb +87 -0
  14. data/lib/ast/merge/comment/block.rb +195 -0
  15. data/lib/ast/merge/comment/empty.rb +78 -0
  16. data/lib/ast/merge/comment/line.rb +138 -0
  17. data/lib/ast/merge/comment/parser.rb +278 -0
  18. data/lib/ast/merge/comment/style.rb +282 -0
  19. data/lib/ast/merge/comment.rb +36 -0
  20. data/lib/ast/merge/conflict_resolver_base.rb +399 -0
  21. data/lib/ast/merge/debug_logger.rb +271 -0
  22. data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
  23. data/lib/ast/merge/file_analyzable.rb +307 -0
  24. data/lib/ast/merge/freezable.rb +82 -0
  25. data/lib/ast/merge/freeze_node_base.rb +434 -0
  26. data/lib/ast/merge/match_refiner_base.rb +312 -0
  27. data/lib/ast/merge/match_score_base.rb +135 -0
  28. data/lib/ast/merge/merge_result_base.rb +169 -0
  29. data/lib/ast/merge/merger_config.rb +258 -0
  30. data/lib/ast/merge/node_typing.rb +373 -0
  31. data/lib/ast/merge/region.rb +124 -0
  32. data/lib/ast/merge/region_detector_base.rb +114 -0
  33. data/lib/ast/merge/region_mergeable.rb +364 -0
  34. data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
  35. data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
  36. data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
  37. data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
  38. data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
  39. data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
  40. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
  41. data/lib/ast/merge/rspec/shared_examples.rb +26 -0
  42. data/lib/ast/merge/rspec.rb +4 -0
  43. data/lib/ast/merge/section_typing.rb +303 -0
  44. data/lib/ast/merge/smart_merger_base.rb +417 -0
  45. data/lib/ast/merge/text/conflict_resolver.rb +161 -0
  46. data/lib/ast/merge/text/file_analysis.rb +168 -0
  47. data/lib/ast/merge/text/line_node.rb +142 -0
  48. data/lib/ast/merge/text/merge_result.rb +42 -0
  49. data/lib/ast/merge/text/section.rb +93 -0
  50. data/lib/ast/merge/text/section_splitter.rb +397 -0
  51. data/lib/ast/merge/text/smart_merger.rb +141 -0
  52. data/lib/ast/merge/text/word_node.rb +86 -0
  53. data/lib/ast/merge/text.rb +35 -0
  54. data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
  55. data/lib/ast/merge/version.rb +12 -0
  56. data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
  57. data/lib/ast/merge.rb +165 -0
  58. data/lib/ast-merge.rb +4 -0
  59. data/sig/ast/merge.rbs +195 -0
  60. data.tar.gz.sig +0 -0
  61. metadata +326 -0
  62. metadata.gz.sig +0 -0
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module Text
6
+ # Abstract base class for text-based section splitters.
7
+ #
8
+ # A SectionSplitter takes text content (typically from a leaf node in an AST)
9
+ # and divides it into logical sections that can be matched, compared, and
10
+ # merged independently. This is useful for:
11
+ #
12
+ # - Markdown documents split by headings
13
+ # - Plain text files with comment-delimited sections
14
+ # - Configuration files with section markers
15
+ # - Any text where structure is defined by patterns, not AST
16
+ #
17
+ # **Important**: This is for TEXT-BASED splitting of content that doesn't
18
+ # have a structured AST. For AST-level node classification (like identifying
19
+ # `appraise` blocks in Ruby), use `Ast::Merge::SectionTyping` instead.
20
+ #
21
+ # ## How Section Splitting Works
22
+ #
23
+ # 1. **Split**: Parse text content into sections with unique names
24
+ # 2. **Match**: Compare sections between template and destination by name
25
+ # 3. **Merge**: Apply merge rules per-section (template wins, dest wins, merge)
26
+ # 4. **Join**: Reconstruct the text from merged sections
27
+ #
28
+ # ## Implementing a SectionSplitter
29
+ #
30
+ # Subclasses must implement:
31
+ # - `split(content)` - Parse content into an array of Section objects
32
+ # - `join(sections)` - Reconstruct content from sections
33
+ #
34
+ # Subclasses may override:
35
+ # - `section_signature(section)` - Custom matching logic beyond name
36
+ # - `merge_sections(template_section, dest_section)` - Custom section merge
37
+ # - `normalize_name(name)` - Custom name normalization for matching
38
+ #
39
+ # @example Implementing a Markdown heading splitter
40
+ # class HeadingSplitter < SectionSplitter
41
+ # def initialize(split_level: 2)
42
+ # @split_level = split_level
43
+ # end
44
+ #
45
+ # def split(content)
46
+ # # Parse and split on headings at @split_level
47
+ # end
48
+ #
49
+ # def join(sections)
50
+ # sections.map(&:full_text).join
51
+ # end
52
+ # end
53
+ #
54
+ # @example Using a splitter for section-based merging
55
+ # splitter = HeadingSplitter.new(split_level: 2)
56
+ # template_sections = splitter.split(template_content)
57
+ # dest_sections = splitter.split(dest_content)
58
+ #
59
+ # merged = splitter.merge_documents(
60
+ # template_sections,
61
+ # dest_sections,
62
+ # preference: {
63
+ # default: :destination,
64
+ # "Installation" => :template
65
+ # }
66
+ # )
67
+ #
68
+ # result = splitter.join(merged)
69
+ #
70
+ # @abstract Subclass and implement {#split} and {#join}
71
+ # @api public
72
+ class SectionSplitter
73
+ # Default preference when none specified
74
+ DEFAULT_PREFERENCE = :destination
75
+
76
+ # @return [Hash] Options passed to the splitter
77
+ attr_reader :options
78
+
79
+ # Initialize the splitter with options.
80
+ #
81
+ # @param options [Hash] Splitter-specific options
82
+ def initialize(**options)
83
+ @options = options
84
+ end
85
+
86
+ # Split text content into sections.
87
+ #
88
+ # @param content [String] The text content to split
89
+ # @return [Array<Section>] Array of sections in document order
90
+ # @abstract Subclasses must implement this method
91
+ def split(content)
92
+ raise NotImplementedError, "#{self.class}#split must be implemented"
93
+ end
94
+
95
+ # Reconstruct text content from sections.
96
+ #
97
+ # @param sections [Array<Section>] Sections to join
98
+ # @return [String] Reconstructed text content
99
+ # @abstract Subclasses must implement this method
100
+ def join(sections)
101
+ raise NotImplementedError, "#{self.class}#join must be implemented"
102
+ end
103
+
104
+ # Merge two text documents using section-based semantics.
105
+ #
106
+ # This is the main entry point for section-based merging. It:
107
+ # 1. Splits both documents into sections
108
+ # 2. Matches sections by name
109
+ # 3. Merges each section according to preferences
110
+ # 4. Joins the result back into text
111
+ #
112
+ # @param template_content [String] Template text content
113
+ # @param dest_content [String] Destination text content
114
+ # @param preference [Symbol, Hash] Merge preference
115
+ # - `:template` - Template wins for all sections
116
+ # - `:destination` - Destination wins for all sections
117
+ # - Hash - Per-section preferences: `{ default: :dest, "Section Name" => :template }`
118
+ # @param add_template_only [Boolean] Whether to add sections only in template
119
+ # @return [String] Merged text content
120
+ def merge(template_content, dest_content, preference: DEFAULT_PREFERENCE, add_template_only: false)
121
+ template_sections = split(template_content)
122
+ dest_sections = split(dest_content)
123
+
124
+ merged_sections = merge_section_lists(
125
+ template_sections,
126
+ dest_sections,
127
+ preference: preference,
128
+ add_template_only: add_template_only,
129
+ )
130
+
131
+ join(merged_sections)
132
+ end
133
+
134
+ # Merge two lists of sections.
135
+ #
136
+ # @param template_sections [Array<Section>] Sections from template
137
+ # @param dest_sections [Array<Section>] Sections from destination
138
+ # @param preference [Symbol, Hash] Merge preference
139
+ # @param add_template_only [Boolean] Whether to add template-only sections
140
+ # @return [Array<Section>] Merged sections
141
+ def merge_section_lists(template_sections, dest_sections, preference: DEFAULT_PREFERENCE, add_template_only: false)
142
+ # Build lookup by normalized name
143
+ dest_by_name = dest_sections.each_with_object({}) do |section, hash|
144
+ key = normalize_name(section.name)
145
+ hash[key] = section
146
+ end
147
+
148
+ merged = []
149
+ seen_names = Set.new
150
+
151
+ # Process template sections in order
152
+ template_sections.each do |template_section|
153
+ key = normalize_name(template_section.name)
154
+ seen_names << key
155
+
156
+ dest_section = dest_by_name[key]
157
+
158
+ if dest_section
159
+ # Section exists in both - merge according to preference
160
+ section_pref = preference_for_section(template_section.name, preference)
161
+ merged << merge_sections(template_section, dest_section, section_pref)
162
+ elsif add_template_only
163
+ # Template-only section - add if configured
164
+ merged << template_section
165
+ end
166
+ # Otherwise skip template-only sections
167
+ end
168
+
169
+ # Append destination-only sections (preserve destination content)
170
+ dest_sections.each do |dest_section|
171
+ key = normalize_name(dest_section.name)
172
+ next if seen_names.include?(key)
173
+ merged << dest_section
174
+ end
175
+
176
+ merged
177
+ end
178
+
179
+ # Merge a single pair of matching sections.
180
+ #
181
+ # The default implementation simply chooses one section based on preference.
182
+ # Subclasses can override for more sophisticated merging (e.g., line-level
183
+ # merging within sections).
184
+ #
185
+ # @param template_section [Section] Section from template
186
+ # @param dest_section [Section] Section from destination
187
+ # @param preference [Symbol] :template or :destination
188
+ # @return [Section] Merged section
189
+ def merge_sections(template_section, dest_section, preference)
190
+ case preference
191
+ when :template
192
+ template_section
193
+ when :destination
194
+ dest_section
195
+ when :merge
196
+ # Subclasses can implement actual content merging
197
+ merge_section_content(template_section, dest_section)
198
+ else
199
+ dest_section
200
+ end
201
+ end
202
+
203
+ # Merge content within a section (for :merge preference).
204
+ #
205
+ # Default implementation prefers destination. Subclasses should override
206
+ # for format-specific content merging.
207
+ #
208
+ # @param template_section [Section] Section from template
209
+ # @param dest_section [Section] Section from destination
210
+ # @return [Section] Section with merged content
211
+ def merge_section_content(template_section, dest_section)
212
+ # Default: use template header, dest body
213
+ Section.new(
214
+ name: dest_section.name,
215
+ header: template_section.header || dest_section.header,
216
+ body: dest_section.body,
217
+ start_line: dest_section.start_line,
218
+ end_line: dest_section.end_line,
219
+ metadata: dest_section.metadata&.merge(template_section.metadata || {}),
220
+ )
221
+ end
222
+
223
+ # Get the preference for a specific section.
224
+ #
225
+ # @param section_name [String, Symbol] The section name
226
+ # @param preference [Symbol, Hash] Overall preference configuration
227
+ # @return [Symbol] :template or :destination
228
+ def preference_for_section(section_name, preference)
229
+ return preference unless preference.is_a?(Hash)
230
+
231
+ # Try exact match first
232
+ return preference[section_name] if preference.key?(section_name)
233
+
234
+ # Try normalized name
235
+ normalized = normalize_name(section_name)
236
+ preference.each do |key, value|
237
+ return value if normalize_name(key) == normalized
238
+ end
239
+
240
+ # Fall back to default
241
+ preference.fetch(:default, DEFAULT_PREFERENCE)
242
+ end
243
+
244
+ # Normalize a section name for matching.
245
+ #
246
+ # Default implementation strips whitespace, downcases, normalizes spaces.
247
+ # Subclasses can override for format-specific normalization.
248
+ #
249
+ # @param name [String, Symbol, nil] The section name
250
+ # @return [String] Normalized name
251
+ def normalize_name(name)
252
+ return "" if name.nil?
253
+ return name.to_s if name.is_a?(Symbol)
254
+ name.to_s.strip.downcase.gsub(/\s+/, " ")
255
+ end
256
+
257
+ # Generate a signature for section matching.
258
+ #
259
+ # Default uses normalized name. Subclasses can override for more
260
+ # sophisticated matching (e.g., including metadata).
261
+ #
262
+ # @param section [Section] The section
263
+ # @return [Array, String] Signature for matching
264
+ def section_signature(section)
265
+ normalize_name(section.name)
266
+ end
267
+
268
+ # Validate splitter configuration.
269
+ #
270
+ # @param config [Hash, nil] Configuration to validate
271
+ # @raise [ArgumentError] If configuration is invalid
272
+ # @return [void]
273
+ def self.validate!(config)
274
+ return if config.nil?
275
+
276
+ unless config.is_a?(Hash)
277
+ raise ArgumentError, "splitter config must be a Hash, got #{config.class}"
278
+ end
279
+ end
280
+ end
281
+
282
+ # Line-pattern section splitter for text content.
283
+ #
284
+ # Splits text content into sections based on a line pattern (regex).
285
+ # Useful for documents with consistent structural markers like headings.
286
+ #
287
+ # @example Split Markdown on level-2 headings
288
+ # splitter = LineSectionSplitter.new(pattern: /^## (.+)$/)
289
+ # sections = splitter.split(markdown_content)
290
+ #
291
+ # @example Split on comment markers
292
+ # splitter = LineSectionSplitter.new(pattern: /^# === (.+) ===\s*$/)
293
+ # sections = splitter.split(config_file)
294
+ #
295
+ class LineSectionSplitter < SectionSplitter
296
+ # @return [Regexp] Pattern to match section headers
297
+ attr_reader :pattern
298
+
299
+ # @return [Integer] Capture group index for section name (1-based)
300
+ attr_reader :name_capture
301
+
302
+ # Initialize a line-based splitter.
303
+ #
304
+ # @param pattern [Regexp] Pattern to match section header lines
305
+ # @param name_capture [Integer] Capture group for section name (default: 1)
306
+ # @param options [Hash] Additional options
307
+ def initialize(pattern:, name_capture: 1, **options)
308
+ super(**options)
309
+ @pattern = pattern
310
+ @name_capture = name_capture
311
+ end
312
+
313
+ # Split content on lines matching the pattern.
314
+ #
315
+ # @param content [String] Text content
316
+ # @return [Array<Section>] Sections
317
+ def split(content)
318
+ lines = content.lines
319
+ sections = []
320
+ current_section = nil
321
+ preamble_lines = []
322
+
323
+ lines.each_with_index do |line, index|
324
+ line_num = index + 1
325
+
326
+ if (match = line.match(pattern))
327
+ # Start new section
328
+ if current_section
329
+ sections << finalize_section(current_section)
330
+ elsif preamble_lines.any?
331
+ sections << Section.new(
332
+ name: :preamble,
333
+ header: nil,
334
+ body: preamble_lines.join,
335
+ start_line: 1,
336
+ end_line: line_num - 1,
337
+ metadata: {type: :preamble},
338
+ )
339
+ end
340
+
341
+ section_name = match[name_capture] || match[0]
342
+ current_section = {
343
+ name: section_name.strip,
344
+ header: line,
345
+ body_lines: [],
346
+ start_line: line_num,
347
+ }
348
+ elsif current_section
349
+ current_section[:body_lines] << line
350
+ else
351
+ preamble_lines << line
352
+ end
353
+ end
354
+
355
+ # Finalize last section
356
+ if current_section
357
+ current_section[:end_line] = lines.length
358
+ sections << finalize_section(current_section)
359
+ elsif preamble_lines.any? && sections.empty?
360
+ # Entire document is preamble (no sections found)
361
+ sections << Section.new(
362
+ name: :preamble,
363
+ header: nil,
364
+ body: preamble_lines.join,
365
+ start_line: 1,
366
+ end_line: lines.length,
367
+ metadata: {type: :preamble},
368
+ )
369
+ end
370
+
371
+ sections
372
+ end
373
+
374
+ # Join sections back into text content.
375
+ #
376
+ # @param sections [Array<Section>] Sections to join
377
+ # @return [String] Reconstructed content
378
+ def join(sections)
379
+ sections.map(&:full_text).join
380
+ end
381
+
382
+ private
383
+
384
+ def finalize_section(section_data)
385
+ Section.new(
386
+ name: section_data[:name],
387
+ header: section_data[:header],
388
+ body: section_data[:body_lines].join,
389
+ start_line: section_data[:start_line],
390
+ end_line: section_data[:end_line] || section_data[:start_line] + section_data[:body_lines].length,
391
+ metadata: nil,
392
+ )
393
+ end
394
+ end
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module Text
6
+ # Smart merger for text-based files.
7
+ #
8
+ # Provides intelligent merging of two text files using a simple line-based AST
9
+ # where lines are top-level nodes and words are nested nodes.
10
+ #
11
+ # @example Basic merge (destination customizations preserved)
12
+ # merger = SmartMerger.new(template_content, dest_content)
13
+ # result = merger.merge
14
+ # puts result # Merged content
15
+ #
16
+ # @example Template wins merge
17
+ # merger = SmartMerger.new(
18
+ # template_content,
19
+ # dest_content,
20
+ # preference: :template,
21
+ # add_template_only_nodes: true
22
+ # )
23
+ # result = merger.merge
24
+ #
25
+ # @example With freeze blocks
26
+ # template = <<~TEXT
27
+ # Line one
28
+ # Line two
29
+ # TEXT
30
+ #
31
+ # dest = <<~TEXT
32
+ # Line one modified
33
+ # # text-merge:freeze
34
+ # Custom content
35
+ # # text-merge:unfreeze
36
+ # TEXT
37
+ #
38
+ # merger = SmartMerger.new(template, dest)
39
+ # result = merger.merge
40
+ # # => "Line one modified\n# text-merge:freeze\nCustom content\n# text-merge:unfreeze"
41
+ #
42
+ # @example With regions (embedded code blocks)
43
+ # merger = SmartMerger.new(
44
+ # template_content,
45
+ # dest_content,
46
+ # regions: [
47
+ # { detector: FencedCodeBlockDetector.ruby, merger_class: SomeRubyMerger }
48
+ # ]
49
+ # )
50
+ class SmartMerger < SmartMergerBase
51
+ # Default freeze token for text merging
52
+ DEFAULT_FREEZE_TOKEN = "text-merge"
53
+
54
+ # Initialize a new SmartMerger
55
+ #
56
+ # @param template_content [String] Template text content
57
+ # @param dest_content [String] Destination text content
58
+ # @param preference [Symbol] :destination or :template
59
+ # @param add_template_only_nodes [Boolean] Whether to add template-only lines
60
+ # @param freeze_token [String] Token for freeze block markers
61
+ # @param signature_generator [Proc, nil] Custom signature generator
62
+ # @param regions [Array<Hash>, nil] Region configurations for nested merging
63
+ # @param region_placeholder [String, nil] Custom placeholder for regions
64
+ def initialize(
65
+ template_content,
66
+ dest_content,
67
+ preference: :destination,
68
+ add_template_only_nodes: false,
69
+ freeze_token: DEFAULT_FREEZE_TOKEN,
70
+ signature_generator: nil,
71
+ regions: nil,
72
+ region_placeholder: nil
73
+ )
74
+ super(
75
+ template_content,
76
+ dest_content,
77
+ signature_generator: signature_generator,
78
+ preference: preference,
79
+ add_template_only_nodes: add_template_only_nodes,
80
+ freeze_token: freeze_token,
81
+ regions: regions,
82
+ region_placeholder: region_placeholder,
83
+ )
84
+ end
85
+
86
+ # Get merge statistics
87
+ #
88
+ # @return [Hash] Statistics about the merge
89
+ def stats
90
+ merge_result # Ensure merge has run
91
+ {
92
+ template_lines: @template_analysis.statements.count { |s| s.is_a?(LineNode) },
93
+ dest_lines: @dest_analysis.statements.count { |s| s.is_a?(LineNode) },
94
+ result_lines: @result.lines.size,
95
+ decisions: @result.decision_summary,
96
+ }
97
+ end
98
+
99
+ protected
100
+
101
+ # @return [Class] The analysis class for text files
102
+ def analysis_class
103
+ FileAnalysis
104
+ end
105
+
106
+ # @return [String] The default freeze token
107
+ def default_freeze_token
108
+ DEFAULT_FREEZE_TOKEN
109
+ end
110
+
111
+ # @return [Class] The resolver class for text files
112
+ def resolver_class
113
+ ConflictResolver
114
+ end
115
+
116
+ # @return [Class] The result class for text files
117
+ def result_class
118
+ MergeResult
119
+ end
120
+
121
+ # Perform the text-specific merge
122
+ #
123
+ # @return [MergeResult] The merge result
124
+ def perform_merge
125
+ @resolver.resolve(@result)
126
+ @result
127
+ end
128
+
129
+ # Build the resolver with positional arguments (Text::ConflictResolver signature)
130
+ def build_resolver
131
+ ConflictResolver.new(
132
+ @template_analysis,
133
+ @dest_analysis,
134
+ preference: @preference,
135
+ add_template_only_nodes: @add_template_only_nodes,
136
+ )
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module Text
6
+ # Represents a word within a line of text.
7
+ # Words are the nested level of the text-based AST.
8
+ # They are identified by word boundaries (regex \b).
9
+ #
10
+ # @example
11
+ # word = WordNode.new("hello", line_number: 1, word_index: 0, start_col: 0, end_col: 5)
12
+ # word.content # => "hello"
13
+ # word.signature # => [:word, "hello"]
14
+ class WordNode
15
+ # @return [String] The word content
16
+ attr_reader :content
17
+
18
+ # @return [Integer] 1-based line number containing this word
19
+ attr_reader :line_number
20
+
21
+ # @return [Integer] 0-based index of this word within the line
22
+ attr_reader :word_index
23
+
24
+ # @return [Integer] 0-based starting column position
25
+ attr_reader :start_col
26
+
27
+ # @return [Integer] 0-based ending column position (exclusive)
28
+ attr_reader :end_col
29
+
30
+ # Initialize a new WordNode
31
+ #
32
+ # @param content [String] The word content
33
+ # @param line_number [Integer] 1-based line number
34
+ # @param word_index [Integer] 0-based word index within line
35
+ # @param start_col [Integer] 0-based start column
36
+ # @param end_col [Integer] 0-based end column (exclusive)
37
+ def initialize(content, line_number:, word_index:, start_col:, end_col:)
38
+ @content = content
39
+ @line_number = line_number
40
+ @word_index = word_index
41
+ @start_col = start_col
42
+ @end_col = end_col
43
+ end
44
+
45
+ # Generate a signature for this word node.
46
+ # The signature is used for matching words across template/destination.
47
+ #
48
+ # @return [Array] Signature array [:word, content]
49
+ def signature
50
+ [:word, @content]
51
+ end
52
+
53
+ # Check equality with another WordNode
54
+ #
55
+ # @param other [WordNode] Other node to compare
56
+ # @return [Boolean] True if content matches
57
+ def ==(other)
58
+ other.is_a?(WordNode) && @content == other.content
59
+ end
60
+
61
+ alias_method :eql?, :==
62
+
63
+ # Hash code for use in Hash keys
64
+ #
65
+ # @return [Integer] Hash code
66
+ def hash
67
+ @content.hash
68
+ end
69
+
70
+ # String representation for debugging
71
+ #
72
+ # @return [String] Debug representation
73
+ def inspect
74
+ "#<WordNode #{@content.inspect} line=#{@line_number} col=#{@start_col}..#{@end_col}>"
75
+ end
76
+
77
+ # Convert to string (returns content)
78
+ #
79
+ # @return [String] Word content
80
+ def to_s
81
+ @content
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Text-based AST module for ast-merge.
6
+ #
7
+ # Provides a simple line/word based AST that can be used with any text file.
8
+ # This serves as both:
9
+ # 1. A reference implementation for *-merge gems
10
+ # 2. A testing tool for validating merge behavior
11
+ #
12
+ # @example Basic usage
13
+ # require "ast/merge/text"
14
+ #
15
+ # template = "Line one\nLine two\nLine three"
16
+ # dest = "Line one modified\nLine two\nCustom line"
17
+ #
18
+ # merger = Ast::Merge::Text::SmartMerger.new(template, dest)
19
+ # result = merger.merge
20
+ module Text
21
+ # Default freeze token for text files
22
+ DEFAULT_FREEZE_TOKEN = "text-merge"
23
+
24
+ autoload :WordNode, "ast/merge/text/word_node"
25
+ autoload :LineNode, "ast/merge/text/line_node"
26
+ autoload :FileAnalysis, "ast/merge/text/file_analysis"
27
+ autoload :MergeResult, "ast/merge/text/merge_result"
28
+ autoload :ConflictResolver, "ast/merge/text/conflict_resolver"
29
+ autoload :SmartMerger, "ast/merge/text/smart_merger"
30
+ autoload :Section, "ast/merge/text/section"
31
+ autoload :SectionSplitter, "ast/merge/text/section_splitter"
32
+ autoload :LineSectionSplitter, "ast/merge/text/section_splitter"
33
+ end
34
+ end
35
+ end