ast-merge 2.0.10 → 3.1.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9659c263184e27b07b7fa51f976e398c82f5da9610411fbe867e2a7319042ba8
4
- data.tar.gz: 5dda81d19a9deb7bc2c9cfad0a8ab28b348cfb243ced1b453495bdef232d446f
3
+ metadata.gz: 4ce2b23f374b6f178709f84a66ccbed7b997a05d170d7b1f63ede714fc844469
4
+ data.tar.gz: d0ae1bc694c669108b5ddaac372424c02f8528f3c59642ced6fb7bb1af67dcf4
5
5
  SHA512:
6
- metadata.gz: 129fb9114b909bf56c1adacbf0b0f3b1527a006d12ad4df61f8f209fee482f681a18f67f4cea21f994fac7ee268ac032ef567ff3fb4dec5ed178f88506c122c5
7
- data.tar.gz: 671afd9a6960261dd3918577a8111e05bd71ecd9291388938e93baeb62b29002036e3e36d23e78e8bada44f0cd47d8e385df6052d252281cd4ea1b49bb057b9c
6
+ metadata.gz: 41981f4b37122e224ecc1b3047156ec15871742fbca09b8203acb91c27df7acf61164871c2ee7eac07db32a12acccc7c8dda3b83b8cd7dafd8f86ade604a82a6
7
+ data.tar.gz: 29c07de568c15bc8a78b6363c438e6c7902af5f24d8d441aaa9f9417bceeedb62107f40e95a226864be3492b16373ae4493efa0ed956c780448bf4a3168980b9
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,76 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [3.1.0] - 2026-01-08
34
+
35
+ - TAG: [v3.1.0][3.1.0t]
36
+ - COVERAGE: 96.89% -- 2465/2544 lines in 47 files
37
+ - BRANCH COVERAGE: 89.62% -- 794/886 branches in 47 files
38
+ - 98.75% documented
39
+
40
+ ### Added
41
+
42
+ - `Ast::Merge::EmitterBase` - Abstract base class for format-specific emitters
43
+ - Provides common infrastructure for converting AST structures back to text
44
+ - Tracks indentation level with configurable `indent_size` (default: 2 spaces)
45
+ - Manages output lines collection with `#lines` accessor
46
+ - `#emit_blank_line` - Emit an empty line
47
+ - `#emit_leading_comments` - Emit comments from CommentTracker
48
+ - `#emit_raw_lines` - Emit lines without modification (preserves exact formatting)
49
+ - `#to_s` - Get output as a single string with trailing newline
50
+ - `#clear` - Reset emitter state
51
+ - `#indent` / `#dedent` - Increase/decrease indentation level
52
+ - Subclass hooks: `#initialize_subclass_state`, `#clear_subclass_state`, `#emit_tracked_comment`
53
+ - Used by jsonc-merge, json-merge, bash-merge, toml-merge, and psych-merge emitters
54
+
55
+ ### Changed
56
+
57
+ - tree_haver v4.0.0
58
+
59
+ ## [3.0.0] - 2026-01-05
60
+
61
+ - TAG: [v3.0.0][3.0.0t]
62
+ - COVERAGE: 96.93% -- 2462/2540 lines in 47 files
63
+ - BRANCH COVERAGE: 89.62% -- 794/886 branches in 47 files
64
+ - 98.72% documented
65
+
66
+ ### Added
67
+
68
+ - `TestableNode` spec helper class that wraps a mock in a real `TreeHaver::Node`, providing consistent API testing without relying on fragile mocks
69
+ - `Recipe::Preset#match_refiner` accessor method (was missing, causing errors in Recipe::Runner)
70
+ - Minimal reproduction specs for `to_commonmark` normalization behavior:
71
+ - `spec/integration/link_reference_preservation_spec.rb` - tests link ref preservation
72
+ - `spec/integration/table_formatting_preservation_spec.rb` - tests table padding preservation
73
+ - `Ast::Merge::PartialTemplateMergerBase` - Abstract base class for parser-agnostic partial template merging
74
+ - `#build_position_based_signature_generator` - Creates signature generators that match elements by position
75
+ - Position counters reset per document key, enabling tables at same position to match regardless of structure
76
+
77
+ ### Changed
78
+
79
+ - **BREAKING**: `NavigableStatement#text` now requires nodes to conform to TreeHaver Node API (must have `#text` method)
80
+ - Removed conditional fallbacks for `to_plaintext`, `to_commonmark`, `slice`
81
+ - Nodes must now implement `#text` directly (all TreeHaver backends already do)
82
+ - **BREAKING**: `ContentMatchRefiner#extract_content` now requires nodes to conform to TreeHaver Node API
83
+ - Removed conditional fallbacks for `text_content`, `string_content`, `content`, `to_s`
84
+ - Custom `content_extractor` proc still supported for non-standard nodes
85
+ - Signature generators and typing scripts now receive TreeHaver nodes directly (no NavigableStatement wrapping)
86
+ - Removed NavigableStatement wrapping from `FileAnalyzable#generate_signature` and `NodeTyping.process`
87
+
88
+ ### Removed
89
+
90
+ - **BREAKING**: `Ast::Merge::PartialTemplateMerger` removed. Use `Markdown::Merge::PartialTemplateMerger` directly.
91
+ - The base class `Ast::Merge::PartialTemplateMergerBase` remains for other parsers to extend
92
+ - Migration: change `Ast::Merge::PartialTemplateMerger.new(parser: :markly, ...)` to
93
+ `Markdown::Merge::PartialTemplateMerger.new(backend: :markly, ...)`
94
+
95
+ ### Fixed
96
+
97
+ - **Source-based rendering**: `Markdown::Merge::PartialTemplateMerger#node_to_text` now prefers extracting
98
+ original source text using `analysis.source_range` instead of `to_commonmark`. This preserves:
99
+ - Link reference definitions (no conversion to inline links)
100
+ - Table column padding/alignment
101
+ - Original formatting exactly as written
102
+
33
103
  ## [2.0.10] - 2026-01-04
