Class: Markdown::Merge::FileAnalysisBase Abstract

Inherits:
Object
  • Object
show all
Includes:
Ast::Merge::FileAnalyzable
Defined in:
lib/markdown/merge/file_analysis_base.rb

Overview

This class is abstract.

Subclass and implement parser-specific methods

Base class for file analysis for Markdown files.

Parses Markdown source code and extracts:

  • Top-level block elements (headings, paragraphs, lists, code blocks, etc.)
  • Freeze blocks marked with HTML comments
  • Structural signatures for matching elements between files

Subclasses must implement parser-specific methods:

  • #parse_document(source) - Parse source and return document node
  • #next_sibling(node) - Get next sibling of a node
  • #compute_parser_signature(node) - Compute signature for parser-specific nodes
  • #node_type_name(type) - Map canonical type names if needed

Freeze blocks are marked with HTML comments:

<!-- markdown-merge:freeze -->
... content to preserve ...
<!-- markdown-merge:unfreeze -->

Examples:

Basic usage (subclass)

class FileAnalysis < Markdown::Merge::FileAnalysisBase
  def parse_document(source)
    Markly.parse(source, flags: @flags)
  end

  def next_sibling(node)
    node.next
  end
end

Direct Known Subclasses

FileAnalysis

Constant Summary collapse

DEFAULT_FREEZE_TOKEN =

Default freeze token for identifying freeze blocks

Returns:

  • (String)
'markdown-merge'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, **parser_options) ⇒ FileAnalysisBase

Initialize file analysis

Parameters:

  • source (String)

    Markdown source code to analyze

  • freeze_token (String) (defaults to: DEFAULT_FREEZE_TOKEN)

    Token for freeze block markers

  • signature_generator (Proc, nil) (defaults to: nil)

    Custom signature generator



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/markdown/merge/file_analysis_base.rb', line 60

def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, **parser_options)
  @source = source
  # Split by newlines, keeping trailing empty strings (-1)
  # But remove the final empty string if source ends with newline
  # (that empty string represents the "line after the last newline" which doesn't exist)
  @lines = source.split("\n", -1)
  @lines.pop if @lines.last == '' && source.end_with?("\n")
  @comment_tracker = CommentTracker.new(@lines)

  @freeze_token = freeze_token
  @signature_generator = signature_generator
  @parser_options = parser_options
  @errors = []

  # Parse the Markdown source - subclasses implement this
  @document = DebugLogger.time('FileAnalysisBase#parse') do
    parse_document(source)
  end

  # Extract and integrate all nodes including freeze blocks
  @statements = extract_and_integrate_all_nodes

  DebugLogger.debug('FileAnalysisBase initialized', {
                      signature_generator: signature_generator ? 'custom' : 'default',
                      document_children: count_children(@document),
                      statements_count: @statements.size,
                      freeze_blocks: freeze_blocks.size
                    })
end

Instance Attribute Details

#comment_trackerCommentTracker (readonly)

Returns Comment tracker for this file.

Returns:



51
52
53
# File 'lib/markdown/merge/file_analysis_base.rb', line 51

def comment_tracker
  @comment_tracker
end

#documentObject (readonly)

Returns The root document node.

Returns:

  • (Object)

    The root document node



45
46
47
# File 'lib/markdown/merge/file_analysis_base.rb', line 45

def document
  @document
end

#errorsArray (readonly)

Returns Parse errors if any.

Returns:

  • (Array)

    Parse errors if any



48
49
50
# File 'lib/markdown/merge/file_analysis_base.rb', line 48

def errors
  @errors
end

#statementsArray<Object, FreezeNode> (readonly)

Get all statements (block nodes outside freeze blocks + FreezeNode instances)

Returns:



218
219
220
# File 'lib/markdown/merge/file_analysis_base.rb', line 218

def statements
  @statements
end

Instance Method Details

#comment_attachment_for(owner, **options) ⇒ Object

Build a passive shared comment attachment for an owner.

Parameters:

  • owner (Object)

    Structural owner for the attachment

  • options (Hash)

    Additional metadata / lookup overrides

