Class: Markdown::Merge::FileAnalysisBase Abstract
- Inherits:
-
Object
- Object
- Markdown::Merge::FileAnalysisBase
- Includes:
- Ast::Merge::FileAnalyzable
- Defined in:
- lib/markdown/merge/file_analysis_base.rb
Overview
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 -->
Direct Known Subclasses
Constant Summary collapse
- DEFAULT_FREEZE_TOKEN =
Default freeze token for identifying freeze blocks
'markdown-merge'
Instance Attribute Summary collapse
-
#comment_tracker ⇒ CommentTracker
readonly
Comment tracker for this file.
-
#document ⇒ Object
readonly
The root document node.
-
#errors ⇒ Array
readonly
Parse errors if any.
-
#statements ⇒ Array<Object, FreezeNode>
readonly
Get all statements (block nodes outside freeze blocks + FreezeNode instances).
Instance Method Summary collapse
-
#comment_attachment_for(owner, **options) ⇒ Object
Build a passive shared comment attachment for an owner.
- #comment_attachment_strategy ⇒ Symbol
-
#comment_augmenter(owners: nil, **options) ⇒ Object
Build a passive shared comment augmenter for this analysis.
-
#comment_capability ⇒ Object
Get shared comment capability information for this analysis.
-
#comment_node_at(line_num) ⇒ Object?
Get a shared comment node at a specific line.
-
#comment_nodes ⇒ Array
Get all tracked comments converted to shared comment nodes.
-
#comment_region_for_range(range, kind:, full_line_only: false) ⇒ Object
Get comments in a line range converted to a shared comment region.
-
#comment_support_style ⇒ Ast::Merge::Comment::SupportStyle
Describe how Markdown merges currently own and emit comments.
-
#compute_node_signature(node) ⇒ Array?
Compute default signature for a node.
-
#compute_parser_signature(node) ⇒ Array?
abstract
Compute signature for a parser-specific node.
-
#extract_text_content(node) ⇒ String
Extract all text content from a node and its children.
-
#fallthrough_node?(value) ⇒ Boolean
Override to detect parser nodes for signature generator fallthrough.
-
#initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, **parser_options) ⇒ FileAnalysisBase
constructor
Initialize file analysis.
-
#next_sibling(node) ⇒ Object?
abstract
Get the next sibling of a node.
-
#parse_document(source) ⇒ Object
abstract
Parse the source document.
-
#parser_node?(value) ⇒ Boolean
Check if value is a parser-specific node.
- #ruleset_delegation_policies ⇒ Object
- #ruleset_logical_owners ⇒ Object
- #ruleset_surfaces ⇒ Object
-
#safe_string_content(node) ⇒ String
Safely get string content from a node.
-
#source_range(start_line, end_line) ⇒ String
Get the source text for a range of lines.
-
#valid? ⇒ Boolean
Check if parse was successful.
Constructor Details
#initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, **parser_options) ⇒ FileAnalysisBase
Initialize file analysis
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, **) @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 = @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_tracker ⇒ CommentTracker (readonly)
Returns Comment tracker for this file.
51 52 53 |
# File 'lib/markdown/merge/file_analysis_base.rb', line 51 def comment_tracker @comment_tracker end |
#document ⇒ Object (readonly)
Returns The root document node.
45 46 47 |
# File 'lib/markdown/merge/file_analysis_base.rb', line 45 def document @document end |
#errors ⇒ Array (readonly)
Returns Parse errors if any.
48 49 50 |
# File 'lib/markdown/merge/file_analysis_base.rb', line 48 def errors @errors end |
#statements ⇒ Array<Object, FreezeNode> (readonly)
Get all statements (block nodes outside freeze blocks + FreezeNode instances)
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.
171 172 173 174 175 176 177 178 179 |
# File 'lib/markdown/merge/file_analysis_base.rb', line 171 def (owner, **) = comment_augmenter(**).(owner) ( owner, tracker_attachment: || comment_tracker.(owner, **), ** ) end |
#comment_attachment_strategy ⇒ Symbol
182 183 184 |
# File 'lib/markdown/merge/file_analysis_base.rb', line 182 def :normalize_tracked_layout_merge end |
#comment_augmenter(owners: nil, **options) ⇒ Object
Build a passive shared comment augmenter for this analysis.
209 210 211 212 213 214 |
# File 'lib/markdown/merge/file_analysis_base.rb', line 209 def comment_augmenter(owners: nil, **) comment_tracker.augment( owners: owners || comment_augmenter_default_owners, ** ) end |
#comment_capability ⇒ Object
Get shared comment capability information for this analysis.
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.
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_nodes ⇒ Array
Get all tracked comments converted to shared comment nodes.
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.
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_style ⇒ Ast::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.
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
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?
Subclasses should override this method
Compute signature for a parser-specific node.
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
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
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?
Subclasses must implement this method
Get the next sibling of a node.
Different parsers use different methods (next vs next_sibling).
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
Subclasses must implement this method
Parse the source document.
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.
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_policies ⇒ Object
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_owners ⇒ Object
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_surfaces ⇒ Object
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
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).
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
112 113 114 |
# File 'lib/markdown/merge/file_analysis_base.rb', line 112 def valid? @errors.empty? && !@document.nil? end |