34
104
 
35
105
  - TAG: [v2.0.10][2.0.10t]
@@ -463,7 +533,11 @@ Please file a bug if you notice a violation of semantic versioning.
463
533
 
464
534
  - Initial release
465
535
 
466
- [Unreleased]: https://github.com/kettle-rb/ast-merge/compare/v2.0.10...HEAD
536
+ [Unreleased]: https://github.com/kettle-rb/ast-merge/compare/v3.1.0...HEAD
537
+ [3.1.0]: https://github.com/kettle-rb/ast-merge/compare/v3.0.0...v3.1.0
538
+ [3.1.0t]: https://github.com/kettle-rb/ast-merge/releases/tag/v3.1.0
539
+ [3.0.0]: https://github.com/kettle-rb/ast-merge/compare/v2.0.10...v3.0.0
540
+ [3.0.0t]: https://github.com/kettle-rb/ast-merge/releases/tag/v3.0.0
467
541
  [2.0.10]: https://github.com/kettle-rb/ast-merge/compare/v2.0.9...v2.0.10
468
542
  [2.0.10t]: https://github.com/kettle-rb/ast-merge/releases/tag/v2.0.10
469
543
  [2.0.9]: https://github.com/kettle-rb/ast-merge/compare/v2.0.8...v2.0.9
data/README.md CHANGED
@@ -75,12 +75,36 @@ The `*-merge` gem family provides intelligent, AST-based merging for various fil
75
75
  | [rbs-merge][rbs-merge] | RBS | [tree-sitter-bash][ts-rbs] (via tree_haver), [RBS][rbs] (`rbs` std lib gem) | Smart merge for Ruby type signatures |
76
76
  | [toml-merge][toml-merge] | TOML | [Citrus + toml-rb][toml-rb] (default, via tree_haver), [tree-sitter-toml][ts-toml] (via tree_haver) | Smart merge for TOML files |
77
77
 
78
+ #### Backend Platform Compatibility
79
+
80
+ tree_haver supports multiple parsing backends, but not all backends work on all Ruby platforms:
81
+
82
+ | Platform 👉️<br> TreeHaver Backend 👇️ | MRI | JRuby | TruffleRuby | Notes |
83
+ |------------------------------------------------|:---:|:-----:|:-----------:|-----------------------------------------------------|
84
+ | **MRI** ([ruby_tree_sitter][ruby_tree_sitter]) | ✅ | ❌ | ❌ | C extension, MRI only |
85
+ | **Rust** ([tree_stump][tree_stump]) | ✅ | ❌ | ❌ | Rust extension via magnus/rb-sys, MRI only |
86
+ | **FFI** | ✅ | ✅ | ❌ | TruffleRuby's FFI doesn't support `STRUCT_BY_VALUE` |
87
+ | **Java** ([jtreesitter][jtreesitter]) | ❌ | ✅ | ❌ | JRuby only, requires grammar JARs |
88
+ | **Prism** | ✅ | ✅ | ✅ | Ruby parsing, stdlib in Ruby 3.4+ |
89
+ | **Psych** | ✅ | ✅ | ✅ | YAML parsing, stdlib |
90
+ | **Citrus** | ✅ | ✅ | ✅ | Pure Ruby, no native dependencies |
91
+ | **Commonmarker** | ✅ | ❌ | ❓ | Rust extension for Markdown |
92
+ | **Markly** | ✅ | ❌ | ❓ | C extension for Markdown |
93
+
94
+ **Legend**: ✅ = Works, ❌ = Does not work, ❓ = Untested
95
+
96
+ **Why some backends don't work on certain platforms**:
97
+
98
+ - **JRuby**: Runs on the JVM; cannot load native C/Rust extensions (`.so` files)
99
+ - **TruffleRuby**: Has C API emulation via Sulong/LLVM, but it doesn't expose all MRI internals that native extensions require (e.g., `RBasic.flags`, `rb_gc_writebarrier`)
100
+ - **FFI on TruffleRuby**: TruffleRuby's FFI implementation doesn't support returning structs by value, which tree-sitter's C API requires
101
+
78
102
  **Example implementations** for the gem templating use case:
79
103
 