Returns:

  • (Object)


171
172
173
174
175
176
177
178
179
# File 'lib/markdown/merge/file_analysis_base.rb', line 171

def comment_attachment_for(owner, **options)
  augmented_attachment = comment_augmenter(**options).attachment_for(owner)

  shared_comment_attachment_for(
    owner,
    tracker_attachment: augmented_attachment || comment_tracker.comment_attachment_for(owner, **options),
    **options
  )
end

#comment_attachment_strategySymbol

Returns:

  • (Symbol)


182
183
184
# File 'lib/markdown/merge/file_analysis_base.rb', line 182

def comment_attachment_strategy
  :normalize_tracked_layout_merge
end

#comment_augmenter(owners: nil, **options) ⇒ Object

Build a passive shared comment augmenter for this analysis.

Parameters:

  • owners (Array, nil) (defaults to: nil)

    Owners used for attachment inference

  • options (Hash)

    Additional augmenter options

Returns:

  • (Object)


209
210
211
212
213
214
# File 'lib/markdown/merge/file_analysis_base.rb', line 209

def comment_augmenter(owners: nil, **options)
  comment_tracker.augment(
    owners: owners || comment_augmenter_default_owners,
    **options
  )
end

#comment_capabilityObject

Get shared comment capability information for this analysis.

Returns:

  • (Object)


119
120
121
# File 'lib/markdown/merge/file_analysis_base.rb', line 119

def comment_capability
  @comment_capability ||= comment_tracker.augment(owners: []).capability
end

#comment_node_at(line_num) ⇒ Object?

Get a shared comment node at a specific line.

Parameters:

  • line_num (Integer)

    1-based line number

Returns:

  • (Object, nil)


148
149
150
# File 'lib/markdown/merge/file_analysis_base.rb', line 148

def comment_node_at(line_num)
  comment_tracker.comment_node_at(line_num)
end

#comment_nodesArray

Get all tracked comments converted to shared comment nodes.

Returns:

  • (Array)


140
141
142
# File 'lib/markdown/merge/file_analysis_base.rb', line 140

def comment_nodes
  comment_tracker.comment_nodes
end

#comment_region_for_range(range, kind:, full_line_only: false) ⇒ Object

Get comments in a line range converted to a shared comment region.

Parameters:

  • range (Range)

    Range of 1-based line numbers

  • kind (Symbol)

    Region kind

  • full_line_only (Boolean) (defaults to: false)

    Whether to keep only full-line comments

Returns:

  • (Object)


158
159
160
161
162
163
164
# File 'lib/markdown/merge/file_analysis_base.rb', line 158

def comment_region_for_range(range, kind:, full_line_only: false)
  comment_tracker.comment_region_for_range(
    range,
    kind: kind,
    full_line_only: full_line_only
  )
end

#comment_support_styleAst::Merge::Comment::SupportStyle

Describe how Markdown merges currently own and emit comments.

Standalone HTML comments are source-augmented and emitted through the shared synthetic comment layer rather than parser-native comment AST.

Returns:

  • (Ast::Merge::Comment::SupportStyle)


129
130
131
132
133
134
135
# File 'lib/markdown/merge/file_analysis_base.rb', line 129

def comment_support_style
  @comment_support_style ||= shared_comment_support_style(
    source: :markdown_source,
    style: :html_comment,
    read_strategy: :source_augmented_portable_write
  )
end

#compute_node_signature(node) ⇒ Array?

Compute default signature for a node

Parameters:

  • node (Object)

    The parser node or FreezeNode

Returns:

  • (Array, nil)

    Signature array



223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/markdown/merge/file_analysis_base.rb', line 223

def compute_node_signature(node)
  case node
  when Ast::Merge::FreezeNodeBase
    node.signature
  when LinkDefinitionNode
    node.signature
  when GapLineNode
    node.signature
  else
    compute_parser_signature(node)
  end
end

#compute_parser_signature(node) ⇒ Array?

This method is abstract.

Subclasses should override this method

Compute signature for a parser-specific node.

