ast-merge 1.0.0 → 1.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: a871b962617929e2c8ed159fd0b971f0f45eb2ed023927fb7d07be2213c26b2f
4
- data.tar.gz: 7dbc474b7d376d4388c00bae81e6bbeb8108faf37e19c6a2c6a87e045f09ad0f
3
+ metadata.gz: 86acfad0e867d5098d34fa4ec30fca07677e8b094a4bbeee27afa4dd236f6ba8
4
+ data.tar.gz: 61b3c5e9b24b4bc396dfa6562ebdf85d7a27153b67f96d53e977d04cc668c653
5
5
  SHA512:
6
- metadata.gz: 9f91eb7a729e6c6c1dff895a715a01c6230ff97c4e87ee697ac5d7c01581f0bc94591c619bd00f5f1304209974d5d769336e89b032e8983a6e1c812cb799abe6
7
- data.tar.gz: 48f14b886f7a2fb7fd547e9aee58cc28f6217193957271995b2917e295bab8d6b62faca337793278982c7f9d89e953aea6b6bb21ce6834ef5a20f1053769fd9b
6
+ metadata.gz: d610aa4ba48cd7b4476c041412dcb661761464a49966af6045af8f3c0e0a6f87cb75b89d1f706caa58505fab9cdbee9aa3681f9b615a79f6db3fb31f2f25aabb
7
+ data.tar.gz: 5dfbdb16cebda587a4c2a460b71a3a6a794ab6dcee048d293f1b39aa679291368e221348439974782ec2cfc5cfca3b9d4f62cb12b566931e34913dfc3a8790dc
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -20,14 +20,49 @@ Please file a bug if you notice a violation of semantic versioning.
20
20
 
21
21
  ### Added
22
22
 
23
+ - **tree_haver Integration**: Major architectural enhancement
24
+ - Added `tree_haver` (~> 3.1) as a runtime dependency
25
+ - `Ast::Merge::AstNode` now implements the TreeHaver::Node protocol for compatibility with tree_haver-based merge operations
26
+ - Adds: `type`, `kind`, `text`, `start_byte`, `end_byte`, `start_point`, `end_point`, `children`, `child_count`, `child(index)`, `each`, `named?`, `structural?`, `has_error?`, `missing?`, `inner_node`
27
+ - Adds `Point` struct compatible with `TreeHaver::Point`
28
+ - Adds `SyntheticNode` alias for clarity (synthetic = not backed by a real parser)
29
+ - `Comment::Line`, `Comment::Block`, `Comment::Empty` now have explicit `type` methods
30
+ - `Text::LineNode` and `Text::WordNode` now inherit from `AstNode`, gaining TreeHaver::Node protocol compliance
31
+ - This enables `*-merge` gems to leverage tree_haver's cross-Ruby parsing capabilities (MRI, JRuby, TruffleRuby)
32
+ - **Documentation**: Comprehensive updates across the gem family
33
+ - Updated all vendor gem READMEs with standardized gem family tables
34
+ - Added `tree_haver` as the foundation layer in architecture documentation
35
+ - Clarified the two-layer architecture: tree_haver (parsing) → ast-merge (merge infrastructure)
36
+ - Added detailed documentation to `FencedCodeBlockDetector` explaining when to use native AST nodes vs text-based detection
37
+ - Updated markdown-merge documentation to highlight inner code block merging capabilities
38
+ - **Example Scripts**: Added comprehensive examples demonstrating inner-merge capabilities
39
+ - `examples/markdown_code_merge.rb` - Shows how markdown-merge delegates to language-specific parsers for semantic merging
40
+ - Documentation proving that language-specific parsers create full ASTs of embedded code blocks
41
+
23
42
  ### Changed
24
43
 
44
+ - **Architecture**: Refactored to use tree_haver as the parsing foundation
45
+ - All tree-sitter-based gems (bash-merge, json-merge, jsonc-merge, toml-merge) now use tree_haver
46
+ - Parser-specific gems (prism-merge, psych-merge, markdown-merge, markly-merge, commonmarker-merge) use tree_haver backends
47
+ - Provides unified API across different Ruby implementations and parsing backends
48
+ - **Documentation Structure**: Standardized gem family tables across all 12 vendor gems
49
+ - Changed from 3-column to 4-column format: Gem | Format | Parser Backend(s) | Description
50
+ - All parser backends now annotated with "(via tree_haver)" where applicable
51
+ - ast-merge description updated from "Shared infrastructure" to "**Infrastructure**: Shared base classes and merge logic"
52
+ - markdown-merge description updated to "**Foundation**: Shared base for Markdown mergers with inner code block merging"
53
+ - **Configuration Documentation**: Enhanced backend selection documentation
54
+
25
55
  ### Deprecated
26
56
 
27
57
  ### Removed
28
58
 
29
59
  ### Fixed
30
60
 
61
+ - Fixed gemspec and Appraisals alignment with tree_haver requirements
62
+ - Fixed CI workflow conditions and retry logic
63
+ - Fixed badge rendering in documentation
64
+ - Fixed README structure issues (removed H3 duplicates, standardized gem family tables)
65
+
31
66
  ### Security
32
67
 
33
68
  ## [1.0.0] - 2025-12-12
data/README.md CHANGED
@@ -42,7 +42,7 @@
42
42
 
43
43
  # ☯️ Ast::Merge
44
44
 
45
- [![Version][👽versioni]][👽version] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: MIT][📄license-img]][📄license-ref] [![Downloads Rank][👽dl-ranki]][👽dl-rank] [![Open Source Helpers][👽oss-helpi]][👽oss-help] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![CI JRuby][🚎10-j-wfi]][🚎10-j-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Supported][🚎6-s-wfi]][🚎6-s-wf] [![CI Legacy][🚎4-lg-wfi]][🚎4-lg-wf] [![CI Unsupported][🚎7-us-wfi]][🚎7-us-wf] [![CI Ancient][🚎1-an-wfi]][🚎1-an-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf] [![CodeQL][🖐codeQL-img]][🖐codeQL] [![Apache SkyWalking Eyes License Compatibility Check][🚎15-🪪-wfi]][🚎15-🪪-wf]
45
+ [![Version][👽versioni]][👽version] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: MIT][📄license-img]][📄license-ref] [![Downloads Rank][👽dl-ranki]][👽dl-rank] [![Open Source Helpers][👽oss-helpi]][👽oss-help] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Supported][🚎6-s-wfi]][🚎6-s-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf] [![CodeQL][🖐codeQL-img]][🖐codeQL] [![Apache SkyWalking Eyes License Compatibility Check][🚎15-🪪-wfi]][🚎15-🪪-wf]
46
46
 
47
47
  `if ci_badges.map(&:color).detect { it != "green"}` ☝️ [let me know][🖼️galtzo-discord], as I may have missed the [discord notification][🖼️galtzo-discord].
48
48
 
@@ -58,20 +58,23 @@ Ast::Merge is **not typically used directly** - instead, use one of the format-s
58
58
 
59
59
  ### The `*-merge` Gem Family
60
60
 
