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,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "line_node"
4
+ require_relative "word_node"
5
+
6
+ module Ast
7
+ module Merge
8
+ module Text
9
+ # Text file analysis class for the text-based AST.
10
+ #
11
+ # This class parses plain text files into a simple AST structure where:
12
+ # - Top-level nodes are LineNodes (one per line)
13
+ # - Nested nodes are WordNodes (words within each line, split on word boundaries)
14
+ #
15
+ # This provides a minimal AST implementation that can be used to test
16
+ # the merge infrastructure with any text-based content.
17
+ #
18
+ # @example Basic usage
19
+ # analysis = FileAnalysis.new("Hello world\nGoodbye world")
20
+ # analysis.statements.size # => 2
21
+ # analysis.statements[0].words.size # => 2
22
+ #
23
+ # @example With freeze blocks
24
+ # content = <<~TEXT
25
+ # Line one
26
+ # # text-merge:freeze
27
+ # Frozen content
28
+ # # text-merge:unfreeze
29
+ # Line four
30
+ # TEXT
31
+ # analysis = FileAnalysis.new(content, freeze_token: "text-merge")
32
+ # analysis.freeze_blocks.size # => 1
33
+ class FileAnalysis
34
+ include FileAnalyzable
35
+
36
+ # Default freeze token for text files
37
+ DEFAULT_FREEZE_TOKEN = "text-merge"
38
+
39
+ # Initialize a new FileAnalysis
40
+ #
41
+ # @param source [String] Source text content
42
+ # @param freeze_token [String] Token for freeze block markers
43
+ # @param signature_generator [Proc, nil] Custom signature generator
44
+ def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil)
45
+ @source = source
46
+ # Split preserving empty lines, but remove trailing empty line from final newline
47
+ @lines = source.split("\n", -1)
48
+ @lines.pop if @lines.last&.empty? && source.end_with?("\n")
49
+ @freeze_token = freeze_token
50
+ @signature_generator = signature_generator
51
+ @statements = parse_statements
52
+ end
53
+
54
+ # Get all top-level statements (LineNodes and FreezeNodes)
55
+ #
56
+ # @return [Array<LineNode, FreezeNodeBase>] All top-level statements
57
+ attr_reader :statements
58
+
59
+ # Compute signature for a node
60
+ #
61
+ # @param node [Object] Node to compute signature for
62
+ # @return [Array, nil] Signature array or nil
63
+ def compute_node_signature(node)
64
+ case node
65
+ when LineNode
66
+ node.signature
67
+ when FreezeNodeBase
68
+ [:freeze_block, node.start_line, node.end_line]
69
+ end
70
+ end
71
+
72
+ # Check if a value is a fallthrough node
73
+ #
74
+ # @param value [Object] Value to check
75
+ # @return [Boolean] True if fallthrough node
76
+ def fallthrough_node?(value)
77
+ value.is_a?(LineNode) || value.is_a?(FreezeNodeBase) || super
78
+ end
79
+
80
+ private
81
+
82
+ # Parse source into statements (LineNodes and FreezeNodes)
83
+ #
84
+ # @return [Array] Parsed statements
85
+ def parse_statements
86
+ statements = []
87
+ freeze_start = nil
88
+ freeze_start_line = nil
89
+
90
+ @lines.each_with_index do |line, idx|
91
+ line_number = idx + 1
92
+
93
+ # Check for freeze markers
94
+ if freeze_marker?(line, :freeze)
95
+ freeze_start = line_number
96
+ freeze_start_line = line
97
+ next
98
+ end
99
+
100
+ if freeze_marker?(line, :unfreeze)
101
+ if freeze_start
102
+ # Create freeze block
103
+ freeze_content = @lines[(freeze_start - 1)..(line_number - 1)].join("\n")
104
+ statements << FreezeNodeBase.new(
105
+ start_line: freeze_start,
106
+ end_line: line_number,
107
+ content: freeze_content,
108
+ reason: extract_freeze_reason(freeze_start_line),
109
+ )
110
+ freeze_start = nil
111
+ freeze_start_line = nil
112
+ end
113
+ next
114
+ end
115
+
116
+ # Skip lines inside freeze blocks
117
+ next if freeze_start
118
+
119
+ # Regular line
120
+ statements << LineNode.new(line, line_number: line_number)
121
+ end
122
+
123
+ # Handle unclosed freeze block
124
+ if freeze_start
125
+ raise FreezeNodeBase::InvalidStructureError.new(
126
+ "Unclosed freeze block starting at line #{freeze_start}",
127
+ start_line: freeze_start,
128
+ )
129
+ end
130
+
131
+ statements
132
+ end
133
+
134
+ # Check if a line is a freeze marker
135
+ #
136
+ # @param line [String] Line to check
137
+ # @param type [Symbol] :freeze or :unfreeze
138
+ # @return [Boolean] True if line is a marker
139
+ def freeze_marker?(line, type)
140
+ pattern = FreezeNodeBase.pattern_for(:hash_comment, @freeze_token)
141
+ match = line.match(pattern)
142
+ return false unless match
143
+
144
+ marker_type = match[1]&.downcase
145
+ case type
146
+ when :freeze
147
+ marker_type == "freeze"
148
+ when :unfreeze
149
+ marker_type == "unfreeze"
150
+ else
151
+ false
152
+ end
153
+ end
154
+
155
+ # Extract freeze reason from marker line
156
+ #
157
+ # @param line [String] Freeze marker line
158
+ # @return [String, nil] Reason or nil
159
+ def extract_freeze_reason(line)
160
+ pattern = FreezeNodeBase.pattern_for(:hash_comment, @freeze_token)
161
+ match = line.match(pattern)
162
+ reason = match[2]&.strip
163
+ (reason.nil? || reason.empty?) ? nil : reason
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module Text
6
+ # Represents a line of text in the text-based AST.
7
+ # Lines are top-level nodes, with words as nested children.
8
+ #
9
+ # @example
10
+ # line = LineNode.new("Hello world!", line_number: 1)
11
+ # line.content # => "Hello world!"
12
+ # line.words.size # => 2
13
+ # line.signature # => [:line, "Hello world!"]
14
+ class LineNode
15
+ # @return [String] The full line content (without trailing newline)
16
+ attr_reader :content
17
+
18
+ # @return [Integer] 1-based line number
19
+ attr_reader :line_number
20
+
21
+ # @return [Array<WordNode>] Words contained in this line
22
+ attr_reader :words
23
+
24
+ # Initialize a new LineNode
25
+ #
26
+ # @param content [String] The line content (without trailing newline)
27
+ # @param line_number [Integer] 1-based line number
28
+ def initialize(content, line_number:)
29
+ @content = content
30
+ @line_number = line_number
31
+ @words = parse_words
32
+ end
33
+
34
+ # Generate a signature for this line node.
35
+ # The signature is used for matching lines across template/destination.
36
+ #
37
+ # @return [Array] Signature array [:line, normalized_content]
38
+ def signature
39
+ [:line, normalized_content]
40
+ end
41
+
42
+ # Get normalized content (trimmed whitespace for comparison)
43
+ #
44
+ # @return [String] Whitespace-trimmed content
45
+ def normalized_content
46
+ @content.strip
47
+ end
48
+
49
+ # Check if this line is blank (empty or whitespace only)
50
+ #
51
+ # @return [Boolean] True if line is blank
52
+ def blank?
53
+ @content.strip.empty?
54
+ end
55
+
56
+ # Check if this line is a comment (starts with # after whitespace)
57
+ # This is a simple heuristic for text files.
58
+ #
59
+ # @return [Boolean] True if line appears to be a comment
60
+ def comment?
61
+ @content.strip.start_with?("#")
62
+ end
63
+
64
+ # Get the starting line (for compatibility with AST node interface)
65
+ #
66
+ # @return [Integer] 1-based start line
67
+ def start_line
68
+ @line_number
69
+ end
70
+
71
+ # Get the ending line (for compatibility with AST node interface)
72
+ #
73
+ # @return [Integer] 1-based end line (same as start for single line)
74
+ def end_line
75
+ @line_number
76
+ end
77
+
78
+ # Check equality with another LineNode
79
+ #
80
+ # @param other [LineNode] Other node to compare
81
+ # @return [Boolean] True if content matches exactly
82
+ def ==(other)
83
+ other.is_a?(LineNode) && @content == other.content
84
+ end
85
+
86
+ alias_method :eql?, :==
87
+
88
+ # Hash code for use in Hash keys
89
+ #
90
+ # @return [Integer] Hash code
91
+ def hash
92
+ @content.hash
93
+ end
94
+
95
+ # String representation for debugging
96
+ #
97
+ # @return [String] Debug representation
98
+ def inspect
99
+ "#<LineNode line=#{@line_number} #{@content.inspect} words=#{@words.size}>"
100
+ end
101
+
102
+ # Convert to string (returns content)
103
+ #
104
+ # @return [String] Line content
105
+ def to_s
106
+ @content
107
+ end
108
+
109
+ private
110
+
111
+ # Parse words from the line content using word boundaries
112
+ #
113
+ # @return [Array<WordNode>] Parsed word nodes
114
+ def parse_words
115
+ words = []
116
+ word_index = 0
117
+
118
+ # Match words using word boundary regex
119
+ # This captures sequences of word characters (\w+)
120
+ @content.scan(/\b(\w+)\b/) do |match|
121
+ word = match[0]
122
+ # Get the match position
123
+ match_data = Regexp.last_match
124
+ start_col = match_data.begin(0)
125
+ end_col = match_data.end(0)
126
+
127
+ words << WordNode.new(
128
+ word,
129
+ line_number: @line_number,
130
+ word_index: word_index,
131
+ start_col: start_col,
132
+ end_col: end_col,
133
+ )
134
+ word_index += 1
135
+ end
136
+
137
+ words
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module Text
6
+ # Merge result for text-based AST merging.
7
+ # Tracks merged lines and decisions made during the merge process.
8
+ class MergeResult < MergeResultBase
9
+ # Add a line to the result
10
+ #
11
+ # @param line [String] Line content to add
12
+ # @return [void]
13
+ def add_line(line)
14
+ @lines << line
15
+ end
16
+
17
+ # Add multiple lines to the result
18
+ #
19
+ # @param lines [Array<String>] Lines to add
20
+ # @return [void]
21
+ def add_lines(lines)
22
+ @lines.concat(lines)
23
+ end
24
+
25
+ # Record a merge decision
26
+ #
27
+ # @param decision [Symbol] Decision constant
28
+ # @param template_node [Object, nil] Template node involved
29
+ # @param dest_node [Object, nil] Destination node involved
30
+ # @return [void]
31
+ def record_decision(decision, template_node, dest_node)
32
+ @decisions << {
33
+ decision: decision,
34
+ template_node: template_node,
35
+ dest_node: dest_node,
36
+ line: @lines.length,
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ module Text
6
+ # Represents a named section within text content.
7
+ #
8
+ # Sections are logical units of text that can be matched and merged
9
+ # independently. For example, in Markdown, sections might be delimited by
10
+ # headings; in plain text, sections might be delimited by comment markers.
11
+ #
12
+ # This is used for text-based splitting of leaf node content, NOT for
13
+ # AST-level node classification (see SectionTyping for that).
14
+ #
15
+ # @example A Markdown section
16
+ # Section.new(
17
+ # name: "Installation",
18
+ # header: "## Installation\n",
19
+ # body: "Install the gem...\n",
20
+ # start_line: 10,
21
+ # end_line: 25,
22
+ # metadata: { heading_level: 2 }
23
+ # )
24
+ #
25
+ # @api public
26
+ Section = Struct.new(
27
+ # @return [String, Symbol] Unique identifier for matching sections
28
+ # (e.g., heading text, comment marker, :preamble for content before first section)
29
+ :name,
30
+
31
+ # @return [String, nil] Header content (heading line, comment marker, etc.)
32
+ :header,
33
+
34
+ # @return [String] The section body content
35
+ :body,
36
+
37
+ # @return [Integer, nil] 1-indexed start line in the original content
38
+ :start_line,
39
+
40
+ # @return [Integer, nil] 1-indexed end line in the original content
41
+ :end_line,
42
+
43
+ # @return [Hash, nil] Optional metadata for splitter-specific information
44
+ # (e.g., { heading_level: 2 }, { marker_type: :comment })
45
+ :metadata,
46
+ keyword_init: true,
47
+ ) do
48
+ # Returns the line range covered by this section.
49
+ #
50
+ # @return [Range, nil] The range from start_line to end_line (inclusive)
51
+ def line_range
52
+ return unless start_line && end_line
53
+ start_line..end_line
54
+ end
55
+
56
+ # Returns the number of lines this section spans.
57
+ #
58
+ # @return [Integer, nil] The number of lines
59
+ def line_count
60
+ return unless start_line && end_line
61
+ end_line - start_line + 1
62
+ end
63
+
64
+ # Reconstructs the full section text including header.
65
+ #
66
+ # @return [String] The complete section with header and body
67
+ def full_text
68
+ result = +""
69
+ result << header.to_s if header
70
+ result << body.to_s
71
+ result
72
+ end
73
+
74
+ # Check if this is the preamble section (content before first split point).
75
+ #
76
+ # @return [Boolean] true if this is the preamble
77
+ def preamble?
78
+ name == :preamble
79
+ end
80
+
81
+ # Normalize the section name for matching.
82
+ # Strips whitespace, downcases, and normalizes spaces.
83
+ #
84
+ # @return [String] Normalized name for matching
85
+ def normalized_name
86
+ return "" if name.nil?
87
+ return name.to_s if name.is_a?(Symbol)
88
+ name.to_s.strip.downcase.gsub(/\s+/, " ")
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end