Parameters:

  • node (Object)

    The parser node

Returns:

  • (Array, nil)

    Signature array



261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/markdown/merge/file_analysis_base.rb', line 261

def compute_parser_signature(node)
  type = node.type
  case type
  when :heading, :header
    level = node.header_level
    # H1 is the document title — treat as a singleton.
    # A well-formed markdown document has exactly one H1. Matching by text
    # would cause a generic template title ("AGENTS.md - Development Guide")
    # and a project-qualified destination title ("AGENTS.md - myGem Development Guide")
    # to be treated as different nodes, keeping both in the merged output.
    # Using level-only for H1 makes them the same structural slot so the
    # preferred version wins cleanly without duplication.
    return [:heading, 1] if level == 1

    # H2+ match by level and normalized text content
    [:heading, level, extract_text_content(node)]
  when :paragraph
    # Content-based: Match paragraphs by content hash (first 32 chars of digest)
    text = extract_text_content(node)
    [:paragraph, Digest::SHA256.hexdigest(text)[0, 32]]
  when :code_block
    # Content-based: Match code blocks by fence info and content hash
    content = safe_string_content(node)
    fence_info = node.respond_to?(:fence_info) ? node.fence_info : nil
    [:code_block, fence_info, Digest::SHA256.hexdigest(content)[0, 16]]
  when :list
    # Content-fingerprint: Match lists by type and a hash of the first few
    # items' significant tokens. This lets two lists with similar (but not
    # identical) content match by signature so item-level inner-merge can run,
    # rather than the template list being appended as a template-only node.
    list_type = node.respond_to?(:list_type) ? node.list_type : nil
    items_text = []
    child = node.first_child
    while child
      items_text << extract_text_content(child).downcase.gsub(/\W+/, ' ').strip
      child = next_sibling(child)
      break if items_text.size >= 5
    end
    fingerprint = Digest::SHA256.hexdigest(items_text.sort.join('|'))[0, 16]
    [:list, list_type, fingerprint]
  when :block_quote, :blockquote
    # Content-based: Match block quotes by content hash
    text = extract_text_content(node)
    [:blockquote, Digest::SHA256.hexdigest(text)[0, 16]]
  when :thematic_break, :hrule
    # Structure-based: All thematic breaks are equivalent
    [:hrule]
  when :html_block, :html
    # Content-based: Match HTML blocks by content hash
    content = safe_string_content(node)
    [:html, Digest::SHA256.hexdigest(content)[0, 16]]
  when :table
    # Content-based: Match tables by structure and header content
    header_content = extract_table_header_content(node)
    [:table, count_children(node), Digest::SHA256.hexdigest(header_content)[0, 16]]
  when :footnote_definition
    # Name/label-based: Match footnotes by name or label
    label = node.respond_to?(:name) ? node.name : safe_string_content(node)
    [:footnote_definition, label]
  when :custom_block
    # Content-based: Match custom blocks by content hash
    text = extract_text_content(node)
    [:custom_block, Digest::SHA256.hexdigest(text)[0, 16]]
  else
    # Unknown type - use type and position
    pos = node.source_position
    [:unknown, type, pos&.dig(:start_line)]
  end
end

#extract_text_content(node) ⇒ String

Extract all text content from a node and its children

Parameters:

  • node (Object)

    The node

Returns:

  • (String)

    Concatenated text content



344
345
346
347
348
349
350
351
352
353
354
# File 'lib/markdown/merge/file_analysis_base.rb', line 344

def extract_text_content(node)
  text_parts = []
  node.walk do |child|
    if child.type == :text
      text_parts << child.string_content.to_s
    elsif child.type == :code
      text_parts << child.string_content.to_s
    end
  end
  text_parts.join
end

#fallthrough_node?(value) ⇒ Boolean

Override to detect parser nodes for signature generator fallthrough

Parameters:

  • value (Object)

    The value to check

Returns:

  • (Boolean)

    true if this is a fallthrough node



239
240
241
242
243
244
245
# File 'lib/markdown/merge/file_analysis_base.rb', line 239