80
- | Gem | Purpose | Description |
81
- | --- | --- | --- |
82
- | [kettle-dev](https://github.com/kettle-rb/kettle-dev) | Gem Development | Gem templating tool using `*-merge` gems |
83
- | [kettle-jem](https://github.com/kettle-rb/kettle-jem) | Gem Templating | Gem template library with smart merge support |
104
+ | Gem | Purpose | Description |
105
+ |--------------------------|-----------------|-----------------------------------------------|
106
+ | [kettle-dev][kettle-dev] | Gem Development | Gem templating tool using `*-merge` gems |
107
+ | [kettle-jem][kettle-jem] | Gem Templating | Gem template library with smart merge support |
84
108
 
85
109
  [tree_haver]: https://github.com/kettle-rb/tree_haver
86
110
  [ast-merge]: https://github.com/kettle-rb/ast-merge
@@ -100,8 +124,11 @@ The `*-merge` gem family provides intelligent, AST-based merging for various fil
100
124
  [prism]: https://github.com/ruby/prism
101
125
  [psych]: https://github.com/ruby/psych
102
126
  [ts-json]: https://github.com/tree-sitter/tree-sitter-json
127
+ [ts-jsonc]: https://gitlab.com/WhyNotHugo/tree-sitter-jsonc
103
128
  [ts-bash]: https://github.com/tree-sitter/tree-sitter-bash
129
+ [ts-rbs]: https://github.com/joker1007/tree-sitter-rbs
104
130
  [ts-toml]: https://github.com/tree-sitter-grammars/tree-sitter-toml
131
+ [dotenv]: https://github.com/bkeepers/dotenv
105
132
  [rbs]: https://github.com/ruby/rbs
106
133
  [toml-rb]: https://github.com/emancu/toml-rb
107
134
  [markly]: https://github.com/ioquatix/markly
@@ -110,10 +137,6 @@ The `*-merge` gem family provides intelligent, AST-based merging for various fil
110
137
  [tree_stump]: https://github.com/joker1007/tree_stump
111
138
  [jtreesitter]: https://central.sonatype.com/artifact/io.github.tree-sitter/jtreesitter
112
139
 
113
-
114
- [ts-jsonc]: https://gitlab.com/WhyNotHugo/tree-sitter-jsonc
115
- [dotenv]: https://github.com/bkeepers/dotenv
116
-
117
140
  #### Backend Platform Compatibility
118
141
 
119
142
  tree\_haver supports multiple parsing backends, but not all backends work on all Ruby platforms:
@@ -1033,7 +1056,7 @@ Thanks for RTFM. ☺️
1033
1056
  [📌gitmoji]: https://gitmoji.dev
1034
1057
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1035
1058
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1036
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.721-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1059
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-2.544-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1037
1060
  [🔐security]: SECURITY.md
1038
1061
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1039
1062
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -140,28 +140,17 @@ module Ast
140
140
 
141
141
  # Extract text content from a node.
142
142
  #
143
- # Uses the custom content_extractor if provided, otherwise tries
144
- # common methods for getting text content.
143
+ # Uses the custom content_extractor if provided, otherwise uses the
144
+ # standard #text method that all TreeHaver nodes provide.
145
145
  #
146
- # @param node [Object] The node
146
+ # @param node [Object] The node (must conform to TreeHaver Node API)
147
147
  # @return [String] The text content
148
148
  def extract_content(node)
149
149
  return @content_extractor.call(node) if @content_extractor
150
150
 
151
- # Try common content extraction methods
152
- if node.respond_to?(:text_content)
153
- node.text_content.to_s
154
- elsif node.respond_to?(:string_content)
155
- node.string_content.to_s
156
- elsif node.respond_to?(:content)
157
- node.content.to_s
158
- elsif node.respond_to?(:text)
159
- node.text.to_s
160
- elsif node.respond_to?(:to_s)
161
- node.to_s
162
- else
163
- ""
164
- end
151
+ # TreeHaver nodes (and any node conforming to the unified API) provide #text.
152
+ # No conditional fallbacks - nodes must conform to the API.
153
+ node.text.to_s
165
154
  end
166
155
 
167
156
  # Compute similarity score between two nodes based on content.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Base class for emitters that convert AST structures back to text.
6
+ # Provides common functionality for tracking indentation, managing output lines,
7
+ # and handling comments.
8
+ #
9
+ # Subclasses implement format-specific emission methods (e.g., emit_pair for JSON,
10
+ # emit_variable_assignment for Bash, etc.)
11
+ #
12
+ # @example Implementing a custom emitter
13
+ # class MyEmitter < Ast::Merge::EmitterBase
14
+ # def emit_my_construct(data)
15
+ # add_comma_if_needed if @needs_separator
16
+ # @lines << "#{current_indent}my_syntax: #{data}"
17
+ # @needs_separator = true
18
+ # end
19
+ # end
20
+ class EmitterBase
21
+ # @return [Array<String>] Output lines
22
+ attr_reader :lines
23
+
24
+ # @return [Integer] Current indentation level
25
+ attr_reader :indent_level
26
+
27
+ # @return [Integer] Spaces per indent level
28
+ attr_reader :indent_size
29
+
30
+ # Initialize a new emitter
31
+ #
32
+ # @param indent_size [Integer] Number of spaces per indent level
33
+ # @param options [Hash] Additional options for subclasses
34
+ def initialize(indent_size: 2, **options)
35
+ @lines = []
36
+ @indent_level = 0
37
+ @indent_size = indent_size
38
+ initialize_subclass_state(**options)
39
+ end
40
+
41
+ # Hook for subclasses to initialize their own state
42
+ # @param options [Hash] Additional options
43
+ def initialize_subclass_state(**options)
44
+ # Override in subclasses if needed
45
+ end
46
+
47
+ # Emit a blank line
48
+ def emit_blank_line
49
+ @lines << ""
50
+ end
51
+
52
+ # Emit leading comments from CommentTracker
53
+ #
54
+ # @param comments [Array<Hash>] Comment hashes with :text, :indent, etc.
55
+ def emit_leading_comments(comments)
56
+ comments.each do |comment|
57
+ emit_tracked_comment(comment)
58
+ end
59
+ end
60
+
61
+ # Emit a comment from CommentTracker hash
62
+ # Subclasses should override this to handle format-specific comment syntax
63
+ #
64
+ # @param comment [Hash] Comment hash with :text, :indent, :block, etc.
65
+ def emit_tracked_comment(comment)
66
+ raise NotImplementedError, "Subclasses must implement emit_tracked_comment"
67
+ end
68
+
69
+ # Emit raw lines as-is (for preserving exact formatting)
70
+ #
71
+ # @param raw_lines [Array<String>] Lines to emit without modification
72
+ def emit_raw_lines(raw_lines)
73
+ raw_lines.each { |line| @lines << line.chomp }
74
+ end
75
+
76
+ # Get the output as a single string
77
+ # Subclasses may override to customize output format (e.g., to_json, to_yaml)
78
+ #
79
+ # @return [String]
80
+ def to_s
81
+ content = @lines.join("\n")
82
+ content += "\n" unless content.empty? || content.end_with?("\n")
83
+ content
84
+ end
85
+
86
+ # Clear the emitter state
87
+ def clear
88
+ @lines = []
89
+ @indent_level = 0
90
+ clear_subclass_state
91
+ end
92
+
93
+ # Hook for subclasses to clear their own state
94
+ def clear_subclass_state
95
+ # Override in subclasses if needed
96
+ end
97
+
98
+ # Increase indentation level
99
+ def indent
100
+ @indent_level += 1
101
+ end
102
+
103
+ # Decrease indentation level
104
+ def dedent
105
+ @indent_level -= 1 if @indent_level > 0
106
+ end
107
+
108
+ protected
109
+
110
+ # Get the current indentation string
111
+ # @return [String]
112
+ def current_indent
113
+ " " * (@indent_level * @indent_size)
114
+ end
115
+
116
+ # Add a line with current indentation
117
+ # @param content [String] Line content
118
+ def add_indented_line(content)
119
+ @lines << "#{current_indent}#{content}"
120
+ end
121
+ end
122
+ end
123
+ end
@@ -224,6 +224,10 @@ module Ast
224
224
  # - Type checks work (e.g., `node.is_a?(Prism::CallNode)`)
225
225
  # - The generator sees the real AST structure
226
226
  # - Frozen nodes match by their underlying identity
227
+ #
228
+ # NOTE: For TreeHaver-based backends, the node already has a unified API
229
+ # with #text, #type, #source_position methods. For other backends, they
230
+ # must conform to the same API (either via TreeHaver or equivalent adapter).
227
231
  custom_result = signature_generator.call(actual_node)
228
232
  case custom_result
229
233
  when Array, nil
@@ -354,6 +354,15 @@ module Ast
354
354
  true
355
355
  end
356
356
 
357
+ # Node type for merge classification
358
+ # @return [Symbol] :freeze_block
359
+ def merge_type
360
+ :freeze_block
361
+ end
362
+
363
+ # Alias for compatibility
364
+ alias_method :type, :merge_type
365
+
357
366
  # Returns a stable signature for this freeze block.
358
367
  # Override in subclasses for file-type-specific normalization.
359
368
  # @return [Array] Signature array
@@ -270,17 +270,9 @@ module Ast
270
270
 
271
271
  # @return [String] Node text content
272
272
  def text
273
- if node.respond_to?(:to_plaintext)
274
- node.to_plaintext.to_s
275
- elsif node.respond_to?(:to_commonmark)
276
- node.to_commonmark.to_s
277
- elsif node.respond_to?(:slice)
278
- node.slice.to_s
279
- elsif node.respond_to?(:text)
280
- node.text.to_s
281
- else
282
- node.to_s
283
- end
273
+ # TreeHaver nodes (and any node conforming to the unified API) provide #text.
274
+ # No conditional fallbacks - nodes must conform to the API.
275
+ node.text.to_s
284
276
  end
285
277
 
286
278
  # @return [Hash, nil] Source position info
@@ -13,6 +13,27 @@ module Ast
13
13
  # The `merge_type` attribute can then be used by other merge tools like
14
14
  # `signature_generator`, `match_refiner`, and per-node-type `preference` settings.
15
15
  #
16
+ # ## Important: Two Uses of merge_type
17
+ #
18
+ # The `merge_type` method serves two complementary purposes in the codebase:
19
+ #
20
+ # ### 1. NodeTyping-specific (gated by typed_node?)
21
+ # Wrapped nodes (Wrapper/FrozenWrapper) with custom type tagging for:
22
+ # - Per-node-type preferences (e.g., `:lint_gem` → `:template`)
23
+ # - Match refinement based on custom categories
24
+ # - Only applies when `typed_node?` returns true
25
+ # - Accessed via `NodeTyping.merge_type_for(node)`
26
+ #
27
+ # ### 2. General node classification (any node)
28
+ # Any node can implement `merge_type` for category identification:
29
+ # - FreezeNodeBase has `merge_type` → `:freeze_block`
30
+ # - GapLineNode has `merge_type` → `:gap_line`
31
+ # - Used by systems like MarkdownStructure for structural spacing rules
32
+ # - These nodes are NOT "typed nodes" (typed_node? returns false)
33
+ #
34
+ # The key distinction: **typed_node? is the gate** for NodeTyping wrapper
35
+ # semantics. A node can have `merge_type` without being a NodeTyping wrapper.
36
+ #
16
37
  # @example Basic node typing for different gem types
17
38
  # node_typing = {
18
39
  # CallNode: ->(node) {
@@ -139,7 +160,10 @@ module Ast
139
160
  callable = find_typing_callable(typing_config, type_key, node)
140
161
  return node unless callable
141
162
 
142
- # Call the typing callable with the node
163
+ # Call the typing callable with the node.
164
+ # NOTE: For TreeHaver-based backends, the node already has a unified API
165
+ # with #text, #type, #source_position methods. For other backends, they
166
+ # must conform to the same API (either via TreeHaver or equivalent adapter).
143
167
  callable.call(node)
144
168
  end
145
169
 
@@ -2,36 +2,23 @@
2
2
 
3
3
  module Ast
4
4
  module Merge
5
- # Merges a partial template into a specific section of a destination document.
5
+ # Base class for merging a partial template into a specific section of a destination document.
6
6
  #
7
- # Unlike the full SmartMerger which merges entire documents, PartialTemplateMerger:
7
+ # Unlike the full SmartMerger which merges entire documents, PartialTemplateMergerBase:
8
8
  # 1. Finds a specific section in the destination (using InjectionPoint)
9
9
  # 2. Replaces/merges only that section with the template
10
10
  # 3. Leaves the rest of the destination unchanged
11
11
  #
12
- # This is useful for updating a specific section (like a "Gem Family" section)
13
- # across multiple files while preserving file-specific content.
12
+ # This is an abstract base class. Subclasses must implement:
13
+ # - #create_analysis(content) - Create a FileAnalysis for the given content
14
+ # - #create_smart_merger(template, section) - Create a SmartMerger for the section merge
15
+ # - #find_section_end(statements, injection_point) - Find where the section ends
16
+ # - #node_to_text(node, analysis) - Convert a node to source text
14
17
  #
15
- # @example Basic usage
16
- # merger = PartialTemplateMerger.new(
17
- # template: template_content,
18
- # destination: destination_content,
19
- # anchor: { type: :heading, text: /Gem Family/ },
20
- # parser: :markly
21
- # )
22
- # result = merger.merge
23
- # puts result.content
18
+ # @abstract Subclass and implement parser-specific methods
19
+ # @see Markdown::Merge::PartialTemplateMerger For markdown implementation
24
20
  #
25
- # @example With boundary
26
- # merger = PartialTemplateMerger.new(
27
- # template: template_content,
28
- # destination: destination_content,
29
- # anchor: { type: :heading, text: /Installation/ },
30
- # boundary: { type: :heading }, # Stop at next heading
31
- # parser: :markly
32
- # )
33
- #
34
- class PartialTemplateMerger
21
+ class PartialTemplateMergerBase
35
22
  # Result of a partial template merge
36
23
  class Result
37
24
  # @return [String] The merged content
@@ -79,9 +66,6 @@ module Ast
79
66
  # @return [Hash, nil] Boundary matcher configuration
80
67
  attr_reader :boundary
81
68
 
82
- # @return [Symbol] Parser to use (:markly, :commonmarker, etc.)
83
- attr_reader :parser
84
-
85
69
  # @return [Symbol, Hash] Merge preference (:template, :destination, or per-type hash)
86
70
  attr_reader :preference
87
71
 
@@ -97,43 +81,46 @@ module Ast
97
81
  # @return [Hash, nil] Node typing configuration for per-type preferences
98
82
  attr_reader :node_typing
99
83
 
100
- # Initialize a PartialTemplateMerger.
84
+ # @return [Object, nil] Match refiner for fuzzy matching unmatched nodes
85
+ attr_reader :match_refiner
86
+
87
+ # Initialize a PartialTemplateMergerBase.
101
88
  #
102
89
  # @param template [String] The template content (the section to merge in)
103
90
  # @param destination [String] The destination content
104
91
  # @param anchor [Hash] Anchor matcher: { type: :heading, text: /pattern/ }
105
92
  # @param boundary [Hash, nil] Boundary matcher (defaults to same type as anchor)
106
- # @param parser [Symbol] Parser to use (:markly, :commonmarker, :prism, :psych)
107
93
  # @param preference [Symbol, Hash] Which content wins (:template, :destination, or per-type hash)
108
94
  # @param add_missing [Boolean, Proc] Whether to add template nodes not in destination
109
95
  # @param when_missing [Symbol] What to do if section not found (:skip, :append, :prepend)
110
96
  # @param replace_mode [Boolean] If true, template replaces section entirely (no merge)
111
97
  # @param signature_generator [Proc, nil] Custom signature generator for SmartMerger
112
98
  # @param node_typing [Hash, nil] Node typing configuration for per-type preferences
99
+ # @param match_refiner [Object, nil] Match refiner for fuzzy matching (e.g., ContentMatchRefiner)
113
100
  def initialize(
114
101
  template:,
115
102
  destination:,
116
103
  anchor:,
117
104
  boundary: nil,
118
- parser: :markly,
119
105
  preference: :template,
120
106
  add_missing: true,
121
107
  when_missing: :skip,
122
108
  replace_mode: false,
123
109
  signature_generator: nil,
124
- node_typing: nil
110
+ node_typing: nil,
111
+ match_refiner: nil
125
112
  )
126
113
  @template = template
127
114
  @destination = destination
128
115
  @anchor = normalize_matcher(anchor)
129
116
  @boundary = boundary ? normalize_matcher(boundary) : nil
130
- @parser = parser
131
117
  @preference = preference
132
118
  @add_missing = add_missing
133
119
  @when_missing = when_missing
134
120
  @replace_mode = replace_mode
135
121
  @signature_generator = signature_generator
136
122
  @node_typing = node_typing
123
+ @match_refiner = match_refiner
137
124
  end
138
125
 
139
126
  # Perform the partial template merge.
@@ -161,6 +148,47 @@ module Ast
161
148
  perform_section_merge(d_analysis, d_statements, injection_point)
162
149
  end
163
150
 
151
+ protected
152
+
153
+ # Create a FileAnalysis for the given content.
154
+ #
155
+ # @abstract Subclasses must implement this method
156
+ # @param content [String] The content to analyze
157
+ # @return [Object] A FileAnalysis instance
158
+ def create_analysis(content)
159
+ raise NotImplementedError, "#{self.class} must implement #create_analysis"
160
+ end
161
+
162
+ # Create a SmartMerger for merging the section.
163
+ #
164
+ # @abstract Subclasses must implement this method
165
+ # @param template_content [String] The template content
166
+ # @param destination_content [String] The destination section content
167
+ # @return [Object] A SmartMerger instance
168
+ def create_smart_merger(template_content, destination_content)
169
+ raise NotImplementedError, "#{self.class} must implement #create_smart_merger"
170
+ end
171
+
172
+ # Find where the section ends.
173
+ #
174
+ # @abstract Subclasses must implement this method
175
+ # @param statements [Array<NavigableStatement>] All statements
176
+ # @param injection_point [InjectionPoint] The injection point
177
+ # @return [Integer] Index of the last statement in the section
178
+ def find_section_end(statements, injection_point)
179
+ raise NotImplementedError, "#{self.class} must implement #find_section_end"
180
+ end
181
+
182
+ # Convert a node to its source text.
183
+ #
184
+ # @abstract Subclasses must implement this method
185
+ # @param node [Object] The node to convert
186
+ # @param analysis [Object, nil] The analysis object for source lookup
187
+ # @return [String] The source text
188
+ def node_to_text(node, analysis = nil)
189
+ raise NotImplementedError, "#{self.class} must implement #node_to_text"
190
+ end
191
+
164
192
  private
165
193
 
166
194
  def normalize_matcher(matcher)
@@ -187,17 +215,10 @@ module Ast
187
215
  end
188
216
  end
189
217
 
190
- def handle_missing_section(_d_analysis)
218
+ def handle_missing_section(d_analysis)
191
219
  case when_missing
192
- when :skip
193
- Result.new(
194
- content: destination,
195
- has_section: false,
196
- changed: false,
197
- message: "Section not found, skipping",
198
- )
199
220
  when :append
200
- # Append template at end of document
221
+ # Append template to end of destination
201
222
  new_content = destination.chomp + "\n\n" + template
202
223
  Result.new(
203
224
  content: new_content,
@@ -206,7 +227,7 @@ module Ast
206
227
  message: "Section not found, appended template",
207
228
  )
208
229
  when :prepend
209
- # Prepend template at start (after any frontmatter)
230
+ # Prepend template to beginning of destination
210
231
  new_content = template + "\n\n" + destination
211
232
  Result.new(
212
233
  content: new_content,
@@ -219,12 +240,12 @@ module Ast
219
240
  content: destination,
220
241
  has_section: false,
221
242
  changed: false,
222
- message: "Section not found, no action taken",
243
+ message: "Section not found, skipping",
223
244
  )
224
245
  end
225
246
  end
226
247
 
227
- def perform_section_merge(_d_analysis, d_statements, injection_point)
248
+ def perform_section_merge(d_analysis, d_statements, injection_point)
228
249
  # Determine section boundaries in destination
229
250
  section_start_idx = injection_point.anchor.index
230
251
  section_end_idx = find_section_end(d_statements, injection_point)
@@ -235,12 +256,12 @@ module Ast
235
256
  after_statements = d_statements[(section_end_idx + 1)..]
236
257
 
237
258
  # Determine the merged section content
238
- section_content = statements_to_content(section_statements)
259
+ section_content = statements_to_content(section_statements, d_analysis)
239
260
  merged_section, stats = merge_section_content(section_content)
240
261
 
241
- # Reconstruct the document
242
- before_content = statements_to_content(before_statements)
243
- after_content = statements_to_content(after_statements)
262
+ # Reconstruct the document using source-based extraction
263
+ before_content = statements_to_content(before_statements, d_analysis)
264
+ after_content = statements_to_content(after_statements, d_analysis)
244
265
 
245
266
  new_content = build_merged_content(before_content, merged_section, after_content)
246
267
 
@@ -280,83 +301,15 @@ module Ast
280
301
  @replace_mode == true
281
302
  end
282
303
 
283
- def find_section_end(statements, injection_point)
284
- # If boundary was specified and found, use it (exclusive - section ends before boundary)
285
- if injection_point.boundary
286
- return injection_point.boundary.index - 1
287
- end
288
-
289
- # Otherwise, find the next node of same type (for headings, same or higher level)
290
- anchor = injection_point.anchor
291
- anchor_type = anchor.type
292
-
293
- # For headings, find next heading of same or higher level
294
- if heading_type?(anchor_type)
295
- anchor_level = get_heading_level(anchor)
296
-
297
- ((anchor.index + 1)...statements.length).each do |idx|
298
- stmt = statements[idx]
299
- if heading_type?(stmt.type)
300
- stmt_level = get_heading_level(stmt)
301
- if stmt_level && anchor_level && stmt_level <= anchor_level
302
- # Found next heading of same or higher level - section ends before it
303
- return idx - 1
304
- end
305
- end
306
- end
307
- else
308
- # For non-headings, find next node of same type
309
- ((anchor.index + 1)...statements.length).each do |idx|
310
- stmt = statements[idx]
311
- if stmt.type == anchor_type
312
- return idx - 1
313
- end
314
- end
315
- end
316
-
317
- # Section extends to end of document
318
- statements.length - 1
319
- end
320
-
321
- def heading_type?(type)
322
- type.to_s == "heading" || type == :heading || type == :header
323
- end
324
-
325
- def get_heading_level(stmt)
326
- inner = stmt.respond_to?(:unwrapped_node) ? stmt.unwrapped_node : stmt.node
327
-
328
- if inner.respond_to?(:header_level)
329
- inner.header_level
330
- elsif inner.respond_to?(:level)
331
- inner.level
332
- end
333
- end
334
-
335
- def statements_to_content(statements)
304
+ def statements_to_content(statements, analysis = nil)
336
305
  return "" if statements.nil? || statements.empty?
337
306
 
338
307
  statements.map do |stmt|
339
308
  node = stmt.respond_to?(:node) ? stmt.node : stmt
340
- node_to_text(node)
309
+ node_to_text(node, analysis)
341
310
  end.join
342
311
  end
343
312
 
344
- def node_to_text(node)
345
- # Unwrap if needed
346
- inner = node
347
- while inner.respond_to?(:inner_node) && inner.inner_node != inner
348
- inner = inner.inner_node
349
- end
350
-
351
- if inner.respond_to?(:to_commonmark)
352
- inner.to_commonmark.to_s
353
- elsif inner.respond_to?(:to_s)
354
- inner.to_s
355
- else
356
- ""
357
- end
358
- end
359
-
360
313
  def build_merged_content(before, section, after)
361
314
  result = +""
362
315
 
@@ -388,54 +341,6 @@ module Ast
388
341
  result << "\n" unless result.empty? || result.end_with?("\n")
389
342
  result
390
343
  end
391
-
392
- def create_analysis(content)
393
- case parser
394
- when :markly
395
- require "markly/merge" unless defined?(Markly::Merge)
396
- Markly::Merge::FileAnalysis.new(content)
397
- when :commonmarker
398
- require "commonmarker/merge" unless defined?(Commonmarker::Merge)
399
- Commonmarker::Merge::FileAnalysis.new(content)
400
- when :prism
401
- require "prism/merge" unless defined?(Prism::Merge)
402
- Prism::Merge::FileAnalysis.new(content)
403
- when :psych
404
- require "psych/merge" unless defined?(Psych::Merge)
405
- Psych::Merge::FileAnalysis.new(content)
406
- else
407
- raise ArgumentError, "Unknown parser: #{parser}"
408
- end
409
- end
410
-
411
- def create_smart_merger(template_content, destination_content)
412
- merger_class = case parser
413
- when :markly
414
- require "markly/merge" unless defined?(Markly::Merge)
415
- Markly::Merge::SmartMerger
416
- when :commonmarker
417
- require "commonmarker/merge" unless defined?(Commonmarker::Merge)
418
- Commonmarker::Merge::SmartMerger
419
- when :prism
420
- require "prism/merge" unless defined?(Prism::Merge)
421
- Prism::Merge::SmartMerger
422
- when :psych
423
- require "psych/merge" unless defined?(Psych::Merge)
424
- Psych::Merge::SmartMerger
425
- else
426
- raise ArgumentError, "Unknown parser: #{parser}"
427
- end
428
-
429
- # Build options hash, only including non-nil values
430
- options = {
431
- preference: preference,
432
- add_template_only_nodes: add_missing,
433
- }
434
- options[:signature_generator] = signature_generator if signature_generator
435
- options[:node_typing] = node_typing if node_typing
436
-
437
- merger_class.new(template_content, destination_content, **options)
438
- end
439
344
  end
440
345
  end
441
346
  end
@@ -124,6 +124,17 @@ module Ast
124
124
  script_loader.load_callable_hash(value)
125
125
  end
126
126
 
127
+ # Get the match_refiner callable, loading from script if needed.
128
+ #
129
+ # @return [Object, nil] Match refiner instance or callable
130
+ def match_refiner
131
+ value = merge_config[:match_refiner]
132
+ return if value.nil?
133
+ return value if value.respond_to?(:call) || value.is_a?(Ast::Merge::MatchRefinerBase)
134
+
135
+ script_loader.load_callable(value)
136
+ end
137
+
127
138
  # Convert preset to a hash suitable for SmartMerger options.
128
139
  #
129
140
  # @return [Hash]
@@ -133,6 +144,7 @@ module Ast
133
144
  add_template_only_nodes: add_missing,
134
145
  signature_generator: signature_generator,
135
146
  node_typing: node_typing,
147
+ match_refiner: match_refiner,
136
148
  freeze_token: freeze_token,
137
149
  }.compact
138
150
  end
@@ -155,6 +167,7 @@ module Ast
155
167
  deep: config["deep"] == true,
156
168
  signature_generator: config["signature_generator"],
157
169
  node_typing: config["node_typing"],
170
+ match_refiner: config["match_refiner"],
158
171
  }