61
- | Gem | Format | Parser | Description |
62
- |-----|--------|--------|-------------|
63
- | [ast-merge][ast-merge] | Text | internal | Shared infrastructure for all `*-merge` gems |
64
- | [prism-merge][prism-merge] | Ruby | [Prism][prism] | Smart merge for Ruby source files |
65
- | [psych-merge][psych-merge] | YAML | [Psych][psych] | Smart merge for YAML files |
66
- | [json-merge][json-merge] | JSON | [tree-sitter-json][ts-json] | Smart merge for JSON files |
67
- | [jsonc-merge][jsonc-merge] | JSONC | [tree-sitter-jsonc][ts-jsonc] | ⚠️ Proof of concept; Smart merge for JSON with Comments |
68
- | [bash-merge][bash-merge] | Bash | [tree-sitter-bash][ts-bash] | Smart merge for Bash scripts |
61
+ The `*-merge` gem family provides intelligent, AST-based merging for various file formats. At the foundation is [tree_haver][tree_haver], which provides a unified cross-Ruby parsing API that works seamlessly across MRI, JRuby, and TruffleRuby.
62
+
63
+ | Gem | Format | Parser Backend(s) | Description |
64
+ |-----|--------|-------------------|-------------|
65
+ | [tree_haver][tree_haver] | Multi | MRI C, Rust, FFI, Java, Prism, Psych, Commonmarker, Markly, Citrus | **Foundation**: Cross-Ruby adapter for parsing libraries (like Faraday for HTTP) |
66
+ | [ast-merge][ast-merge] | Text | internal | **Infrastructure**: Shared base classes and merge logic for all `*-merge` gems |
67
+ | [prism-merge][prism-merge] | Ruby | [Prism][prism] (via tree_haver) | Smart merge for Ruby source files |
68
+ | [psych-merge][psych-merge] | YAML | [Psych][psych] (via tree_haver) | Smart merge for YAML files |
69
+ | [json-merge][json-merge] | JSON | [tree-sitter-json][ts-json] (via tree_haver) | Smart merge for JSON files |
70
+ | [jsonc-merge][jsonc-merge] | JSONC | [tree-sitter-jsonc][ts-jsonc] (via tree_haver) | ⚠️ Proof of concept; Smart merge for JSON with Comments |
71
+ | [bash-merge][bash-merge] | Bash | [tree-sitter-bash][ts-bash] (via tree_haver) | Smart merge for Bash scripts |
69
72
  | [rbs-merge][rbs-merge] | RBS | [RBS][rbs] | Smart merge for Ruby type signatures |
70
73
  | [dotenv-merge][dotenv-merge] | Dotenv | internal ([dotenv][dotenv]) | Smart merge for `.env` files |
71
- | [toml-merge][toml-merge] | TOML | [tree-sitter-toml][ts-toml] | Smart merge for TOML files |
72
- | [markdown-merge][markdown-merge] | Markdown | _base classes_ | Shared foundation for Markdown mergers |
73
- | [markly-merge][markly-merge] | Markdown | [Markly][markly] | Smart merge for Markdown (CommonMark via libcmark-gfm) |
74
- | [commonmarker-merge][commonmarker-merge] | Markdown | [Commonmarker][commonmarker] | Smart merge for Markdown (CommonMark via comrak) |
74
+ | [toml-merge][toml-merge] | TOML | [tree-sitter-toml][ts-toml] (via tree_haver) | Smart merge for TOML files |
75
+ | [markdown-merge][markdown-merge] | Markdown | [Commonmarker][commonmarker] / [Markly][markly] (via tree_haver) | **Foundation**: Shared base for Markdown mergers with inner code block merging |
76
+ | [markly-merge][markly-merge] | Markdown | [Markly][markly] (via tree_haver) | Smart merge for Markdown (CommonMark via cmark-gfm C) |
77
+ | [commonmarker-merge][commonmarker-merge] | Markdown | [Commonmarker][commonmarker] (via tree_haver) | Smart merge for Markdown (CommonMark via comrak Rust) |
75
78
 
76
79
  **Example implementations** for the gem templating use case:
77
80
 
@@ -80,6 +83,7 @@ Ast::Merge is **not typically used directly** - instead, use one of the format-s
80
83
  | [kettle-dev][kettle-dev] | Gem Development | Gem templating tool using `*-merge` gems |
81
84
  | [kettle-jem][kettle-jem] | Gem Templating | Gem template library with smart merge support |
82
85
 
86
+ [tree_haver]: https://github.com/kettle-rb/tree_haver
83
87
  [ast-merge]: https://github.com/kettle-rb/ast-merge
84
88
  [prism-merge]: https://github.com/kettle-rb/prism-merge
85
89
  [psych-merge]: https://github.com/kettle-rb/psych-merge
@@ -102,15 +106,34 @@ Ast::Merge is **not typically used directly** - instead, use one of the format-s
102
106
  [ts-toml]: https://github.com/tree-sitter-grammars/tree-sitter-toml
103
107
  [rbs]: https://github.com/ruby/rbs
104
108
  [dotenv]: https://github.com/bkeepers/dotenv
105
- [markly]: https://github.com/kivikakk/markly
109
+ [markly]: https://github.com/ioquatix/markly
106
110
  [commonmarker]: https://github.com/gjtorikian/commonmarker
107
111
 
108
- ### What Ast::Merge Provides
112
+ ### Architecture: tree_haver + ast-merge
113
+
114
+ The `*-merge` gem family is built on a two-layer architecture:
115
+
116
+ #### Layer 1: tree_haver (Parsing Foundation)
117
+
118
+ [tree_haver][tree_haver] provides cross-Ruby parsing capabilities:
119
+
120
+ - **Universal Backend Support**: Automatically selects the best parsing backend for your Ruby implementation (MRI, JRuby, TruffleRuby)
121
+ - **10 Backend Options**: MRI C extensions, Rust bindings, FFI, Java (JRuby), language-specific parsers (Prism, Psych, Commonmarker, Markly), and pure Ruby fallback (Citrus)
122
+ - **Unified API**: Write parsing code once, run on any Ruby implementation
123
+ - **Grammar Discovery**: Built-in `GrammarFinder` for platform-aware grammar library discovery
124
+ - **Thread-Safe**: Language registry with thread-safe caching
125
+
126
+ #### Layer 2: ast-merge (Merge Infrastructure)
127
+
128
+ Ast::Merge builds on tree_haver to provide:
109
129
 
110
130
  - **Base Classes**: `FreezeNode`, `MergeResult` base classes with unified constructors
111
- - **Shared Modules**: `FileAnalysisBase`, `MergerConfig`, `DebugLogger`
112
- - **Freeze Block Support**: Configurable marker patterns for multiple comment syntaxes
131
+ - **Shared Modules**: `FileAnalysisBase`, `FileAnalyzable`, `MergerConfig`, `DebugLogger`
132
+ - **Freeze Block Support**: Configurable marker patterns for multiple comment syntaxes (preserve sections during merge)
133
+ - **Node Typing System**: `NodeTyping` for canonical node type identification across different parsers
134
+ - **Conflict Resolution**: `ConflictResolverBase` with pluggable strategies
113
135
  - **Error Classes**: `ParseError`, `TemplateParseError`, `DestinationParseError`
