markdown-merge 7.0.0 → 7.1.3

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 (49) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +673 -0
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
  9. data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
  10. data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
  11. data/lib/markdown/merge/cleanse.rb +44 -0
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +742 -0
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +199 -0
  16. data/lib/markdown/merge/debug_logger.rb +26 -0
  17. data/lib/markdown/merge/document_problems.rb +190 -0
  18. data/lib/markdown/merge/file_aligner.rb +496 -0
  19. data/lib/markdown/merge/file_analysis.rb +689 -0
  20. data/lib/markdown/merge/file_analysis_base.rb +766 -0
  21. data/lib/markdown/merge/freeze_node.rb +93 -0
  22. data/lib/markdown/merge/gap_line_node.rb +142 -0
  23. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  24. data/lib/markdown/merge/link_definition_node.rb +157 -0
  25. data/lib/markdown/merge/link_parser.rb +421 -0
  26. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  27. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  28. data/lib/markdown/merge/list_merger.rb +322 -0
  29. data/lib/markdown/merge/markdown_structure.rb +123 -0
  30. data/lib/markdown/merge/merge_result.rb +483 -0
  31. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  32. data/lib/markdown/merge/output_builder.rb +248 -0
  33. data/lib/markdown/merge/partial_template_merger.rb +555 -0
  34. data/lib/markdown/merge/preservation_support.rb +291 -0
  35. data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
  36. data/lib/markdown/merge/smart_merger.rb +269 -0
  37. data/lib/markdown/merge/smart_merger_base.rb +1490 -0
  38. data/lib/markdown/merge/source_preserving_provider.rb +814 -0
  39. data/lib/markdown/merge/table_match_algorithm.rb +499 -0
  40. data/lib/markdown/merge/table_match_refiner.rb +132 -0
  41. data/lib/markdown/merge/version.rb +5 -3
  42. data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
  43. data/lib/markdown/merge/wrapper_support.rb +194 -0
  44. data/lib/markdown/merge.rb +271 -87
  45. data/lib/markdown-merge.rb +7 -1
  46. data/sig/markdown/merge.rbs +62 -0
  47. data.tar.gz.sig +0 -0
  48. metadata +289 -15
  49. metadata.gz.sig +0 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Conservatively tracks standalone HTML comment lines in Markdown sources.