def fallthrough_node?(value)
  value.is_a?(Ast::Merge::FreezeNodeBase) ||
    value.is_a?(LinkDefinitionNode) ||
    value.is_a?(GapLineNode) ||
    parser_node?(value) ||
    super
end

#next_sibling(node) ⇒ Object?

This method is abstract.

Subclasses must implement this method

Get the next sibling of a node.

Different parsers use different methods (next vs next_sibling).

Parameters:

  • node (Object)

    Current node

Returns:

  • (Object, nil)

    Next sibling or nil

Raises:

  • (NotImplementedError)


106
107
108
# File 'lib/markdown/merge/file_analysis_base.rb', line 106

def next_sibling(node)
  raise NotImplementedError, "#{self.class} must implement #next_sibling"
end

#parse_document(source) ⇒ Object

This method is abstract.

Subclasses must implement this method

Parse the source document.

Parameters:

  • source (String)

    Markdown source to parse

Returns:

  • (Object)

    Root document node

Raises:

  • (NotImplementedError)


95
96
97
# File 'lib/markdown/merge/file_analysis_base.rb', line 95

def parse_document(source)
  raise NotImplementedError, "#{self.class} must implement #parse_document"
end

#parser_node?(value) ⇒ Boolean

Check if value is a parser-specific node.

Parameters:

  • value (Object)

    Value to check

Returns:

  • (Boolean)

    true if this is a parser node



251
252
253
254
# File 'lib/markdown/merge/file_analysis_base.rb', line 251

def parser_node?(value)
  # Default: check if it responds to :type (common for AST nodes)
  value.respond_to?(:type)
end

#ruleset_delegation_policiesObject



198
199
200
201
202
# File 'lib/markdown/merge/file_analysis_base.rb', line 198

def ruleset_delegation_policies
  [
    { surface_name: :fenced_code_block, strategy: :by_language }
  ]
end

#ruleset_logical_ownersObject



186
187
188
189
190
# File 'lib/markdown/merge/file_analysis_base.rb', line 186

def ruleset_logical_owners
  {
    link_definition: :preserve_if_referenced
  }
end

#ruleset_surfacesObject



192
193
194
195
196
# File 'lib/markdown/merge/file_analysis_base.rb', line 192

def ruleset_surfaces
  [
    { name: :fenced_code_block, selector: :language_tag }
  ]
end

#safe_string_content(node) ⇒ String

Safely get string content from a node

Parameters:

  • node (Object)

    The node

Returns:

  • (String)

    String content or empty string



334
335
336
337
338
339
# File 'lib/markdown/merge/file_analysis_base.rb', line 334

def safe_string_content(node)
  node.string_content.to_s
rescue TypeError
  # Some node types don't support string_content
  extract_text_content(node)
end

#source_range(start_line, end_line) ⇒ String

Get the source text for a range of lines

Lines are joined with newlines, and each line gets a trailing newline except for the last line of the file (which may or may not have one in the original).

Parameters:

  • start_line (Integer)

    Start line (1-indexed)

  • end_line (Integer)

    End line (1-indexed)

Returns:

  • (String)

    Source text



364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/markdown/merge/file_analysis_base.rb', line 364

def source_range(start_line, end_line)
  return '' if start_line < 1 || end_line < start_line

  extracted_lines = @lines[(start_line - 1)..(end_line - 1)]
  return '' if extracted_lines.empty?

  # Add newlines between and after lines, but not after the last line of the file
  # unless it originally had one
  result = extracted_lines.join("\n")

  # Add trailing newline if this isn't the last line of the file
  # (the last line may or may not have a trailing newline in the original source)
  if end_line < @lines.length
    result += "\n"
  elsif @source&.end_with?("\n")
    # Last line of file, but original source ends with newline
    result += "\n"
  end

  result
end

#valid?Boolean

Check if parse was successful

Returns:

  • (Boolean)


112
113
114
# File 'lib/markdown/merge/file_analysis_base.rb', line 112

def valid?
  @errors.empty? && !@document.nil?
end