159
172
  end
160
173
 
@@ -144,19 +144,19 @@ module Ast
144
144
  begin
145
145
  destination_content = File.read(target_path)
146
146
 
147
- # Use PartialTemplateMerger which handles finding injection point and merging
148
- merger = PartialTemplateMerger.new(
147
+ # Use the appropriate PartialTemplateMerger based on parser
148
+ merger = create_partial_template_merger(
149
149
  template: template_content,
150
150
  destination: destination_content,
151
151
  anchor: recipe.injection[:anchor] || {},
152
152
  boundary: recipe.injection[:boundary],
153
- parser: parser,
154
153
  preference: recipe.preference,
155
154
  add_missing: recipe.add_missing,
156
155
  when_missing: recipe.when_missing,
157
156
  replace_mode: recipe.replace_mode?,
158
157
  signature_generator: recipe.signature_generator,
159
158
  node_typing: recipe.node_typing,
159
+ match_refiner: recipe.match_refiner,
160
160
  )
161
161
 
162
162
  result = merger.merge
@@ -179,6 +179,26 @@ module Ast
179
179
  end
180
180
  end
181
181
 
182
+ # Create the appropriate PartialTemplateMerger based on parser type.
183
+ #
184
+ # @param options [Hash] Merger options
185
+ # @return [Object] A PartialTemplateMerger instance
186
+ def create_partial_template_merger(**options)
187
+ case parser.to_sym
188
+ when :markly, :commonmarker
189
+ require "markdown/merge" unless defined?(Markdown::Merge)
190
+ Markdown::Merge::PartialTemplateMerger.new(backend: parser, **options)
191
+ when :prism
192
+ require "prism/merge" unless defined?(Prism::Merge)
193
+ raise NotImplementedError, "Prism PartialTemplateMerger not yet implemented"
194
+ when :psych
195
+ require "psych/merge" unless defined?(Psych::Merge)
196
+ raise NotImplementedError, "Psych PartialTemplateMerger not yet implemented"
197
+ else
198
+ raise ArgumentError, "Unknown parser: #{parser}. Supported: :markly, :commonmarker"
199
+ end
200
+ end
201
+
182
202
  def create_result_from_merge(target_path, relative_path, _destination_content, merge_result)
183
203
  changed = merge_result.changed
184
204
 
@@ -5,7 +5,7 @@ module Ast
5
5
  # Version information for Ast::Merge
6
6
  module Version
7
7
  # Current version of the ast-merge gem
8
- VERSION = "2.0.10"
8
+ VERSION = "3.1.0"
9
9
  end
10
10
  VERSION = Version::VERSION # traditional location
11
11
  end
data/lib/ast/merge.rb CHANGED
@@ -142,6 +142,7 @@ module Ast
142
142
  autoload :ConflictResolverBase, "ast/merge/conflict_resolver_base"
143
143
  autoload :ContentMatchRefiner, "ast/merge/content_match_refiner"
144
144
  autoload :DebugLogger, "ast/merge/debug_logger"
145
+ autoload :EmitterBase, "ast/merge/emitter_base"
145
146
  autoload :FileAnalyzable, "ast/merge/file_analyzable"
146
147
  autoload :Freezable, "ast/merge/freezable"
147
148
  autoload :FreezeNodeBase, "ast/merge/freeze_node_base"
@@ -154,7 +155,7 @@ module Ast
154
155
  autoload :NavigableStatement, "ast/merge/navigable_statement"
155
156
  autoload :NodeTyping, "ast/merge/node_typing"
156
157
  autoload :NodeWrapperBase, "ast/merge/node_wrapper_base"
157
- autoload :PartialTemplateMerger, "ast/merge/partial_template_merger"
158
+ autoload :PartialTemplateMergerBase, "ast/merge/partial_template_merger_base"
158
159
  autoload :SectionTyping, "ast/merge/section_typing"
159
160
  autoload :SmartMergerBase, "ast/merge/smart_merger_base"
160
161
  autoload :Text, "ast/merge/text"
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ast-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.10
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -63,20 +63,20 @@ dependencies:
63
63
  requirements:
64
64
  - - "~>"
65
65
  - !ruby/object:Gem::Version
66
- version: '3.2'
66
+ version: '4.0'
67
67
  - - ">="
68
68
  - !ruby/object:Gem::Version
69
- version: 3.2.3
69
+ version: 4.0.0
70
70
  type: :runtime
71
71
  prerelease: false
72
72
  version_requirements: !ruby/object:Gem::Requirement
73
73
  requirements:
74
74
  - - "~>"
75
75
  - !ruby/object:Gem::Version
76
- version: '3.2'
76
+ version: '4.0'
77
77
  - - ">="
78
78
  - !ruby/object:Gem::Version
79
- version: 3.2.3
79
+ version: 4.0.0
80
80
  - !ruby/object:Gem::Dependency
81
81
  name: kettle-dev
82
82
  requirement: !ruby/object:Gem::Requirement
@@ -309,6 +309,7 @@ files:
309
309
  - lib/ast/merge/detector/mergeable.rb
310
310
  - lib/ast/merge/detector/toml_frontmatter.rb
311
311
  - lib/ast/merge/detector/yaml_frontmatter.rb
312
+ - lib/ast/merge/emitter_base.rb
312
313
  - lib/ast/merge/file_analyzable.rb
313
314
  - lib/ast/merge/freezable.rb
314
315
  - lib/ast/merge/freeze_node_base.rb
@@ -322,7 +323,7 @@ files:
322
323
  - lib/ast/merge/node_typing/normalizer.rb
323
324
  - lib/ast/merge/node_typing/wrapper.rb
324
325
  - lib/ast/merge/node_wrapper_base.rb
325
- - lib/ast/merge/partial_template_merger.rb
326
+ - lib/ast/merge/partial_template_merger_base.rb
326
327
  - lib/ast/merge/recipe.rb
327
328
  - lib/ast/merge/recipe/config.rb
328
329
  - lib/ast/merge/recipe/preset.rb
@@ -356,10 +357,10 @@ licenses:
356
357
  - MIT
357
358
  metadata:
358
359
  homepage_uri: https://ast-merge.galtzo.com/
359
- source_code_uri: https://github.com/kettle-rb/ast-merge/tree/v2.0.10
360
- changelog_uri: https://github.com/kettle-rb/ast-merge/blob/v2.0.10/CHANGELOG.md
360
+ source_code_uri: https://github.com/kettle-rb/ast-merge/tree/v3.1.0
361
+ changelog_uri: https://github.com/kettle-rb/ast-merge/blob/v3.1.0/CHANGELOG.md
361
362
  bug_tracker_uri: https://github.com/kettle-rb/ast-merge/issues
362
- documentation_uri: https://www.rubydoc.info/gems/ast-merge/2.0.10
363
+ documentation_uri: https://www.rubydoc.info/gems/ast-merge/3.1.0
363
364
  funding_uri: https://github.com/sponsors/pboling
364
365
  wiki_uri: https://github.com/kettle-rb/ast-merge/wiki
365
366
  news_uri: https://www.railsbling.com/tags/ast-merge
metadata.gz.sig CHANGED
Binary file