Class: Markdown::Merge::FileAnalysis

Inherits:
FileAnalysisBase show all
Defined in:
lib/markdown/merge/file_analysis.rb

Overview

File analysis for Markdown files using tree_haver backends.

Extends FileAnalysisBase with backend-agnostic parsing via tree_haver. Supports both Commonmarker and Markly backends through tree_haver's unified API.

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

All nodes are wrapped with canonical types via NodeTypeNormalizer, enabling portable merge rules across backends.

Freeze blocks are marked with HTML comments:

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

Examples:

Basic usage with auto backend

analysis = FileAnalysis.new(markdown_source)
analysis.statements.each do |node|
  puts "#{node.merge_type}: #{node.type}"
end

With specific backend

analysis = FileAnalysis.new(markdown_source, backend: :markly)

With custom freeze token

analysis = FileAnalysis.new(source, freeze_token: "my-merge")
# Looks for: <!-- my-merge:freeze --> / <!-- my-merge:unfreeze -->

See Also:

Defined Under Namespace

Classes: HeadingSectionOwner, HtmlCommentOwner, InlineReferenceOwner, LinkDefinitionOwner, ListItemOwner, Location, TableRowOwner

Constant Summary collapse

DEFAULT_FREEZE_TOKEN =

Default freeze token for identifying freeze blocks

Returns:

  • (String)
'markdown-merge'

Instance Attribute Summary collapse

Attributes inherited from FileAnalysisBase

#comment_tracker, #document, #errors, #statements

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from FileAnalysisBase

#comment_attachment_for, #comment_attachment_strategy, #comment_augmenter, #comment_capability, #comment_node_at, #comment_nodes, #comment_region_for_range, #comment_support_style, #compute_node_signature, #ruleset_delegation_policies, #ruleset_logical_owners, #ruleset_surfaces, #source_range, #valid?

Constructor Details

#initialize(source, backend: self.class.default_backend, freeze_token: self.class.default_freeze_token, signature_generator: nil, **parser_options) ⇒ FileAnalysis

Initialize file analysis with tree_haver backend.

Parameters:

  • source (String)

    Markdown source code to analyze

  • backend (Symbol) (defaults to: self.class.default_backend)

    Backend to use (:commonmarker, :markly, :kramdown, :auto)

  • freeze_token (String) (defaults to: self.class.default_freeze_token)

    Token for freeze block markers

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

    Custom signature generator

  • parser_options (Hash)

    Backend-specific parser options For commonmarker: { options: {} } For markly: { flags: Markly::DEFAULT, extensions: [:table] }



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/markdown/merge/file_analysis.rb', line 97

def initialize(
  source,
  backend: self.class.default_backend,
  freeze_token: self.class.default_freeze_token,
  signature_generator: nil,
  **parser_options
)
  @requested_backend = backend
  @parser_options = self.class.default_parser_options.merge(parser_options)

  # Resolve and initialize the backend
  @backend = resolve_backend(backend)
  @parser = create_parser

  super(source, freeze_token: freeze_token, signature_generator: signature_generator)
end

Instance Attribute Details

#backendSymbol (readonly)

Returns The backend being used (:commonmarker, :markly, :kramdown).

Returns:

  • (Symbol)

    The backend being used (:commonmarker, :markly, :kramdown)



65
66
67
# File 'lib/markdown/merge/file_analysis.rb', line 65

def backend
  @backend
end

#parser_optionsHash (readonly)

Returns Parser-specific options.

Returns:

  • (Hash)

    Parser-specific options



68
69
70
# File 'lib/markdown/merge/file_analysis.rb', line 68

def parser_options
  @parser_options
end

Class Method Details

.default_backendObject



47
48
49
# File 'lib/markdown/merge/file_analysis.rb', line 47

def default_backend
  :auto
end

.default_freeze_node_classObject



59
60
61
# File 'lib/markdown/merge/file_analysis.rb', line 59

def default_freeze_node_class
  Markdown::Merge::FreezeNode
end

.default_freeze_tokenObject



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

def default_freeze_token
  self::DEFAULT_FREEZE_TOKEN
end

.default_parser_optionsObject



55
56
57
# File 'lib/markdown/merge/file_analysis.rb', line 55

def default_parser_options
  {}
end

Instance Method Details

#collect_top_level_nodesArray<Object>

Collect top-level nodes from document, wrapping with canonical types.

Returns:

  • (Array<Object>)

    Wrapped nodes



281
282
283
284
285
286
287
288
289
290
291
# File 'lib/markdown/merge/file_analysis.rb', line 281

def collect_top_level_nodes
  nodes = []
  child = @document.first_child
  while child
    # Wrap each node with its canonical type
    wrapped = NodeTypeNormalizer.wrap(child, @backend)
    nodes << wrapped
    child = next_sibling(child)
  end
  nodes
end

#compute_parser_signature(node) ⇒ Array?

Compute signature for a tree_haver node.

Uses canonical types from NodeTypeNormalizer for portable signatures.