6
+ class CommentTracker < Ast::Merge::Comment::HashTrackerBase
7
+ STANDALONE_HTML_COMMENT_REGEX = /\A(?<indent>\s*)<!--\s?(?<text>.*?)\s?-->\s*\z/
8
+
9
+ def initialize(lines)
10
+ super(Array(lines))
11
+ end
12
+
13
+ private
14
+
15
+ def comment_style
16
+ :html_comment
17
+ end
18
+
19
+ def extract_comments
20
+ @lines.each_with_index.filter_map do |line, index|
21
+ match = line.match(STANDALONE_HTML_COMMENT_REGEX)
22
+ next unless match
23
+
24
+ {
25
+ line: index + 1,
26
+ indent: match[:indent].length,
27
+ text: match[:text].to_s,
28
+ full_line: true,
29
+ raw: line
30
+ }
31
+ end
32
+ end
33
+
34
+ def owner_line_num(owner)
35
+ pos = owner.respond_to?(:source_position) ? owner.source_position : nil
36
+ return pos[:start_line] if pos && pos[:start_line]
37
+
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Resolves conflicts between matching Markdown elements from template and destination.
6
+ #
7
+ # When two elements have the same signature but different content, the resolver
8
+ # determines which version to use based on the configured preference.
9
+ #
10
+ # Inherits from Ast::Merge::ConflictResolverBase using the :node strategy,
11
+ # which resolves conflicts on a per-node-pair basis.
12
+ #
13
+ # @example Basic usage
14
+ # resolver = ConflictResolver.new(
15
+ # preference: :destination,
16
+ # template_analysis: template_analysis,
17
+ # dest_analysis: dest_analysis
18
+ # )
19
+ # resolution = resolver.resolve(template_node, dest_node, template_index: 0, dest_index: 0)
20
+ # case resolution[:source]
21
+ # when :template
22
+ # # Use template version
23
+ # when :destination
24
+ # # Use destination version
25
+ # end
26
+ #
27
+ # @see SmartMergerBase
28
+ # @see Ast::Merge::ConflictResolverBase
29
+ class ConflictResolver < Ast::Merge::ConflictResolverBase
30
+ # Initialize a conflict resolver
31
+ #
32
+ # @param preference [Symbol] Which version to prefer (:destination or :template)
33
+ # @param template_analysis [FileAnalysisBase] Analysis of the template file
34
+ # @param dest_analysis [FileAnalysisBase] Analysis of the destination file
35
+ # @param options [Hash] Additional options for forward compatibility
36
+ def initialize(preference:, template_analysis:, dest_analysis:, **options)
37
+ @resolution_mode = options.fetch(:resolution_mode, :eager)
38
+ @unresolved_policy = Ast::Merge::UnresolvedPolicy.coerce(options[:unresolved_policy])
39
+ super(
40
+ strategy: :node,
41
+ preference: preference,
42
+ template_analysis: template_analysis,
43
+ dest_analysis: dest_analysis,
44
+ **options
45
+ )
46
+ end
47
+
48
+ protected
49
+
50
+ # Resolve a conflict between template and destination nodes
51
+ #
52
+ # @param template_node [Object] Node from template
53
+ # @param dest_node [Object] Node from destination
54
+ # @param template_index [Integer] Index in template statements
55
+ # @param dest_index [Integer] Index in destination statements
56
+ # @return [Hash] Resolution with :source, :decision, and node references
57
+ def resolve_node_pair(template_node, dest_node, template_index:, dest_index:)
58
+ # Frozen blocks always win
59
+ if freeze_node?(dest_node)
60
+ return frozen_resolution(
61
+ source: :destination,
62
+ template_node: template_node,
63
+ dest_node: dest_node,
64
+ reason: dest_node.reason
65
+ )
66
+ end
67
+
68
+ if freeze_node?(template_node)
69
+ return frozen_resolution(
70
+ source: :template,
71
+ template_node: template_node,
72
+ dest_node: dest_node,
73
+ reason: template_node.reason
74
+ )
75
+ end
76
+
77
+ # Check if content is identical
78
+ if content_identical?(template_node, dest_node)
79
+ return identical_resolution(
80
+ template_node: template_node,
81
+ dest_node: dest_node
82
+ )
83
+ end
84
+
85
+ if unresolved_mode? && @unresolved_policy.unresolved_for?(:matched_block)
86
+ return unresolved_resolution(
87
+ template_node: template_node,
88
+ dest_node: dest_node
89
+ )
90
+ end
91
+
92
+ # Use preference to decide
93
+ preference_resolution(
94
+ template_node: template_node,
95
+ dest_node: dest_node
96
+ )
97
+ end
98
+
99
+ private
100
+
101
+ # Check if two nodes have identical content
102
+ #
103
+ # @param template_node [Object] Template node
104
+ # @param dest_node [Object] Destination node
105
+ # @return [Boolean] True if content is identical
106
+ def content_identical?(template_node, dest_node)
107
+ template_text = node_to_text(template_node, @template_analysis)
108
+ dest_text = node_to_text(dest_node, @dest_analysis)
109
+ template_text == dest_text
110
+ end
111
+
112
+ def unresolved_resolution(template_node:, dest_node:)
113
+ preferred_resolution = preference_resolution(template_node: template_node, dest_node: dest_node)
114
+ provisional_winner = @unresolved_policy.provisional_winner_for(
115
+ :matched_block,
116
+ fallback: preferred_resolution[:source]
117
+ )
118
+ line = node_line_range(dest_node)&.first || node_line_range(template_node)&.first
119
+ case_id = ['markdown', 'matched_block', line].compact.join('-')
120
+ surface_path = unresolved_surface_path("matched_block[line=#{line}]")
121
+
122
+ unresolved_case = Ast::Merge::Runtime::ResolutionCase.new(
123
+ case_id: case_id,
124
+ reason: :conflict,
125
+ candidates: {
126
+ template: node_to_text(template_node, @template_analysis),
127
+ destination: node_to_text(dest_node, @dest_analysis)
128
+ },
129
+ provisional_winner: provisional_winner,
130
+ surface_path: surface_path,
131
+ metadata: {
132
+ match_kind: :matched_block,
133
+ template_lines: node_line_range(template_node),
134
+ destination_lines: node_line_range(dest_node),
135
+ node_type: markdown_node_type(dest_node || template_node)
136
+ }.compact
137
+ )
138
+
139
+ {
140
+ source: provisional_winner,
141
+ decision: Ast::Merge::MergeResultBase::DECISION_UNRESOLVED,
142
+ template_node: template_node,
143
+ dest_node: dest_node,
144
+ unresolved_case: unresolved_case,
145
+ conflict: {
146
+ case_id: case_id,
147
+ reason: :conflict,
148
+ template: unresolved_case.candidates[:template],
149
+ destination: unresolved_case.candidates[:destination],
150
+ provisional_winner: provisional_winner,
151
+ location: line ? "line #{line}" : nil
152
+ }.compact
153
+ }
154
+ end
155
+
156
+ # Convert a node to its source text
157
+ #
158
+ # @param node [Object] Node to convert
159
+ # @param analysis [FileAnalysisBase] Analysis for source lookup
160
+ # @return [String] Source text
161
+ def node_to_text(node, analysis)
162
+ # Check for any FreezeNode type (base class or subclass)
163
+ if node.is_a?(Ast::Merge::FreezeNodeBase)
164
+ node.full_text
165
+ else
166
+ pos = node.source_position
167
+ start_line = pos&.dig(:start_line)
168
+ end_line = pos&.dig(:end_line)
169
+
170
+ if start_line && end_line
171
+ analysis.source_range(start_line, end_line)
172
+ else
173
+ # simplecov:disable defensive - Markdown nodes typically have source positions
174
+ node.to_commonmark
175
+ # simplecov:enable
176
+ end
177
+ end
178
+ end
179
+
180
+ def node_line_range(node)
181
+ if node.respond_to?(:source_position)
182
+ pos = node.source_position
183
+ start_line = pos&.dig(:start_line)
184
+ end_line = pos&.dig(:end_line)
185
+ return [start_line, end_line] if start_line && end_line
186
+ end
187
+ return [node.start_line, node.end_line] if node.respond_to?(:start_line) && node.respond_to?(:end_line)
188
+
189
+ nil
190
+ end
191
+
192
+ def markdown_node_type(node)
193
+ return node.type.to_sym if node.respond_to?(:type) && !node.type.nil?
194
+
195
+ node.class.name.split('::').last
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Debug logging utility for Markdown::Merge operations.
6
+ #
7
+ # Extends Ast::Merge::DebugLogger to provide consistent logging
8
+ # across all merge gems. Logs are controlled via environment variables.
9
+ #
10
+ # @example Enable debug logging
11
+ # ENV["MARKDOWN_MERGE_DEBUG"] = "1"
12
+ # DebugLogger.debug("Parsing markdown", { file: "README.md" })
13
+ #
14
+ # @example Time an operation
15
+ # result = DebugLogger.time("parse") { Markly.parse(source) }
16
+ #
17
+ # @see Ast::Merge::DebugLogger Base module
18
+ module DebugLogger
19
+ extend Ast::Merge::DebugLogger
20
+
21
+ # Configure for markdown-merge
22
+ self.env_var_name = 'MARKDOWN_MERGE_DEBUG'
23
+ self.log_prefix = '[markdown-merge]'
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Container for document issues found during processing.
6
+ #
7
+ # Collects problems discovered during merge operations, link reference
8
+ # rehydration, whitespace normalization, and other document transformations.
9
+ # Problems are categorized and have severity levels for filtering and reporting.
10
+ #
11
+ # @example Basic usage
12
+ # problems = DocumentProblems.new
13
+ # problems.add(:duplicate_link_definition, label: "example", url: "https://example.com")
14
+ # problems.add(:excessive_whitespace, line: 42, count: 5, severity: :warning)
15
+ # problems.empty? # => false
16
+ # problems.count # => 2
17
+ #
18
+ # @example Filtering by category
19
+ # problems.by_category(:duplicate_link_definition)
20
+ # # => [{ category: :duplicate_link_definition, label: "example", ... }]
21
+ #
22
+ # @example Filtering by severity
23
+ # problems.by_severity(:error)
24
+ # problems.warnings
25
+ # problems.errors
26
+ #
27
+ class DocumentProblems
28
+ # Problem entry struct
29
+ Problem = Struct.new(:category, :severity, :details, keyword_init: true) do
30
+ def to_h
31
+ { category: category, severity: severity, **details }
32
+ end
33
+
34
+ def warning?
35
+ severity == :warning
36
+ end
37
+
38
+ def error?
39
+ severity == :error
40
+ end
41
+
42
+ def info?
43
+ severity == :info
44
+ end
45
+ end
46
+
47
+ # Valid severity levels
48
+ SEVERITIES = %i[info warning error].freeze
49
+
50
+ # Valid problem categories
51
+ CATEGORIES = %i[
52
+ duplicate_link_definition
53
+ excessive_whitespace
54
+ link_has_title
55
+ image_has_title
56
+ link_ref_spacing
57
+ ].freeze
58
+
59
+ # @return [Array<Problem>] All collected problems
60
+ attr_reader :problems
61
+
62
+ def initialize
63
+ @problems = []
64
+ end
65
+
66
+ # Add a problem to the collection.
67
+ #
68
+ # @param category [Symbol] Problem category (see CATEGORIES)
69
+ # @param severity [Symbol] Severity level (:info, :warning, :error), default :warning
70
+ # @param details [Hash] Additional details about the problem
71
+ # @return [Problem] The added problem
72
+ def add(category, severity: :warning, **details)
73
+ validate_category!(category)
74
+ validate_severity!(severity)
75
+
76
+ problem = Problem.new(category: category, severity: severity, details: details)
77
+ @problems << problem
78
+ problem
79
+ end
80
+
81
+ # Get all problems as an array of hashes.
82
+ #
83
+ # @return [Array<Hash>] All problems
84
+ def all
85
+ @problems.map(&:to_h)
86
+ end
87
+
88
+ # Get problems by category.
89
+ #
90
+ # @param category [Symbol] Category to filter by
91
+ # @return [Array<Problem>] Problems in that category
92
+ def by_category(category)
93
+ @problems.select { |p| p.category == category }
94
+ end
95
+
96
+ # Get problems by severity.
97
+ #
98
+ # @param severity [Symbol] Severity to filter by
99
+ # @return [Array<Problem>] Problems with that severity
100
+ def by_severity(severity)
101
+ @problems.select { |p| p.severity == severity }
102
+ end
103
+
104
+ # Get all info-level problems.
105
+ #
106
+ # @return [Array<Problem>] Info problems
107
+ def infos
108
+ by_severity(:info)
109
+ end
110
+
111
+ # Get all warning-level problems.
112
+ #
113
+ # @return [Array<Problem>] Warning problems
114
+ def warnings
115
+ by_severity(:warning)
116
+ end
117
+
118
+ # Get all error-level problems.
119
+ #
120
+ # @return [Array<Problem>] Error problems
121
+ def errors
122
+ by_severity(:error)
123
+ end
124
+
125
+ # Check if there are any problems.
126
+ #
127
+ # @return [Boolean] true if no problems
128
+ def empty?
129
+ @problems.empty?
130
+ end
131
+
132
+ # Get the count of problems.
133
+ #
134
+ # @param category [Symbol, nil] Optional category filter
135
+ # @param severity [Symbol, nil] Optional severity filter
136
+ # @return [Integer] Problem count
137
+ def count(category: nil, severity: nil)
138
+ filtered = @problems
139
+ filtered = filtered.select { |p| p.category == category } if category
140
+ filtered = filtered.select { |p| p.severity == severity } if severity
141
+ filtered.size
142
+ end
143
+
144
+ # Merge another DocumentProblems into this one.
145
+ #
146
+ # @param other [DocumentProblems] Problems to merge
147
+ # @return [self]
148
+ def merge!(other)
149
+ @problems.concat(other.problems)
150
+ self
151
+ end
152
+
153
+ # Clear all problems.
154
+ #
155
+ # @return [self]
156
+ def clear
157
+ @problems.clear
158
+ self
159
+ end
160
+
161
+ # Get a summary of problems by category.
162
+ #
163
+ # @return [Hash<Symbol, Integer>] Counts by category
164
+ def summary_by_category
165
+ @problems.group_by(&:category).transform_values(&:size)
166
+ end
167
+
168
+ # Get a summary of problems by severity.
169
+ #
170
+ # @return [Hash<Symbol, Integer>] Counts by severity
171
+ def summary_by_severity
172
+ @problems.group_by(&:severity).transform_values(&:size)
173
+ end
174
+
175
+ private
176
+
177
+ def validate_category!(category)
178
+ return if CATEGORIES.include?(category)
179
+
180
+ raise ArgumentError, "Invalid category: #{category}. Valid: #{CATEGORIES.join(', ')}"
181
+ end
182
+
183
+ def validate_severity!(severity)
184
+ return if SEVERITIES.include?(severity)
185
+
186
+ raise ArgumentError, "Invalid severity: #{severity}. Valid: #{SEVERITIES.join(', ')}"
187
+ end
188
+ end
189
+ end
190
+ end