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,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Normalizes whitespace in markdown documents.
6
+ #
7
+ # Supports multiple normalization modes:
8
+ # - `:basic` (or `true`) - Collapse excessive blank lines (3+ → 2)
9
+ # - `:link_refs` - Also remove blank lines between consecutive link reference definitions
10
+ # - `:strict` - All of the above normalizations
11
+ #
12
+ # Uses {LinkParser} for detecting link reference definitions, which supports:
13
+ # - Standard definitions: `[label]: url`
14
+ # - Definitions with titles: `[label]: url "title"`
15
+ # - Angle-bracketed URLs: `[label]: <url>`
16
+ # - Emoji in labels: `[🎨logo]: url`
17
+ #
18
+ # @example Basic normalization (default)
19
+ # content = "Hello\n\n\n\nWorld"
20
+ # normalized = WhitespaceNormalizer.normalize(content)
21
+ # # => "Hello\n\nWorld"
22
+ #
23
+ # @example With link_refs mode
24
+ # content = "[link1]: url1\n\n[link2]: url2"
25
+ # normalized = WhitespaceNormalizer.normalize(content, mode: :link_refs)
26
+ # # => "[link1]: url1\n[link2]: url2"
27
+ #
28
+ # @example With problem tracking
29
+ # normalizer = WhitespaceNormalizer.new(content, mode: :link_refs)
30
+ # result = normalizer.normalize
31
+ # normalizer.problems.by_category(:link_ref_spacing)
32
+ #
33
+ class WhitespaceNormalizer
34
+ # Valid normalization modes
35
+ MODES = %i[basic link_refs strict].freeze
36
+
37
+ # @return [String] The original content
38
+ attr_reader :content
39
+
40
+ # @return [Symbol] The normalization mode
41
+ attr_reader :mode
42
+
43
+ # @return [DocumentProblems] Problems found during normalization
44
+ attr_reader :problems
45
+
46
+ class << self
47
+ # Normalize whitespace in content (class method for convenience).
48
+ #
49
+ # @param content [String] Content to normalize
50
+ # @param mode [Symbol, Boolean] Normalization mode (:basic, :link_refs, :strict, or true for :basic)
51
+ # @return [String] Normalized content
52
+ def normalize(content, mode: :basic)
53
+ new(content, mode: mode).normalize
54
+ end
55
+ end
56
+
57
+ # Initialize a new normalizer.
58
+ #
59
+ # @param content [String] Content to normalize
60
+ # @param mode [Symbol, Boolean] Normalization mode (:basic, :link_refs, :strict, or true for :basic)
61
+ def initialize(content, mode: :basic)
62
+ @content = content
63
+ @mode = normalize_mode(mode)
64
+ @problems = DocumentProblems.new
65
+ @link_parser = LinkParser.new
66
+ end
67
+
68
+ # Normalize whitespace based on the configured mode.
69
+ #
70
+ # @return [String] Normalized content
71
+ def normalize
72
+ result = content.dup
73
+
74
+ # Always collapse excessive blank lines (3+ → 2)
75
+ result = collapse_excessive_blank_lines(result)
76
+
77
+ # Remove blank lines between link refs if mode requires it
78
+ result = remove_blank_lines_between_link_refs(result) if @mode == :link_refs || @mode == :strict
79
+
80
+ result
81
+ end
82
+
83
+ # Check if normalization made any changes.
84
+ #
85
+ # @return [Boolean] true if content had whitespace issues
86
+ def changed?
87
+ !@problems.empty?
88
+ end
89
+
90
+ # Get count of normalizations performed.
91
+ #
92
+ # @return [Integer] Number of whitespace issues fixed
93
+ def normalization_count
94
+ @problems.count
95
+ end
96
+
97
+ private
98
+
99
+ # Normalize mode parameter to a symbol.
100
+ #
101
+ # @param mode [Symbol, Boolean] Input mode
102
+ # @return [Symbol] Normalized mode
103
+ def normalize_mode(mode)
104
+ case mode
105
+ when true
106
+ :basic
107
+ when false
108
+ :basic # Still do basic normalization
109
+ when Symbol
110
+ raise ArgumentError, "Unknown mode: #{mode}. Valid modes: #{MODES.join(', ')}" unless MODES.include?(mode)
111
+
112
+ mode
113
+ else
114
+ raise ArgumentError, "Mode must be a Symbol or Boolean, got: #{mode.class}"
115
+ end
116
+ end
117
+
118
+ # Collapse 3+ consecutive newlines to 2.
119
+ #
120
+ # This detects runs of blank lines (empty lines) and collapses them.
121
+ # Note: A blank line is a line containing only whitespace.
122
+ # 3+ consecutive newlines means 2+ blank lines.
123
+ #
124
+ # @param text [String] Text to process
125
+ # @return [String] Processed text
126
+ def collapse_excessive_blank_lines(text)
127
+ lines = text.lines
128
+ result = []
129
+ consecutive_blank_count = 0
130
+ problem_start_line = nil
131
+ line_number = 0
132
+
133
+ lines.each do |line|
134
+ line_number += 1
135
+
136
+ if line.chomp.empty?
137
+ consecutive_blank_count += 1
138
+ # The problem starts at the line BEFORE the first blank line
139
+ # (i.e., the line that ends with the first \n of the excessive sequence)
140
+ problem_start_line ||= line_number - 1
141
+
142
+ # Only add up to 1 blank line (which creates the standard paragraph gap)
143
+ result << line if consecutive_blank_count <= 1
144
+ # Skip adding lines when consecutive_blank_count >= 2
145
+ else
146
+ # Record problem if we had 2+ blank lines (which means 3+ newlines)
147
+ # consecutive_blank_count is the count of blank lines, so >= 2 means excessive
148
+ if consecutive_blank_count >= 2
149
+ @problems.add(
150
+ :excessive_whitespace,
151
+ severity: :warning,
152
+ line: problem_start_line,
153
+ newline_count: consecutive_blank_count + 1, # +1 because first line ends with \n too
154
+ collapsed_to: 2
155
+ )
156
+ end
157
+
158
+ consecutive_blank_count = 0
159
+ problem_start_line = nil
160
+ result << line
161
+ end
162
+ end
163
+
164
+ # Handle trailing blank lines
165
+ if consecutive_blank_count >= 2
166
+ @problems.add(
167
+ :excessive_whitespace,
168
+ severity: :warning,
169
+ line: problem_start_line,
170
+ newline_count: consecutive_blank_count + 1,
171
+ collapsed_to: 2
172
+ )
173
+ end
174
+
175
+ result.join
176
+ end
177
+
178
+ # Remove blank lines between consecutive link reference definitions.
179
+ #
180
+ # Uses {LinkParser} to detect link definitions, supporting:
181
+ # - Standard: `[label]: url`
182
+ # - With title: `[label]: url "title"`
183
+ # - Angle-bracketed: `[label]: <url>`
184
+ # - Emoji labels: `[🎨logo]: url`
185
+ #
186
+ # @param text [String] Text to process
187
+ # @return [String] Processed text
188
+ def remove_blank_lines_between_link_refs(text)
189
+ lines = text.lines
190
+ result = []
191
+ i = 0
192
+
193
+ while i < lines.length
194
+ line = lines[i]
195
+ result << line
196
+
197
+ # Check if current line is a link ref definition using LinkParser
198
+ if link_definition_line?(line)
199
+ # Look ahead for blank lines followed by another link ref
200
+ j = i + 1
201
+ while j < lines.length
202
+ next_line = lines[j]
203
+ break unless next_line.chomp.empty?
204
+
205
+ # Check if there's a link ref definition after the blank line(s)
206
+ k = j + 1
207
+ k += 1 while k < lines.length && lines[k].chomp.empty?
208
+ break unless k < lines.length && link_definition_line?(lines[k])
209
+
210
+ # Skip all blank lines between link refs
211
+ blanks_skipped = k - j
212
+ @problems.add(
213
+ :link_ref_spacing,
214
+ severity: :info,
215
+ line: j + 1,
216
+ blank_lines_removed: blanks_skipped
217
+ )
218
+ j = k
219
+
220
+ # Not followed by a link ref, keep the blank line
221
+
222
+ end
223
+ i = j
224
+ else
225
+ i += 1
226
+ end
227
+ end
228
+
229
+ result.join
230
+ end
231
+
232
+ # Check if a line is a link reference definition using LinkParser.
233
+ #
234
+ # @param line [String] Line to check
235
+ # @return [Boolean] true if line is a link definition
236
+ def link_definition_line?(line)
237
+ # Use LinkParser to attempt parsing the line as a definition
238
+ result = @link_parser.parse_definition_line(line.chomp)
239
+ !result.nil?
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Shared bootstrap helpers for backend-specific Markdown wrapper gems.
6
+ module WrapperSupport
7
+ SHARED_REEXPORTS = {
8
+ FileAligner: Markdown::Merge::FileAligner,
9
+ ConflictResolver: Markdown::Merge::ConflictResolver,
10
+ MergeResult: Markdown::Merge::MergeResult,
11
+ TableMatchAlgorithm: Markdown::Merge::TableMatchAlgorithm,
12
+ TableMatchRefiner: Markdown::Merge::TableMatchRefiner,
13
+ CodeBlockMerger: Markdown::Merge::CodeBlockMerger,
14
+ NodeTypeNormalizer: Markdown::Merge::NodeTypeNormalizer
15
+ }.freeze
16
+
17
+ WRAPPER_AUTOLOADS = {
18
+ DebugLogger: 'debug_logger',
19
+ CommentTracker: 'comment_tracker',
20
+ FreezeNode: 'freeze_node',
21
+ FileAnalysis: 'file_analysis',
22
+ PartialTemplateMerger: 'partial_template_merger',
23
+ SmartMerger: 'smart_merger',
24
+ Backend: 'backend'
25
+ }.freeze
26
+
27
+ module_function
28
+
29
+ def install!(
30
+ wrapper_module:,
31
+ require_prefix:,
32
+ default_freeze_token:,
33
+ default_inner_merge_code_blocks:,
34
+ registry_tag:,
35
+ merger_class:,
36
+ test_source: "# Test\n\nParagraph",
37
+ category: :markdown
38
+ )
39
+ define_error_classes!(wrapper_module)
40
+ define_constant_unless_present(wrapper_module, :DEFAULT_FREEZE_TOKEN, default_freeze_token)
41
+ define_constant_unless_present(wrapper_module, :DEFAULT_INNER_MERGE_CODE_BLOCKS,
42
+ default_inner_merge_code_blocks)
43
+
44
+ SHARED_REEXPORTS.each do |name, value|
45
+ define_constant_unless_present(wrapper_module, name, value)
46
+ end
47
+
48
+ WRAPPER_AUTOLOADS.each do |name, suffix|
49
+ next if wrapper_module.const_defined?(name, false) || wrapper_module.autoload?(name)
50
+
51
+ wrapper_module.send(:autoload, name, "#{require_prefix}/#{suffix}")
52
+ end
53
+
54
+ install_backend_loader!(wrapper_module)
55
+ register_merge_gem!(
56
+ registry_tag: registry_tag,
57
+ require_path: require_prefix,
58
+ merger_class: merger_class,
59
+ test_source: test_source,
60
+ category: category
61
+ )
62
+ end
63
+
64
+ def configure_debug_logger!(debug_logger_module:, env_var_name:, log_prefix:)
65
+ debug_logger_module.extend(Ast::Merge::DebugLogger)
66
+ debug_logger_module.env_var_name = env_var_name
67
+ debug_logger_module.log_prefix = log_prefix
68
+ end
69
+
70
+ def configure_file_analysis_subclass!(klass, default_backend:, default_parser_options: nil)
71
+ install_singleton_value_method!(klass, :default_backend, default_backend)
72
+ return unless default_parser_options
73
+
74
+ install_singleton_value_method!(klass, :default_parser_options,
75
+ default_parser_options)
76
+ end
77
+
78
+ def configure_smart_merger_subclass!(
79
+ klass,
80
+ default_backend:,
81
+ default_freeze_token: nil,
82
+ default_inner_merge_code_blocks: nil,
83
+ default_parser_options: nil,
84
+ file_analysis_class: nil,
85
+ template_parse_error_class: nil,
86
+ destination_parse_error_class: nil
87
+ )
88
+ install_singleton_value_method!(klass, :default_backend, default_backend)
89
+ install_singleton_value_method!(klass, :default_freeze_token, default_freeze_token) if default_freeze_token
90
+ unless default_inner_merge_code_blocks.nil?
91
+ install_singleton_value_method!(klass, :default_inner_merge_code_blocks,
92
+ default_inner_merge_code_blocks)
93
+ end
94
+ if default_parser_options
95
+ install_singleton_value_method!(klass, :default_parser_options,
96
+ default_parser_options)
97
+ end
98
+ install_singleton_value_method!(klass, :file_analysis_class, file_analysis_class) if file_analysis_class
99
+ if template_parse_error_class
100
+ install_singleton_value_method!(klass, :template_parse_error_class,
101
+ template_parse_error_class)
102
+ end
103
+ return unless destination_parse_error_class
104
+
105
+ install_singleton_value_method!(klass, :destination_parse_error_class,
106
+ destination_parse_error_class)
107
+ end
108
+
109
+ def configure_partial_template_merger_subclass!(klass, default_backend:, file_analysis_class:,
110
+ smart_merger_class:)
111
+ install_singleton_value_method!(klass, :default_backend, default_backend)
112
+ install_singleton_value_method!(klass, :file_analysis_class, file_analysis_class)
113
+ install_singleton_value_method!(klass, :smart_merger_class, smart_merger_class)
114
+ end
115
+
116
+ def install_backend_loader!(wrapper_module)
117
+ return if wrapper_module.respond_to?(:ensure_backend_loaded!)
118
+
119
+ wrapper_module.singleton_class.class_eval do
120
+ define_method(:ensure_backend_loaded!) do
121
+ const_get(:Backend)
122
+ end
123
+ end
124
+ end
125
+ private_class_method :install_backend_loader!
126
+
127
+ def define_error_classes!(wrapper_module)
128
+ define_constant_unless_present(wrapper_module, :Error, Class.new(Markdown::Merge::Error))
129
+ define_constant_unless_present(wrapper_module, :ParseError, Class.new(Markdown::Merge::ParseError))
130
+ define_constant_unless_present(
131
+ wrapper_module,
132
+ :TemplateParseError,
133
+ Class.new(wrapper_module.const_get(:ParseError))
134
+ )
135
+ define_constant_unless_present(
136
+ wrapper_module,
137
+ :DestinationParseError,
138
+ Class.new(wrapper_module.const_get(:ParseError))
139
+ )
140
+ end
141
+ private_class_method :define_error_classes!
142
+
143
+ def install_singleton_value_method!(klass, method_name, value)
144
+ klass.singleton_class.class_eval do
145
+ define_method(method_name) do
146
+ WrapperSupport.send(:resolve_config_value, value, context: self)
147
+ end
148
+ end
149
+ end
150
+ private_class_method :install_singleton_value_method!
151
+
152
+ def resolve_config_value(value, context:)
153
+ return context.instance_exec(&value) if value.respond_to?(:call)
154
+
155
+ duplicate_config_value(value)
156
+ end
157
+ private_class_method :resolve_config_value
158
+
159
+ def duplicate_config_value(value)
160
+ case value
161
+ when Hash
162
+ value.each_with_object({}) do |(key, nested_value), result|
163
+ result[key] = duplicate_config_value(nested_value)
164
+ end
165
+ when Array
166
+ value.map { |nested_value| duplicate_config_value(nested_value) }
167
+ else
168
+ value
169
+ end
170
+ end
171
+ private_class_method :duplicate_config_value
172
+
173
+ def register_merge_gem!(registry_tag:, require_path:, merger_class:, test_source:, category:)
174
+ return unless defined?(Ast::Merge::RSpec::MergeGemRegistry)
175
+
176
+ Ast::Merge::RSpec::MergeGemRegistry.register(
177
+ registry_tag,
178
+ require_path: require_path,
179
+ merger_class: merger_class,
180
+ test_source: test_source,
181
+ category: category
182
+ )
183
+ end
184
+ private_class_method :register_merge_gem!
185
+
186
+ def define_constant_unless_present(wrapper_module, name, value)
187
+ return if wrapper_module.const_defined?(name, false)
188
+
189
+ wrapper_module.const_set(name, value)
190
+ end
191
+ private_class_method :define_constant_unless_present
192
+ end
193
+ end
194
+ end