Parameters:

  • node (Object)

    The node (may be wrapped)

Returns:

  • (Array, nil)

    Signature array



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/markdown/merge/file_analysis.rb', line 186

def compute_parser_signature(node)
  # Get canonical type from wrapper or normalize raw type
  canonical_type = if Ast::Merge::NodeTyping.typed_node?(node)
                     Ast::Merge::NodeTyping.merge_type_for(node)
                   else
                     NodeTypeNormalizer.canonical_type(node.type, @backend)
                   end

  # Unwrap to access underlying node methods
  raw_node = Ast::Merge::NodeTyping.unwrap(node)

  case canonical_type
  when :heading
    level = raw_node.header_level
    # H1 is the document title — treat as a singleton (see FileAnalysisBase for rationale)
    return [:heading, 1] if level == 1

    [:heading, level, extract_text_content(raw_node)]
  when :paragraph
    # Content-based: Match paragraphs by content hash (first 32 chars of digest)
    text = extract_text_content(raw_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(raw_node)
    fence_info = raw_node.respond_to?(:fence_info) ? raw_node.fence_info : nil
    [:code_block, fence_info, Digest::SHA256.hexdigest(content)[0, 16]]
  when :list
    # Structure-based: Match lists by type and item count (content may differ)
    list_type = raw_node.respond_to?(:list_type) ? raw_node.list_type : nil
    [:list, list_type, count_children(raw_node)]
  when :block_quote
    # Content-based: Match block quotes by content hash
    text = extract_text_content(raw_node)
    [:block_quote, Digest::SHA256.hexdigest(text)[0, 16]]
  when :thematic_break
    # Structure-based: All thematic breaks are equivalent
    [:thematic_break]
  when :html_block
    # Content-based: Match HTML blocks by content hash
    content = safe_string_content(raw_node)
    [:html_block, Digest::SHA256.hexdigest(content)[0, 16]]
  when :table
    # Content-based: Match tables by structure and header content
    header_content = extract_table_header_content(raw_node)
    [:table, count_children(raw_node), Digest::SHA256.hexdigest(header_content)[0, 16]]
  when :footnote_definition
    # Name/label-based: Match footnotes by name or label
    label = raw_node.respond_to?(:name) ? raw_node.name : safe_string_content(raw_node)
    [:footnote_definition, label]
  when :custom_block
    # Content-based: Match custom blocks by content hash
    text = extract_text_content(raw_node)
    [:custom_block, Digest::SHA256.hexdigest(text)[0, 16]]
  else
    # Unknown type - use canonical type and position
    pos = raw_node.source_position
    [:unknown, canonical_type, pos&.dig(:start_line)]
  end
end

#extract_text_content(node) ⇒ String

Extract all text content from a node and its children.

Override for tree_haver nodes which don't have a walk method. Uses recursive traversal via children instead.

Parameters:

  • node (Object)

    The node

Returns:

  • (String)

    Concatenated text content



254
255
256
257
258
# File 'lib/markdown/merge/file_analysis.rb', line 254

def extract_text_content(node)
  text_parts = []
  collect_text_recursive(node, text_parts)
  text_parts.join
end

#fallthrough_node?(value) ⇒ Boolean

Override to detect tree_haver nodes for signature generator fallthrough

Parameters:

  • value (Object)

    The value to check

Returns:

  • (Boolean)

    true if this is a fallthrough node



173
174
175
176
177
178
# File 'lib/markdown/merge/file_analysis.rb', line 173

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

#freeze_node_classClass

Returns the FreezeNode class to use.

Returns:

  • (Class)

    Markdown::Merge::FreezeNode



154
155
156
# File 'lib/markdown/merge/file_analysis.rb', line 154

def freeze_node_class
  self.class.default_freeze_node_class
end

#heading_section_ownersObject



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/markdown/merge/file_analysis.rb', line 293

def heading_section_owners
  headings = Array(statements).filter_map do |statement|
    next unless heading_statement?(statement)

    build_heading_owner(statement)
  end

  headings.each_with_index.map do |owner, index|
    branch_end_line = branch_end_line(headings, index)
    HeadingSectionOwner.new(
      location: Location.new(start_line: owner.location.start_line, end_line: branch_end_line),
      heading_text: owner.heading_text,
      heading_source: owner.heading_source,
      level: owner.level,
      base: owner.base
    )
  end
end

#html_comment_ownersObject



333
334
335
336
337
338
339
340
341
# File 'lib/markdown/merge/file_analysis.rb', line 333

def html_comment_owners
  comment_tracker.comment_nodes.map do |comment|
    HtmlCommentOwner.new(
      location: Location.new(start_line: comment.location.start_line, end_line: comment.location.end_line),
      text: comment.content,
      source: comment.text
    )
  end
end

#inline_reference_ownersObject



349
350
351
352
353
# File 'lib/markdown/merge/file_analysis.rb', line 349

def inline_reference_owners
  source.to_s.lines.each_with_index.flat_map do |line, index|
    inline_references_for_line(line.chomp, index + 1)
  end
end


312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/markdown/merge/file_analysis.rb', line 312

def link_definition_owners
  Array(statements).filter_map do |statement|
    next unless statement.respond_to?(:merge_type) && statement.merge_type == :link_definition

    position = statement.source_position
    next unless position

    LinkDefinitionOwner.new(
      location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
      label: statement.label,
      url: statement.url,
      title: statement.title,
      source: if statement.respond_to?(:content)
                statement.content
              else
                source_range(position[:start_line], position[:end_line]).chomp
              end
    )
  end
end

#list_item_ownersObject



343
344
345
346
347
# File 'lib/markdown/merge/file_analysis.rb', line 343

def list_item_owners
  Array(statements).flat_map do |statement|
    collect_list_item_owners(unwrap_markdown_statement(statement), depth: 0)
  end.sort_by { |owner| [owner.location.start_line, owner.location.end_line] }
end

#next_sibling(node) ⇒ Object?

Get the next sibling of a node.

Handles differences between backends:

  • Commonmarker: node.next_sibling
  • Markly: node.next

Parameters:

  • node (Object)

    Current node

Returns:

  • (Object, nil)

    Next sibling or nil



142
143
144
145
146
147
148
149
# File 'lib/markdown/merge/file_analysis.rb', line 142

def next_sibling(node)
  # tree_haver normalizes this, but handle both patterns for safety
  if node.respond_to?(:next_sibling)
    node.next_sibling
  elsif node.respond_to?(:next)
    node.next
  end
end

#parse_document(source) ⇒ Object?

Parse the source document using tree_haver backend.

Error handling follows the same pattern as other *-merge gems:

  • TreeHaver::Error (which inherits from Exception, not StandardError) is caught
  • TreeHaver::NotAvailable is a subclass of TreeHaver::Error, so it's also caught
  • When an error occurs, the error is stored in @errors and nil is returned
  • SmartMergerBase#parse_and_analyze checks valid? and raises the appropriate parse error

Parameters:

  • source (String)

    Markdown source to parse

Returns:

  • (Object, nil)

    Root document node from tree_haver, or nil on error



124
125
126
127
128
129
130
131
132
# File 'lib/markdown/merge/file_analysis.rb', line 124

def parse_document(source)
  tree = @parser.parse(source)
  tree.root_node
rescue TreeHaver::Error => e
  # TreeHaver::Error inherits from Exception, not StandardError.
  # This also catches TreeHaver::NotAvailable (subclass of Error).
  @errors << e.message
  nil
end

#parser_node?(value) ⇒ Boolean

Check if value is a tree_haver node.

Parameters:

  • value (Object)

    Value to check

Returns:

  • (Boolean)

    true if this is a parser node



162
163
164
165
166
167
168
# File 'lib/markdown/merge/file_analysis.rb', line 162

def parser_node?(value)
  # Check for tree_haver node or wrapped node
  return true if value.respond_to?(:type) && value.respond_to?(:source_position)
  return true if Ast::Merge::NodeTyping.typed_node?(value)

  false
end

#safe_string_content(node) ⇒ String

Safely get string content from a node.

Override for tree_haver nodes which use text instead of string_content.

Parameters:

  • node (Object)

    The node

Returns:

  • (String)

    String content or empty string



266
267
268
269
270
271
272
273
274
275
276
# File 'lib/markdown/merge/file_analysis.rb', line 266

def safe_string_content(node)
  if node.respond_to?(:string_content)
    node.string_content.to_s
  elsif node.respond_to?(:text)
    node.text.to_s
  else
    extract_text_content(node)
  end
rescue TypeError, NoMethodError
  extract_text_content(node)
end

#table_row_ownersObject



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
# File 'lib/markdown/merge/file_analysis.rb', line 355

def table_row_owners
  ast_table_lines = {}
  ast_rows = Array(statements).flat_map do |statement|
    node = unwrap_markdown_statement(statement)
    next [] unless node.respond_to?(:type) && node.type.to_s == 'table'

    table_position = node.source_position
    if table_position
      (table_position[:start_line]..table_position[:end_line]).each do |line|
        ast_table_lines[line] = true
      end
    end
    Array(node.children).filter_map do |child|
      next unless child.respond_to?(:type) && child.type.to_s == 'table_row'

      position = child.source_position
      next unless position

      TableRowOwner.new(
        location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
        source: source_range(position[:start_line], position[:end_line]),
        text: extract_text_content(child)
      )
    end
  end
  ast_lines = ast_rows.map { |owner| owner.location.start_line }.to_h { |line| [line, true] }
  loose_rows = source.to_s.lines.each_with_index.filter_map do |line, index|
    line_number = index + 1
    next if ast_lines[line_number]
    next if ast_table_lines[line_number]
    next unless loose_table_row_line?(line)

    TableRowOwner.new(
      location: Location.new(start_line: line_number, end_line: line_number),
      source: line,
      text: line
    )
  end
  ast_rows + loose_rows
end