136
+ - **Region Detection**: `RegionDetectorBase`, `FencedCodeBlockDetector` for text-based analysis
114
137
  - **RSpec Shared Examples**: Test helpers for implementing new merge gems
115
138
 
116
139
  ### Creating a New Merge Gem
@@ -765,26 +788,16 @@ Thanks for RTFM. ☺️
765
788
  [🏀coveralls-img]: https://coveralls.io/repos/github/kettle-rb/ast-merge/badge.svg?branch=main
766
789
  [🖐codeQL]: https://github.com/kettle-rb/ast-merge/security/code-scanning
767
790
  [🖐codeQL-img]: https://github.com/kettle-rb/ast-merge/actions/workflows/codeql-analysis.yml/badge.svg
768
- [🚎1-an-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/ancient.yml
769
- [🚎1-an-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/ancient.yml/badge.svg
770
791
  [🚎2-cov-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/coverage.yml
771
792
  [🚎2-cov-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/coverage.yml/badge.svg
772
793
  [🚎3-hd-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/heads.yml
773
794
  [🚎3-hd-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/heads.yml/badge.svg
774
- [🚎4-lg-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/legacy.yml
775
- [🚎4-lg-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/legacy.yml/badge.svg
776
795
  [🚎5-st-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/style.yml
777
796
  [🚎5-st-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/style.yml/badge.svg
778
797
  [🚎6-s-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/supported.yml
779
798
  [🚎6-s-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/supported.yml/badge.svg
780
- [🚎7-us-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/unsupported.yml
781
- [🚎7-us-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/unsupported.yml/badge.svg
782
- [🚎8-ho-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/hoary.yml
783
- [🚎8-ho-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/hoary.yml/badge.svg
784
799
  [🚎9-t-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/truffle.yml
785
800
  [🚎9-t-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/truffle.yml/badge.svg
786
- [🚎10-j-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/jruby.yml
787
- [🚎10-j-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/jruby.yml/badge.svg
788
801
  [🚎11-c-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/current.yml
789
802
  [🚎11-c-wfi]: https://github.com/kettle-rb/ast-merge/actions/workflows/current.yml/badge.svg
790
803
  [🚎12-crh-wf]: https://github.com/kettle-rb/ast-merge/actions/workflows/dep-heads.yml
@@ -2,27 +2,78 @@
2
2
 
3
3
  module Ast
4
4
  module Merge
5
- # Base class for AST nodes in the ast-merge framework.
5
+ # Base class for synthetic AST nodes in the ast-merge framework.
6
6
  #
7
- # This provides a common API that works across different AST implementations
8
- # (Prism, TreeSitter, custom comment nodes, etc.) enabling uniform handling
9
- # in merge operations.
7
+ # "Synthetic" nodes are nodes that aren't backed by a real parser - they're
8
+ # created by ast-merge for representing content that doesn't have a native
9
+ # AST (comments, text lines, env file entries, etc.).
10
10
  #
11
- # Subclasses should implement:
12
- # - #slice - returns the source text for the node
13
- # - #location - returns an object responding to start_line/end_line
14
- # - #children - returns child nodes (empty array for leaf nodes)
15
- # - #signature - returns a signature array for matching (optional, can use default)
11
+ # This class implements the TreeHaver::Node protocol, making it compatible
12
+ # with all code that expects TreeHaver nodes. This allows synthetic nodes
13
+ # to be used interchangeably with parser-backed nodes in merge operations.
16
14
  #
17
- # @abstract
15
+ # Implements the TreeHaver::Node protocol:
16
+ # - type → String node type
17
+ # - text / slice → Source text content
18
+ # - start_byte / end_byte → Byte offsets
19
+ # - start_point / end_point → Point (row, column)
20
+ # - children → Array of child nodes
21
+ # - named? / structural? → Node classification
22
+ # - inner_node → Returns self (no wrapping layer for synthetic nodes)
23
+ #
24
+ # Adds merge-specific methods:
25
+ # - signature → Array used for matching nodes across files
26
+ # - normalized_content → Cleaned text for comparison
27
+ #
28
+ # @example Subclassing for custom node types
29
+ # class MyNode < AstNode
30
+ # def type
31
+ # "my_node"
32
+ # end
33
+ #
34
+ # def signature
35
+ # [:my_node, normalized_content]
36
+ # end
37
+ # end
38
+ #
39
+ # @see TreeHaver::Node The protocol this class implements
40
+ # @see Comment::Line Example synthetic node for comments
41
+ # @see Text::LineNode Example synthetic node for text lines
18
42
  class AstNode
19
- # Simple location struct for nodes that don't have a native location object
43
+ include Comparable
44
+
45
+ # Point class compatible with TreeHaver::Point
46
+ # Provides both method and hash-style access to row/column
47
+ Point = Struct.new(:row, :column, keyword_init: true) do
48
+ # Hash-like access for compatibility
49
+ def [](key)
50
+ case key
51
+ when :row, "row" then row
52
+ when :column, "column" then column
53
+ end
54
+ end
55
+
56
+ def to_h
57
+ {row: row, column: column}
58
+ end
59
+
60
+ def to_s
61
+ "(#{row}, #{column})"
62
+ end
63
+
64
+ def inspect
65
+ "#<Ast::Merge::AstNode::Point row=#{row} column=#{column}>"
66
+ end
67
+ end
68
+
69
+ # Location struct for tracking source positions
70
+ # Compatible with TreeHaver location expectations
20
71
  Location = Struct.new(:start_line, :end_line, :start_column, :end_column, keyword_init: true) do
21
72
  # Check if a line number falls within this location
22
- # @param line_number [Integer] The line number to check
73
+ # @param line_number [Integer] The line number to check (1-based)
23
74
  # @return [Boolean] true if the line number is within the range
24
75
  def cover?(line_number)
25
- line_number >= start_line && line_number <= end_line
76
+ line_number.between?(start_line, end_line)
26
77
  end
27
78
  end
28
79
 
@@ -32,28 +83,164 @@ module Ast
32
83
  # @return [String] The source text for this node
33
84
  attr_reader :slice
34
85
 
86
+ # @return [String, nil] The full source text (for text extraction)
87
+ attr_reader :source
88
+
35
89
  # Initialize a new AstNode.
36
90
  #
37
91
  # @param slice [String] The source text for this node
38
- # @param location [Location, #start_line] Location object or anything responding to start_line/end_line
39
- def initialize(slice:, location:)
92
+ # @param location [Location, #start_line] Location object
93
+ # @param source [String, nil] Full source text (optional)
94
+ def initialize(slice:, location:, source: nil)
40
95
  @slice = slice
41
96
  @location = location
97
+ @source = source
42
98
  end
43
99
 
100
+ # TreeHaver::Node protocol: inner_node
101
+ # For synthetic nodes, this returns self (no wrapping layer)
102
+ #
103
+ # @return [AstNode] self
104
+ def inner_node
105
+ self
106
+ end
107
+
108
+ # TreeHaver::Node protocol: type
109
+ # Returns the node type as a string.
110
+ # Subclasses should override this with specific type names.
111
+ #
112
+ # @return [String] Node type
113
+ def type
114
+ # Default: derive from class name (MyNode → "my_node")
115
+ self.class.name.split("::").last
116
+ .gsub(/([A-Z])/, '_\1')
117
+ .downcase
118
+ .sub(/^_/, "")
119
+ end
120
+
121
+ # Alias for tree-sitter compatibility
122
+ alias_method :kind, :type
123
+
124
+ # TreeHaver::Node protocol: text
125
+ # @return [String] The source text
126
+ def text
127
+ slice.to_s
128
+ end
129
+
130
+ # TreeHaver::Node protocol: start_byte
131
+ # Calculates byte offset from source if available, otherwise estimates from lines
132
+ #
133
+ # @return [Integer] Starting byte offset
134
+ def start_byte
135
+ return 0 unless source && location
136
+
137
+ # Calculate byte offset from line/column
138
+ lines = source.lines
139
+ byte_offset = 0
140
+ (0...(location.start_line - 1)).each do |i|
141
+ byte_offset += lines[i]&.bytesize || 0
142
+ end
143
+ byte_offset + (location.start_column || 0)
144
+ end
145
+
146
+ # TreeHaver::Node protocol: end_byte
147
+ #
148
+ # @return [Integer] Ending byte offset
149
+ def end_byte
150
+ start_byte + slice.to_s.bytesize
151
+ end
152
+
153
+ # TreeHaver::Node protocol: start_point
154
+ # Returns a Point with row (0-based) and column
155
+ #
156
+ # @return [Point] Starting position
157
+ def start_point
158
+ Point.new(
159
+ row: (location&.start_line || 1) - 1, # Convert to 0-based
160
+ column: location&.start_column || 0,
161
+ )
162
+ end
163
+
164
+ # TreeHaver::Node protocol: end_point
165
+ # Returns a Point with row (0-based) and column
166
+ #
167
+ # @return [Point] Ending position
168
+ def end_point
169
+ Point.new(
170
+ row: (location&.end_line || 1) - 1, # Convert to 0-based
171
+ column: location&.end_column || 0,
172
+ )
173
+ end
174
+
175
+ # TreeHaver::Node protocol: children
44
176
  # @return [Array<AstNode>] Child nodes (empty for leaf nodes)
45
177
  def children
46
178
  []
47
179
  end
48
180
 
181
+ # TreeHaver::Node protocol: child_count
182
+ # @return [Integer] Number of children
183
+ def child_count
184
+ children.size
185
+ end
186
+
187
+ # TreeHaver::Node protocol: child(index)
188
+ # @param index [Integer] Child index
189
+ # @return [AstNode, nil] Child at index
190
+ def child(index)
191
+ children[index]
192
+ end
193
+
194
+ # TreeHaver::Node protocol: named?
195
+ # Synthetic nodes are always "named" (structural) nodes
196
+ #
197
+ # @return [Boolean] true
198
+ def named?
199
+ true
200
+ end
201
+
202
+ # TreeHaver::Node protocol: structural?
203
+ # Synthetic nodes are always structural
204
+ #
205
+ # @return [Boolean] true
206
+ def structural?
207
+ true
208
+ end
209
+
210
+ # TreeHaver::Node protocol: has_error?
211
+ # Synthetic nodes don't have parse errors
212
+ #
213
+ # @return [Boolean] false
214
+ def has_error?
215
+ false
216
+ end
217
+
218
+ # TreeHaver::Node protocol: missing?
219
+ # Synthetic nodes are never "missing"
220
+ #
221
+ # @return [Boolean] false
222
+ def missing?
223
+ false
224
+ end
225
+
226
+ # TreeHaver::Node protocol: each
227
+ # Iterate over children
228
+ #
229
+ # @yield [AstNode] Each child node
230
+ # @return [Enumerator, nil]
231
+ def each(&block)
232
+ return to_enum(__method__) unless block_given?
233
+ children.each(&block)
234
+ end
235
+
49
236
  # Generate a signature for this node for matching purposes.
50
237
  #
51
238
  # Override in subclasses for custom signature logic.
52
- # Default returns the node class name and a normalized form of the slice.
239
+ # Default returns the node type and a normalized form of the slice.
53
240
  #
54
241
  # @return [Array] Signature array for matching
55
242
  def signature
56
- [self.class.name, normalized_content]
243
+ [type.to_sym, normalized_content]
57
244
  end
58
245
 
59
246
  # @return [String] Normalized content for signature comparison
@@ -61,9 +248,22 @@ module Ast
61
248
  slice.to_s.strip
62
249
  end
63
250
 
251
+ # Comparable: compare nodes by position
252
+ #
253
+ # @param other [AstNode] node to compare with
254
+ # @return [Integer, nil] -1, 0, 1, or nil if not comparable
255
+ def <=>(other)
256
+ return unless other.respond_to?(:start_byte) && other.respond_to?(:end_byte)
257
+
258
+ cmp = start_byte <=> other.start_byte
259
+ return cmp if cmp.nonzero?
260
+
261
+ end_byte <=> other.end_byte
262
+ end
263
+
64
264
  # @return [String] Human-readable representation
65
265
  def inspect
66
- "#<#{self.class.name} lines=#{location.start_line}..#{location.end_line} slice=#{slice.to_s[0..50].inspect}>"
266
+ "#<#{self.class.name} type=#{type} lines=#{location&.start_line}..#{location&.end_line}>"
67
267
  end
68
268
 
69
269
  # @return [String] The source text
@@ -76,12 +276,12 @@ module Ast
76
276
  def unwrap
77
277
  self
78
278
  end
79
-
80
- # Check if this node responds to the Prism-style location API
81
- # @return [Boolean] true
82
- def respond_to_missing?(method, include_private = false)
83
- [:location, :slice].include?(method) || super
84
- end
85
279
  end
280
+
281
+ # Alias for clarity - SyntheticNode clearly indicates "not backed by a real parser"
282
+ # Use this alias when the distinction between synthetic and parser-backed nodes matters.
283
+ #
284
+ # @see AstNode
285
+ SyntheticNode = AstNode
86
286
  end
87
287
  end
@@ -41,6 +41,12 @@ module Ast
41
41
  # @return [Style] The comment style configuration
42
42
  attr_reader :style
43
43
 
44
+ # TreeHaver::Node protocol: type
45
+ # @return [String] "comment_block"
46
+ def type
47
+ "comment_block"
48
+ end
49
+
44
50
  # Initialize a new Block.
45
51
  #
46
52
  # For line-based comments, pass `children` array.
@@ -28,6 +28,12 @@ module Ast
28
28
  # @return [String] The actual line content (may have whitespace)
29
29
  attr_reader :text
30
30
 
31
+ # TreeHaver::Node protocol: type
32
+ # @return [String] "empty_line"
33
+ def type
34
+ "empty_line"
35
+ end
36
+
31
37
  # Initialize a new Empty line.
32
38
  #
33
39
  # @param line_number [Integer] The 1-based line number
@@ -38,6 +38,12 @@ module Ast
38
38
  # @return [Style] The comment style configuration
39
39
  attr_reader :style
40
40
 
41
+ # TreeHaver::Node protocol: type
42
+ # @return [String] "comment_line"
43
+ def type
44
+ "comment_line"
45
+ end
46
+
41
47
  # Initialize a new Line.
42
48
  #
43
49
  # @param text [String] The full comment text including delimiter
@@ -69,13 +69,15 @@ module Ast
69
69
  end
70
70
  end
71
71
 
72
- # Class method for convenient one-shot parsing.
73
- #
74
- # @param lines [Array<String>] Source lines
75
- # @param style [Style, Symbol, nil] Comment style
76
- # @return [Array<AstNode>] Parsed nodes
77
- def self.parse(lines, style: nil)
78
- new(lines, style: style).parse
72
+ class << self
73
+ # Parse lines as comments.
74
+ #
75
+ # @param lines [Array<String>] Source lines
76
+ # @param style [Style, Symbol, nil] Comment style
77
+ # @return [Array<AstNode>] Parsed nodes
78
+ def parse(lines, style: nil)
79
+ new(lines, style: style).parse
80
+ end
79
81
  end
80
82
 
81
83
  private
@@ -83,10 +83,14 @@ module Ast
83
83
 
84
84
  class << self
85
85
  # @return [String] Environment variable name to check for debug mode
86
+ # rubocop:disable ThreadSafety/ClassAndModuleAttributes - Configuration attribute, set once at load time
86
87
  attr_accessor :env_var_name
88
+ # rubocop:enable ThreadSafety/ClassAndModuleAttributes
87
89
 
88
90
  # @return [String] Prefix for log messages
91
+ # rubocop:disable ThreadSafety/ClassAndModuleAttributes - Configuration attribute, set once at load time
89
92
  attr_accessor :log_prefix
93
+ # rubocop:enable ThreadSafety/ClassAndModuleAttributes
90
94
 
91
95
  # Hook called when a module extends Ast::Merge::DebugLogger.
92
96
  # Sets up attr_accessor for env_var_name and log_prefix on the extending module,
@@ -8,6 +8,108 @@ module Ast
8
8
  # that have a specific language identifier. It can be configured for any
9
9
  # language: ruby, json, yaml, mermaid, etc.
10
10
  #
11
+ # ## When to Use This Detector
12
+ #
13
+ # **Use FencedCodeBlockDetector when:**
14
+ # - Working with raw Markdown text without parsing to AST
15
+ # - Quick extraction from strings without parser dependencies
16
+ # - Custom text processing requiring line-level precision
17
+ # - Operating on source text directly (e.g., linters, formatters)
18
+ #
19
+ # **Do NOT use FencedCodeBlockDetector when:**
20
+ # - Working with parsed Markdown AST (use native code block nodes instead)
21
+ # - Integrating with markdown-merge's CodeBlockMerger (it uses native nodes)
22
+ # - Using tree_haver's unified Markdown backend API
23
+ #
24
+ # ## Comparison: FencedCodeBlockDetector vs Native AST Nodes
25
+ #
26
+ # ### Native AST Approach (Preferred for AST-based Tools)
27
+ #
28
+ # When working with parsed Markdown AST via tree_haver (commonmarker/markly backends):
29
+ #
30
+ # ```ruby
31
+ # # markdown-merge's CodeBlockMerger uses this approach:
32
+ # language = node.fence_info.split(/\s+/).first # e.g., "ruby"
33
+ # content = node.string_content # Raw code inside block
34
+ #
35
+ # # Then delegate to language-specific parser:
36
+ # case language
37
+ # when "ruby"
38
+ # merger = Prism::Merge::SmartMerger.new(template, dest, preference: :destination)
39
+ # merged_content = merger.merge # Prism parses Ruby code into full AST!
40
+ # when "yaml"
41
+ # merger = Psych::Merge::SmartMerger.new(template, dest, preference: :destination)
42
+ # merged_content = merger.merge # Psych parses YAML into AST!
43
+ # when "json"
44
+ # merger = Json::Merge::SmartMerger.new(template, dest, preference: :destination)
45
+ # merged_content = merger.merge # JSON parser creates AST!
46
+ # when "bash"
47
+ # merger = Bash::Merge::SmartMerger.new(template, dest, preference: :destination)
48
+ # merged_content = merger.merge # tree-sitter parses bash into AST!
49
+ # end
50
+ # ```
51
+ #
52
+ # **Advantages of Native AST approach:**
53
+ # - ✓ Parser handles all edge cases (nested backticks, indentation, etc.)
54
+ # - ✓ Respects node boundaries from authoritative source
55
+ # - ✓ No regex brittleness
56
+ # - ✓ Automatic handling of ``` and ~~~ fence styles
57
+ # - ✓ Enables TRUE language-aware merging (not just text replacement)
58
+ # - ✓ Language-specific parsers create full ASTs of embedded code
59
+ # - ✓ Smart merging at semantic level (method definitions, YAML keys, JSON properties)
60
+ #
61
+ # ### Text-Based Approach (This Class)
62
+ #
63
+ # When working with raw text:
64
+ #
65
+ # ```ruby
66
+ # detector = FencedCodeBlockDetector.ruby
67
+ # regions = detector.detect_all(markdown_text)
68
+ # regions.each do |region|
69
+ # puts "Ruby code at lines #{region.start_line}-#{region.end_line}"
70
+ # # region.content is just a string - NO parsing happens
71
+ # end
72
+ # ```
73
+ #
74
+ # **Limitations of text-based approach:**
75
+ # - • Uses regex to find blocks (may miss edge cases)
76
+ # - • Returns strings, not parsed structures
77
+ # - • Cannot perform semantic merging
78
+ # - • Manual handling of fence variations
79
+ # - • No language-specific intelligence
80
+ #
81
+ # ## Real-World Example: markdown-merge Inner Code Block Merging
82
+ #
83
+ # When `inner_merge_code_blocks: true` is enabled in markdown-merge:
84
+ #
85
+ # 1. **Markdown Parser** (commonmarker/markly) parses markdown into AST
86
+ # - Creates code_block nodes with `fence_info` and `string_content`
87
+ #
88
+ # 2. **CodeBlockMerger** extracts code using native node properties:
89
+ # ```ruby
90
+ # language = node.fence_info.split(/\s+/).first
91
+ # template_code = template_node.string_content
92
+ # dest_code = dest_node.string_content
93
+ # ```
94
+ #
95
+ # 3. **Language-Specific Parser** creates FULL AST of the embedded code:
96
+ # - `Prism::Merge` → Prism parses Ruby into complete AST (ClassNode, DefNode, etc.)
97
+ # - `Psych::Merge` → Psych parses YAML into document structure
98
+ # - `Json::Merge` → JSON parser creates object/array tree
99
+ # - `Bash::Merge` → tree-sitter creates bash statement AST
100
+ #
101
+ # 4. **Smart Merger** performs SEMANTIC merging at AST level:
102
+ # - Ruby: Merges class definitions, preserves custom methods
103
+ # - YAML: Merges keys, preserves custom configuration values
104
+ # - JSON: Merges objects, destination values win on conflicts
105
+ # - Bash: Merges statements, preserves custom exports
106
+ #
107
+ # 5. **Result** is intelligently merged code, not simple text concatenation!
108
+ #
109
+ # **This means:** The embedded code is FULLY PARSED by its native language parser,
110
+ # enabling true semantic-level merging. FencedCodeBlockDetector would only find
111
+ # the text boundaries - it cannot perform this semantic merging.
112
+ #
11
113
  # @example Detecting Ruby code blocks
12
114
  # detector = FencedCodeBlockDetector.new("ruby", aliases: ["rb"])
13
115
  # regions = detector.detect_all(markdown_source)
@@ -48,9 +48,11 @@ module Ast
48
48
  # @return [String] Token used to mark freeze blocks (e.g., "prism-merge", "psych-merge")
49
49
  # @!attribute [r] signature_generator
50
50
  # @return [Proc, nil] Custom signature generator, or nil to use default
51
- def self.included(base)
52
- base.class_eval do
53
- attr_reader(:source, :lines, :freeze_token, :signature_generator)
51
+ class << self
52
+ def included(base)
53
+ base.class_eval do
54
+ attr_reader(:source, :lines, :freeze_token, :signature_generator)
55
+ end
54
56
  end
55
57
  end
56
58
 
@@ -379,7 +379,7 @@ module Ast
379
379
  # Validate that end_line is not before start_line
380
380
  # @raise [InvalidStructureError] if structure is invalid
381
381
  def validate_line_order!
382
- return unless @end_line < @start_line
382
+ return if @end_line >= @start_line
383
383
 
384
384
  raise InvalidStructureError.new(
385
385
  "Freeze block end line (#{@end_line}) is before start line (#{@start_line})",
@@ -169,7 +169,7 @@ module Ast
169
169
  # @param threshold [Float] Minimum score to accept a match (0.0-1.0)
170
170
  # @param node_types [Array<Symbol>] Node types to process (empty = all)
171
171
  def initialize(threshold: DEFAULT_THRESHOLD, node_types: [])
172
- @threshold = [[threshold.to_f, 0.0].max, 1.0].min
172
+ @threshold = threshold.to_f.clamp(0.0, 1.0)
173
173
  @node_types = Array(node_types)
174
174
  end
175
175
 
@@ -128,7 +128,7 @@ module Ast
128
128
  def compute_score
129
129
  result = algorithm.call(node_a, node_b)
130
130
  # Clamp to valid range
131
- [[result.to_f, 0.0].max, 1.0].min
131
+ result.to_f.clamp(0.0, 1.0)
132
132
  end
133
133
  end
134
134
  end
@@ -193,38 +193,40 @@ module Ast
193
193
  )
194
194
  end
195
195
 
196
- # Create a config preset for "destination wins" merging.
197
- # Destination customizations are preserved, template-only content is skipped.
198
- #
199
- # @param freeze_token [String, nil] Optional freeze token
200
- # @param signature_generator [Proc, nil] Optional signature generator
201
- # @param node_typing [Hash, nil] Optional node typing configuration
202
- # @return [MergerConfig] Config preset
203
- def self.destination_wins(freeze_token: nil, signature_generator: nil, node_typing: nil)
204
- new(
205
- preference: :destination,
206
- add_template_only_nodes: false,
207
- freeze_token: freeze_token,
208
- signature_generator: signature_generator,
209
- node_typing: node_typing,
210
- )
211
- end
196
+ class << self
197
+ # Create a config preset for "destination wins" merging.
198
+ # Destination customizations are preserved, template-only content is skipped.
199
+ #
200
+ # @param freeze_token [String, nil] Optional freeze token
201
+ # @param signature_generator [Proc, nil] Optional signature generator
202
+ # @param node_typing [Hash, nil] Optional node typing configuration
203
+ # @return [MergerConfig] Config preset
204
+ def destination_wins(freeze_token: nil, signature_generator: nil, node_typing: nil)
205
+ new(
206
+ preference: :destination,
207
+ add_template_only_nodes: false,
208
+ freeze_token: freeze_token,
209
+ signature_generator: signature_generator,
210
+ node_typing: node_typing,
211
+ )
212
+ end
212
213
 
213
- # Create a config preset for "template wins" merging.
214
- # Template updates are applied, template-only content is added.
215
- #
216
- # @param freeze_token [String, nil] Optional freeze token
217
- # @param signature_generator [Proc, nil] Optional signature generator
218
- # @param node_typing [Hash, nil] Optional node typing configuration
219
- # @return [MergerConfig] Config preset
220
- def self.template_wins(freeze_token: nil, signature_generator: nil, node_typing: nil)
221
- new(
222
- preference: :template,
223
- add_template_only_nodes: true,
224
- freeze_token: freeze_token,
225
- signature_generator: signature_generator,
226
- node_typing: node_typing,
227
- )
214
+ # Create a config preset for "template wins" merging.
215
+ # Template updates are applied, template-only content is added.
216
+ #
217
+ # @param freeze_token [String, nil] Optional freeze token
218
+ # @param signature_generator [Proc, nil] Optional signature generator
219
+ # @param node_typing [Hash, nil] Optional node typing configuration
220
+ # @return [MergerConfig] Config preset
221
+ def template_wins(freeze_token: nil, signature_generator: nil, node_typing: nil)
222
+ new(
223
+ preference: :template,
224
+ add_template_only_nodes: true,
225
+ freeze_token: freeze_token,
226
+ signature_generator: signature_generator,
227
+ node_typing: node_typing,
228
+ )
229
+ end
228
230
  end
229
231
 
230
232
  private
@@ -235,68 +235,70 @@ module Ast
235
235
  # Merge typed sections from template and destination.
236
236
  #
237
237
  # Similar to `Text::SectionSplitter#merge_section_lists` but works with
238
- # TypedSection objects wrapping AST nodes.
239
- #
240
- # @param template_sections [Array<TypedSection>] Sections from template
241
- # @param dest_sections [Array<TypedSection>] Sections from destination
242
- # @param preference [Symbol, Hash] Merge preference (:template, :destination, or per-section Hash)
243
- # @param add_template_only [Boolean] Whether to add sections only in template
244
- # @return [Array<TypedSection>] Merged sections
245
- def self.merge_sections(template_sections, dest_sections, preference: :destination, add_template_only: false)
246
- dest_by_name = dest_sections.each_with_object({}) do |section, hash|
247
- key = section.normalized_name
248
- hash[key] = section unless section.unclassified?
249
- end
238
+ class << self
239
+ # TypedSection objects wrapping AST nodes.
240
+ #
241
+ # @param template_sections [Array<TypedSection>] Sections from template
242
+ # @param dest_sections [Array<TypedSection>] Sections from destination
243
+ # @param preference [Symbol, Hash] Merge preference (:template, :destination, or per-section Hash)
244
+ # @param add_template_only [Boolean] Whether to add sections only in template
245
+ # @return [Array<TypedSection>] Merged sections
246
+ def merge_sections(template_sections, dest_sections, preference: :destination, add_template_only: false)
247
+ dest_by_name = dest_sections.each_with_object({}) do |section, hash|
248
+ key = section.normalized_name
249
+ hash[key] = section unless section.unclassified?
250
+ end
250
251
 
251
- merged = []
252
- seen_names = Set.new
252
+ merged = []
253
+ seen_names = Set.new
253
254
 
254
- template_sections.each do |template_section|
255
- if template_section.unclassified?
256
- # Unclassified sections are typically kept as-is or merged specially
257
- merged << template_section if add_template_only
258
- next
259
- end
255
+ template_sections.each do |template_section|
256
+ if template_section.unclassified?
257
+ # Unclassified sections are typically kept as-is or merged specially
258
+ merged << template_section if add_template_only
259
+ next
260
+ end
260
261
 
261
- key = template_section.normalized_name
262
- seen_names << key
262
+ key = template_section.normalized_name
263
+ seen_names << key
263
264
 
264
- dest_section = dest_by_name[key]
265
+ dest_section = dest_by_name[key]
265
266
 
266
- if dest_section
267
- # Section exists in both - choose based on preference
268
- section_pref = preference_for(template_section.name, preference)
269
- merged << ((section_pref == :template) ? template_section : dest_section)
270
- elsif add_template_only
271
- merged << template_section
267
+ if dest_section
268
+ # Section exists in both - choose based on preference
269
+ section_pref = preference_for(template_section.name, preference)
270
+ merged << ((section_pref == :template) ? template_section : dest_section)
271
+ elsif add_template_only
272
+ merged << template_section
273
+ end
272
274
  end
273
- end
274
275
 
275
- # Append destination-only sections
276
- dest_sections.each do |dest_section|
277
- next if dest_section.unclassified?
278
- key = dest_section.normalized_name
279
- next if seen_names.include?(key)
280
- merged << dest_section
276
+ # Append destination-only sections
277
+ dest_sections.each do |dest_section|
278
+ next if dest_section.unclassified?
279
+ key = dest_section.normalized_name
280
+ next if seen_names.include?(key)
281
+ merged << dest_section
282
+ end
283
+
284
+ merged
281
285
  end
282
286
 
283
- merged
284
- end
287
+ # Get preference for a specific section.
288
+ #
289
+ # @param section_name [String, Symbol] The section name
290
+ # @param preference [Symbol, Hash] Overall preference
291
+ # @return [Symbol] :template or :destination
292
+ def preference_for(section_name, preference)
293
+ return preference unless preference.is_a?(Hash)
285
294
 
286
- # Get preference for a specific section.
287
- #
288
- # @param section_name [String, Symbol] The section name
289
- # @param preference [Symbol, Hash] Overall preference
290
- # @return [Symbol] :template or :destination
291
- def self.preference_for(section_name, preference)
292
- return preference unless preference.is_a?(Hash)
295
+ normalized = section_name.to_s.strip.downcase
296
+ preference.each do |key, value|
297
+ return value if key.to_s.strip.downcase == normalized
298
+ end
293
299
 
294
- normalized = section_name.to_s.strip.downcase
295
- preference.each do |key, value|
296
- return value if key.to_s.strip.downcase == normalized
300
+ preference.fetch(:default, :destination)
297
301
  end
298
-
299
- preference.fetch(:default, :destination)
300
302
  end
301
303
  end
302
304
  end
@@ -1,23 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../ast_node"
4
+
3
5
  module Ast
4
6
  module Merge
5
7
  module Text
6
8
  # Represents a line of text in the text-based AST.
7
9
  # Lines are top-level nodes, with words as nested children.
8
10
  #
11
+ # Inherits from AstNode (SyntheticNode) to implement the TreeHaver::Node
12
+ # protocol, making it compatible with all tree_haver-based merge operations.
13
+ #
9
14
  # @example
10
15
  # line = LineNode.new("Hello world!", line_number: 1)
11
16
  # line.content # => "Hello world!"
12
17
  # line.words.size # => 2
13
18
  # line.signature # => [:line, "Hello world!"]
14
- class LineNode
19
+ # line.type # => "line_node" (TreeHaver protocol)
20
+ # line.text # => "Hello world!" (TreeHaver protocol)
21
+ class LineNode < AstNode
15
22
  # @return [String] The full line content (without trailing newline)
16
23
  attr_reader :content
17
24
 
18
- # @return [Integer] 1-based line number
19
- attr_reader :line_number
20
-
21
25
  # @return [Array<WordNode>] Words contained in this line
22
26
  attr_reader :words
23
27
 
@@ -27,10 +31,33 @@ module Ast
27
31
  # @param line_number [Integer] 1-based line number
28
32
  def initialize(content, line_number:)
29
33
  @content = content
30
- @line_number = line_number
34
+
35
+ location = AstNode::Location.new(
36
+ start_line: line_number,
37
+ end_line: line_number,
38
+ start_column: 0,
39
+ end_column: content.length,
40
+ )
41
+
42
+ super(slice: content, location: location)
43
+
44
+ # Parse words AFTER super sets up location
31
45
  @words = parse_words
32
46
  end
33
47
 
48
+ # TreeHaver::Node protocol: type
49
+ # @return [String] "line_node"
50
+ def type
51
+ "line_node"
52
+ end
53
+
54
+ # TreeHaver::Node protocol: children
55
+ # Returns word nodes as children
56
+ # @return [Array<WordNode>]
57
+ def children
58
+ @words
59
+ end
60
+
34
61
  # Generate a signature for this line node.
35
62
  # The signature is used for matching lines across template/destination.
36
63
  #
@@ -61,18 +88,24 @@ module Ast
61
88
  @content.strip.start_with?("#")
62
89
  end
63
90
 
91
+ # Get the 1-based line number
92
+ # @return [Integer] 1-based line number
93
+ def line_number
94
+ location.start_line
95
+ end
96
+
64
97
  # Get the starting line (for compatibility with AST node interface)
65
98
  #
66
99
  # @return [Integer] 1-based start line
67
100
  def start_line
68
- @line_number
101
+ location.start_line
69
102
  end
70
103
 
71
104
  # Get the ending line (for compatibility with AST node interface)
72
105
  #
73
106
  # @return [Integer] 1-based end line (same as start for single line)
74
107
  def end_line
75
- @line_number
108
+ location.end_line
76
109
  end
77
110
 
78
111
  # Check equality with another LineNode
@@ -96,7 +129,7 @@ module Ast
96
129
  #
97
130
  # @return [String] Debug representation
98
131
  def inspect
99
- "#<LineNode line=#{@line_number} #{@content.inspect} words=#{@words.size}>"
132
+ "#<LineNode line=#{line_number} #{@content.inspect} words=#{@words.size}>"
100
133
  end
101
134
 
102
135
  # Convert to string (returns content)
@@ -126,7 +159,7 @@ module Ast
126
159
 
127
160
  words << WordNode.new(
128
161
  word,
129
- line_number: @line_number,
162
+ line_number: line_number,
130
163
  word_index: word_index,
131
164
  start_col: start_col,
132
165
  end_col: end_col,
@@ -265,16 +265,18 @@ module Ast
265
265
  normalize_name(section.name)
266
266
  end
267
267
 
268
- # Validate splitter configuration.
269
- #
270
- # @param config [Hash, nil] Configuration to validate
271
- # @raise [ArgumentError] If configuration is invalid
272
- # @return [void]
273
- def self.validate!(config)
274
- return if config.nil?
275
-
276
- unless config.is_a?(Hash)
277
- raise ArgumentError, "splitter config must be a Hash, got #{config.class}"
268
+ class << self
269
+ # Validate a splitter configuration.
270
+ #
271
+ # @param config [Hash, nil] Configuration to validate
272
+ # @raise [ArgumentError] If configuration is invalid
273
+ # @return [void]
274
+ def validate!(config)
275
+ return if config.nil?
276
+
277
+ unless config.is_a?(Hash)
278
+ raise ArgumentError, "splitter config must be a Hash, got #{config.class}"
279
+ end
278
280
  end
279
281
  end
280
282
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../ast_node"
4
+
3
5
  module Ast
4
6
  module Merge
5
7
  module Text
@@ -7,26 +9,21 @@ module Ast
7
9
  # Words are the nested level of the text-based AST.
8
10
  # They are identified by word boundaries (regex \b).
9
11
  #
12
+ # Inherits from AstNode (SyntheticNode) to implement the TreeHaver::Node
13
+ # protocol, making it compatible with all tree_haver-based merge operations.
14
+ #
10
15
  # @example
11
16
  # word = WordNode.new("hello", line_number: 1, word_index: 0, start_col: 0, end_col: 5)
12
17
  # word.content # => "hello"
13
18
  # word.signature # => [:word, "hello"]
14
- class WordNode
19
+ # word.type # => "word_node" (TreeHaver protocol)
20
+ class WordNode < AstNode
15
21
  # @return [String] The word content
16
22
  attr_reader :content
17
23
 
18
- # @return [Integer] 1-based line number containing this word
19
- attr_reader :line_number
20
-
21
24
  # @return [Integer] 0-based index of this word within the line
22
25
  attr_reader :word_index
23
26
 
24
- # @return [Integer] 0-based starting column position
25
- attr_reader :start_col
26
-
27
- # @return [Integer] 0-based ending column position (exclusive)
28
- attr_reader :end_col
29
-
30
27
  # Initialize a new WordNode
31
28
  #
32
29
  # @param content [String] The word content
@@ -36,10 +33,22 @@ module Ast
36
33
  # @param end_col [Integer] 0-based end column (exclusive)
37
34
  def initialize(content, line_number:, word_index:, start_col:, end_col:)
38
35
  @content = content
39
- @line_number = line_number
40
36
  @word_index = word_index
41
- @start_col = start_col
42
- @end_col = end_col
37
+
38
+ location = AstNode::Location.new(
39
+ start_line: line_number,
40
+ end_line: line_number,
41
+ start_column: start_col,
42
+ end_column: end_col,
43
+ )
44
+
45
+ super(slice: content, location: location)
46
+ end
47
+
48
+ # TreeHaver::Node protocol: type
49
+ # @return [String] "word_node"
50
+ def type
51
+ "word_node"
43
52
  end
44
53
 
45
54
  # Generate a signature for this word node.
@@ -50,6 +59,30 @@ module Ast
50
59
  [:word, @content]
51
60
  end
52
61
 
62
+ # Get normalized content (the word itself for words)
63
+ # @return [String]
64
+ def normalized_content
65
+ @content
66
+ end
67
+
68
+ # Get the 1-based line number
69
+ # @return [Integer]
70
+ def line_number
71
+ location.start_line
72
+ end
73
+
74
+ # Get start column (0-based)
75
+ # @return [Integer]
76
+ def start_col
77
+ location.start_column
78
+ end
79
+
80
+ # Get end column (0-based, exclusive)
81
+ # @return [Integer]
82
+ def end_col
83
+ location.end_column
84
+ end
85
+
53
86
  # Check equality with another WordNode
54
87
  #
55
88
  # @param other [WordNode] Other node to compare
@@ -71,7 +104,7 @@ module Ast
71
104
  #
72
105
  # @return [String] Debug representation
73
106
  def inspect
74
- "#<WordNode #{@content.inspect} line=#{@line_number} col=#{@start_col}..#{@end_col}>"
107
+ "#<WordNode #{@content.inspect} line=#{line_number} col=#{start_col}..#{end_col}>"
75
108
  end
76
109
 
77
110
  # Convert to string (returns content)
@@ -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 = "1.0.0"
8
+ VERSION = "1.1.0"
9
9
  end
10
10
  VERSION = Version::VERSION # traditional location
11
11
  end
@@ -56,26 +56,6 @@ module Ast
56
56
  # Calculate line numbers
57
57
  # Frontmatter starts at line 1 (or after BOM)
58
58
  start_line = 1
59
- # Count newlines in content to determine end line
60
- # Opening delimiter ends at line 1
61
- # Content spans from line 2 to line 2 + content_lines - 1
62
- # Closing delimiter is on the next line
63
- content_newlines = content.count("\n")
64
- # end_line is the line with the closing ---
65
- end_line = start_line + 1 + content_newlines
66
-
67
- # Adjust if content ends without newline
68
- end_line - 1 if content.end_with?("\n") && content_newlines > 0
69
-
70
- # Actually, let's calculate more carefully
71
- # Line 1: ---
72
- # Line 2 to N: content
73
- # Line N+1: ---
74
- if content.empty?
75
- 0
76
- else
77
- content.count("\n") + (content.end_with?("\n") ? 0 : 1)
78
- end
79
59
 
80
60
  # Simplify: count total newlines in the full match to determine end line
81
61
  full_match = match[0]
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: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -57,6 +57,26 @@ dependencies:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
59
  version: 1.1.9
60
+ - !ruby/object:Gem::Dependency
61
+ name: tree_haver
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.1'
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.1.0
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '3.1'
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.1.0
60
80
  - !ruby/object:Gem::Dependency
61
81
  name: kettle-dev
62
82
  requirement: !ruby/object:Gem::Requirement
@@ -288,10 +308,10 @@ licenses:
288
308
  - MIT
289
309
  metadata:
290
310
  homepage_uri: https://ast-merge.galtzo.com/
291
- source_code_uri: https://github.com/kettle-rb/ast-merge/tree/v1.0.0
292
- changelog_uri: https://github.com/kettle-rb/ast-merge/blob/v1.0.0/CHANGELOG.md
311
+ source_code_uri: https://github.com/kettle-rb/ast-merge/tree/v1.1.0
312
+ changelog_uri: https://github.com/kettle-rb/ast-merge/blob/v1.1.0/CHANGELOG.md
293
313
  bug_tracker_uri: https://github.com/kettle-rb/ast-merge/issues
294
- documentation_uri: https://www.rubydoc.info/gems/ast-merge/1.0.0
314
+ documentation_uri: https://www.rubydoc.info/gems/ast-merge/1.1.0
295
315
  funding_uri: https://github.com/sponsors/pboling
296
316
  wiki_uri: https://github.com/kettle-rb/ast-merge/wiki
297
317
  news_uri: https://www.railsbling.com/tags/ast-merge
metadata.gz.sig CHANGED
Binary file