ast-merge 1.0.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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +46 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +852 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/ast/merge/ast_node.rb +87 -0
  14. data/lib/ast/merge/comment/block.rb +195 -0
  15. data/lib/ast/merge/comment/empty.rb +78 -0
  16. data/lib/ast/merge/comment/line.rb +138 -0
  17. data/lib/ast/merge/comment/parser.rb +278 -0
  18. data/lib/ast/merge/comment/style.rb +282 -0
  19. data/lib/ast/merge/comment.rb +36 -0
  20. data/lib/ast/merge/conflict_resolver_base.rb +399 -0
  21. data/lib/ast/merge/debug_logger.rb +271 -0
  22. data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
  23. data/lib/ast/merge/file_analyzable.rb +307 -0
  24. data/lib/ast/merge/freezable.rb +82 -0
  25. data/lib/ast/merge/freeze_node_base.rb +434 -0
  26. data/lib/ast/merge/match_refiner_base.rb +312 -0
  27. data/lib/ast/merge/match_score_base.rb +135 -0
  28. data/lib/ast/merge/merge_result_base.rb +169 -0
  29. data/lib/ast/merge/merger_config.rb +258 -0
  30. data/lib/ast/merge/node_typing.rb +373 -0
  31. data/lib/ast/merge/region.rb +124 -0
  32. data/lib/ast/merge/region_detector_base.rb +114 -0
  33. data/lib/ast/merge/region_mergeable.rb +364 -0
  34. data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
  35. data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
  36. data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
  37. data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
  38. data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
  39. data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
  40. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
  41. data/lib/ast/merge/rspec/shared_examples.rb +26 -0
  42. data/lib/ast/merge/rspec.rb +4 -0
  43. data/lib/ast/merge/section_typing.rb +303 -0
  44. data/lib/ast/merge/smart_merger_base.rb +417 -0
  45. data/lib/ast/merge/text/conflict_resolver.rb +161 -0
  46. data/lib/ast/merge/text/file_analysis.rb +168 -0
  47. data/lib/ast/merge/text/line_node.rb +142 -0
  48. data/lib/ast/merge/text/merge_result.rb +42 -0
  49. data/lib/ast/merge/text/section.rb +93 -0
  50. data/lib/ast/merge/text/section_splitter.rb +397 -0
  51. data/lib/ast/merge/text/smart_merger.rb +141 -0
  52. data/lib/ast/merge/text/word_node.rb +86 -0
  53. data/lib/ast/merge/text.rb +35 -0
  54. data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
  55. data/lib/ast/merge/version.rb +12 -0
  56. data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
  57. data/lib/ast/merge.rb +165 -0
  58. data/lib/ast-merge.rb +4 -0
  59. data/sig/ast/merge.rbs +195 -0
  60. data.tar.gz.sig +0 -0
  61. metadata +326 -0
  62. metadata.gz.sig +0 -0
data/REEK ADDED
File without changes
data/RUBOCOP.md ADDED
@@ -0,0 +1,71 @@
1
+ # RuboCop Usage Guide
2
+
3
+ ## Overview
4
+
5
+ A tale of two RuboCop plugin gems.
6
+
7
+ ### RuboCop Gradual
8
+
9
+ This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file.
10
+
11
+ ### RuboCop LTS
12
+
13
+ This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2.
14
+ RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more.
15
+
16
+ ## Checking RuboCop Violations
17
+
18
+ To check for RuboCop violations in this project, always use:
19
+
20
+ ```bash
21
+ bundle exec rake rubocop_gradual:check
22
+ ```
23
+
24
+ **Do not use** the standard RuboCop commands like:
25
+ - `bundle exec rubocop`
26
+ - `rubocop`
27
+
28
+ ## Understanding the Lock File
29
+
30
+ The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to:
31
+
32
+ 1. Prevent new violations while gradually fixing existing ones
33
+ 2. Track progress on code style improvements
34
+ 3. Ensure CI builds don't fail due to pre-existing violations
35
+
36
+ ## Common Commands
37
+
38
+ - **Check violations**
39
+ - `bundle exec rake rubocop_gradual`
40
+ - `bundle exec rake rubocop_gradual:check`
41
+ - **(Safe) Autocorrect violations, and update lockfile if no new violations**
42
+ - `bundle exec rake rubocop_gradual:autocorrect`
43
+ - **Force update the lock file (w/o autocorrect) to match violations present in code**
44
+ - `bundle exec rake rubocop_gradual:force_update`
45
+
46
+ ## Workflow
47
+
48
+ 1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect`
49
+ a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task.
50
+ 2. If there are new violations, either:
51
+ - Fix them in your code
52
+ - Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately)
53
+ 3. Commit the updated `.rubocop_gradual.lock` file along with your changes
54
+
55
+ ## Never add inline RuboCop disables
56
+
57
+ Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways:
58
+
59
+ - Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide.
60
+ - Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow:
61
+ - `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced)
62
+ - If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately)
63
+
64
+ In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test.
65
+
66
+ ## Benefits of rubocop_gradual
67
+
68
+ - Allows incremental adoption of code style rules
69
+ - Prevents CI failures due to pre-existing violations
70
+ - Provides a clear record of code style debt
71
+ - Enables focused efforts on improving code quality over time
data/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ |----------|-----------|
7
+ | 1.latest | ✅ |
8
+
9
+ ## Security contact information
10
+
11
+ To report a security vulnerability, please use the
12
+ [Tidelift security contact](https://tidelift.com/security).
13
+ Tidelift will coordinate the fix and disclosure.
14
+
15
+ ## Additional Support
16
+
17
+ If you are interested in support for versions older than the latest release,
18
+ please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate,
19
+ or find other sponsorship links in the [README].
20
+
21
+ [README]: README.md
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Base class for AST nodes in the ast-merge framework.
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.
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)
16
+ #
17
+ # @abstract
18
+ class AstNode
19
+ # Simple location struct for nodes that don't have a native location object
20
+ Location = Struct.new(:start_line, :end_line, :start_column, :end_column, keyword_init: true) do
21
+ # Check if a line number falls within this location
22
+ # @param line_number [Integer] The line number to check
23
+ # @return [Boolean] true if the line number is within the range
24
+ def cover?(line_number)
25
+ line_number >= start_line && line_number <= end_line
26
+ end
27
+ end
28
+
29
+ # @return [Location] The location of this node in source
30
+ attr_reader :location
31
+
32
+ # @return [String] The source text for this node
33
+ attr_reader :slice
34
+
35
+ # Initialize a new AstNode.
36
+ #
37
+ # @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:)
40
+ @slice = slice
41
+ @location = location
42
+ end
43
+
44
+ # @return [Array<AstNode>] Child nodes (empty for leaf nodes)
45
+ def children
46
+ []
47
+ end
48
+
49
+ # Generate a signature for this node for matching purposes.
50
+ #
51
+ # Override in subclasses for custom signature logic.
52
+ # Default returns the node class name and a normalized form of the slice.
53
+ #
54
+ # @return [Array] Signature array for matching
55
+ def signature
56
+ [self.class.name, normalized_content]
57
+ end
58
+
59
+ # @return [String] Normalized content for signature comparison
60
+ def normalized_content
61
+ slice.to_s.strip
62
+ end
63
+
64
+ # @return [String] Human-readable representation
65
+ def inspect
66
+ "#<#{self.class.name} lines=#{location.start_line}..#{location.end_line} slice=#{slice.to_s[0..50].inspect}>"
67
+ end
68
+
69
+ # @return [String] The source text
70
+ def to_s
71
+ slice.to_s
72
+ end
73
+
74
+ # Support unwrap protocol (returns self for non-wrapper nodes)
75
+ # @return [AstNode] self
76
+ def unwrap
77
+ self
78
+ 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
+ end
86
+ end
87
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast_node"
4
+ require_relative "style"
5
+
6
+ module Ast
7
+ module Merge
8
+ module Comment
9
+ # Represents a contiguous block of comment content.
10
+ #
11
+ # A comment block can represent:
12
+ # - A sequence of line comments not separated by blank lines
13
+ # - A C-style block comment (`/* ... */`)
14
+ # - An HTML comment block (`<!-- ... -->`)
15
+ #
16
+ # The block acts as a grouping mechanism for signature matching and
17
+ # merge operations.
18
+ #
19
+ # @example Line comment block (Ruby/Python style)
20
+ # block = Block.new(children: [
21
+ # Line.new(text: "# First line", line_number: 1),
22
+ # Line.new(text: "# Second line", line_number: 2),
23
+ # ])
24
+ # block.signature #=> [:comment_block, "first line"]
25
+ #
26
+ # @example C-style block comment
27
+ # block = Block.new(
28
+ # raw_content: "/* This is a\n multi-line comment */",
29
+ # start_line: 1,
30
+ # end_line: 2,
31
+ # style: :c_style_block
32
+ # )
33
+ #
34
+ class Block < AstNode
35
+ # @return [Array<Line, Empty>] The child nodes in this block (for line-based blocks)
36
+ attr_reader :children
37
+
38
+ # @return [String, nil] Raw content for block-style comments (e.g., /* ... */)
39
+ attr_reader :raw_content
40
+
41
+ # @return [Style] The comment style configuration
42
+ attr_reader :style
43
+
44
+ # Initialize a new Block.
45
+ #
46
+ # For line-based comments, pass `children` array.
47
+ # For block-style comments (/* ... */), pass `raw_content`.
48
+ #
49
+ # @param children [Array<Line, Empty>, nil] Child nodes (for line comments)
50
+ # @param raw_content [String, nil] Raw block content (for block comments)
51
+ # @param start_line [Integer, nil] Start line (required for raw_content)
52
+ # @param end_line [Integer, nil] End line (required for raw_content)
53
+ # @param style [Style, Symbol, nil] Comment style (default: :hash_comment)
54
+ def initialize(children: nil, raw_content: nil, start_line: nil, end_line: nil, style: nil)
55
+ @style = resolve_style(style)
56
+ @children = children || []
57
+ @raw_content = raw_content
58
+
59
+ if raw_content
60
+ # Block-style comment (e.g., /* ... */)
61
+ @start_line = start_line || 1
62
+ @end_line = end_line || @start_line
63
+ combined_slice = raw_content
64
+ else
65
+ # Line-based comment block
66
+ first_child = @children.first
67
+ last_child = @children.last
68
+ @start_line = first_child&.location&.start_line || 1
69
+ @end_line = last_child&.location&.end_line || @start_line
70
+ combined_slice = @children.map(&:slice).join("\n")
71
+ end
72
+
73
+ location = AstNode::Location.new(
74
+ start_line: @start_line,
75
+ end_line: @end_line,
76
+ start_column: 0,
77
+ end_column: combined_slice.split("\n").last&.length || 0,
78
+ )
79
+
80
+ super(slice: combined_slice, location: location)
81
+ end
82
+
83
+ # Generate signature for matching.
84
+ #
85
+ # For line-based blocks, uses the first non-empty line's content.
86
+ # For block-style comments, uses the first meaningful line of content.
87
+ #
88
+ # @return [Array] Signature for matching
89
+ def signature
90
+ content = first_meaningful_content
91
+ [:comment_block, content[0..120]] # Limit signature length
92
+ end
93
+
94
+ # @return [String] Normalized combined content
95
+ def normalized_content
96
+ if raw_content
97
+ extract_block_content
98
+ else
99
+ children
100
+ .select { |c| c.is_a?(Line) }
101
+ .map { |c| c.content.strip }
102
+ .join("\n")
103
+ end
104
+ end
105
+
106
+ # Check if this block contains a freeze marker.
107
+ #
108
+ # @param freeze_token [String] The freeze token to look for
109
+ # @return [Boolean] true if any child contains a freeze marker
110
+ def freeze_marker?(freeze_token)
111
+ return false unless freeze_token
112
+
113
+ if raw_content
114
+ pattern = /#{Regexp.escape(freeze_token)}:(freeze|unfreeze)/i
115
+ raw_content.match?(pattern)
116
+ else
117
+ children.any? { |c| c.respond_to?(:freeze_marker?) && c.freeze_marker?(freeze_token) }
118
+ end
119
+ end
120
+
121
+ # @return [String] Human-readable representation
122
+ def inspect
123
+ if raw_content
124
+ "#<Comment::Block lines=#{@start_line}..#{@end_line} style=#{style.name} block_comment>"
125
+ else
126
+ "#<Comment::Block lines=#{@start_line}..#{@end_line} style=#{style.name} children=#{children.size}>"
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ # Resolve the style parameter to a Style instance.
133
+ #
134
+ # @param style [Style, Symbol, nil] Style configuration
135
+ # @return [Style] Resolved style instance
136
+ def resolve_style(style)
137
+ case style
138
+ when Style
139
+ style
140
+ when Symbol
141
+ Style.for(style)
142
+ when nil
143
+ Style.for(Style::DEFAULT_STYLE)
144
+ else
145
+ raise ArgumentError, "Invalid style: #{style.inspect}"
146
+ end
147
+ end
148
+
149
+ # Get the first meaningful content for signature generation.
150
+ #
151
+ # @return [String] First non-empty content, lowercased
152
+ def first_meaningful_content
153
+ if raw_content
154
+ # Extract first line of content from block comment
155
+ extract_block_content.split("\n").first&.strip&.downcase || ""
156
+ else
157
+ # Find first comment line with actual content
158
+ first_content = children.find { |c| c.is_a?(Line) && !c.content.strip.empty? }
159
+ first_content&.content&.strip&.downcase || ""
160
+ end
161
+ end
162
+
163
+ # Extract content from a block-style comment.
164
+ #
165
+ # Removes the opening and closing delimiters.
166
+ #
167
+ # @return [String] The content without delimiters
168
+ def extract_block_content
169
+ return "" unless raw_content
170
+
171
+ content = raw_content.to_s
172
+
173
+ # Remove block start delimiter
174
+ if style.block_start
175
+ content = content.sub(/^\s*#{Regexp.escape(style.block_start)}\s*/, "")
176
+ end
177
+
178
+ # Remove block end delimiter
179
+ if style.block_end
180
+ content = content.sub(/\s*#{Regexp.escape(style.block_end)}\s*$/, "")
181
+ end
182
+
183
+ # Clean up common patterns in multi-line block comments
184
+ # (leading asterisks on each line, common in /* ... */ style)
185
+ lines = content.split("\n")
186
+ if lines.size > 1 && lines[1..].all? { |l| l.match?(/^\s*\*/) }
187
+ lines = lines.map { |l| l.sub(/^\s*\*\s?/, "") }
188
+ end
189
+
190
+ lines.map(&:strip).join("\n")
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast_node"
4
+
5
+ module Ast
6
+ module Merge
7
+ module Comment
8
+ # Represents an empty/blank line in source code.
9
+ #
10
+ # Empty lines are important for preserving document structure and
11
+ # separating comment blocks. They serve as natural boundaries between
12
+ # logical sections of comments.
13
+ #
14
+ # @example
15
+ # empty = Empty.new(line_number: 5)
16
+ # empty.slice #=> ""
17
+ # empty.signature #=> [:empty_line]
18
+ #
19
+ # @example With whitespace-only content
20
+ # empty = Empty.new(line_number: 5, text: " ")
21
+ # empty.slice #=> " "
22
+ # empty.signature #=> [:empty_line]
23
+ #
24
+ class Empty < AstNode
25
+ # @return [Integer] The line number in source
26
+ attr_reader :line_number
27
+
28
+ # @return [String] The actual line content (may have whitespace)
29
+ attr_reader :text
30
+
31
+ # Initialize a new Empty line.
32
+ #
33
+ # @param line_number [Integer] The 1-based line number
34
+ # @param text [String] The actual line content (may have whitespace)
35
+ def initialize(line_number:, text: "")
36
+ @line_number = line_number
37
+ @text = text.to_s
38
+
39
+ location = AstNode::Location.new(
40
+ start_line: line_number,
41
+ end_line: line_number,
42
+ start_column: 0,
43
+ end_column: @text.length,
44
+ )
45
+
46
+ super(slice: @text, location: location)
47
+ end
48
+
49
+ # Empty lines have a generic signature - they don't match by content.
50
+ #
51
+ # All empty lines are considered equivalent for matching purposes.
52
+ #
53
+ # @return [Array] Signature for matching
54
+ def signature
55
+ [:empty_line]
56
+ end
57
+
58
+ # @return [String] Empty normalized content
59
+ def normalized_content
60
+ ""
61
+ end
62
+
63
+ # Empty lines never contain freeze markers.
64
+ #
65
+ # @param _freeze_token [String] Ignored
66
+ # @return [Boolean] Always false
67
+ def freeze_marker?(_freeze_token)
68
+ false
69
+ end
70
+
71
+ # @return [String] Human-readable representation
72
+ def inspect
73
+ "#<Comment::Empty line=#{line_number}>"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast_node"
4
+ require_relative "style"
5
+
6
+ module Ast
7
+ module Merge
8
+ module Comment
9
+ # Represents a single comment line in source code.
10
+ #
11
+ # A comment line is a line that starts with a comment delimiter
12
+ # (e.g., `#` in Ruby, `//` in JavaScript, `<!--` in HTML).
13
+ # The style determines how the comment is parsed and normalized.
14
+ #
15
+ # @example Ruby-style hash comment
16
+ # line = Line.new(text: "# frozen_string_literal: true", line_number: 1)
17
+ # line.slice #=> "# frozen_string_literal: true"
18
+ # line.content #=> "frozen_string_literal: true"
19
+ # line.signature #=> [:comment_line, "frozen_string_literal: true"]
20
+ #
21
+ # @example JavaScript-style line comment
22
+ # style = Style.for(:c_style_line)
23
+ # line = Line.new(text: "// TODO: fix this", line_number: 5, style: style)
24
+ # line.content #=> "TODO: fix this"
25
+ #
26
+ # @example HTML-style comment
27
+ # style = Style.for(:html_comment)
28
+ # line = Line.new(text: "<!-- Important note -->", line_number: 1, style: style)
29
+ # line.content #=> "Important note"
30
+ #
31
+ class Line < AstNode
32
+ # @return [String] The raw text of the comment line
33
+ attr_reader :text
34
+
35
+ # @return [Integer] The line number in source
36
+ attr_reader :line_number
37
+
38
+ # @return [Style] The comment style configuration
39
+ attr_reader :style
40
+
41
+ # Initialize a new Line.
42
+ #
43
+ # @param text [String] The full comment text including delimiter
44
+ # @param line_number [Integer] The 1-based line number
45
+ # @param style [Style, Symbol, nil] The comment style (default: :hash_comment)
46
+ def initialize(text:, line_number:, style: nil)
47
+ @text = text.to_s
48
+ @line_number = line_number
49
+ @style = resolve_style(style)
50
+
51
+ location = AstNode::Location.new(
52
+ start_line: line_number,
53
+ end_line: line_number,
54
+ start_column: 0,
55
+ end_column: @text.length,
56
+ )
57
+
58
+ super(slice: @text, location: location)
59
+ end
60
+
61
+ # Extract the comment content without the delimiter.
62
+ #
63
+ # Uses the style configuration to properly strip delimiters.
64
+ #
65
+ # @return [String] The comment text without the leading delimiter and whitespace
66
+ def content
67
+ @content ||= style.extract_line_content(text)
68
+ end
69
+
70
+ # Generate signature for matching.
71
+ # Uses normalized content (without delimiter) for better matching across files.
72
+ #
73
+ # @return [Array] Signature for matching
74
+ def signature
75
+ [:comment_line, normalized_content.downcase]
76
+ end
77
+
78
+ # @return [String] Normalized content for comparison
79
+ def normalized_content
80
+ content.strip
81
+ end
82
+
83
+ # Check if this comment contains a specific token pattern.
84
+ #
85
+ # Useful for detecting freeze markers or other special directives.
86
+ #
87
+ # @param token [String] The token to look for
88
+ # @param action [String, nil] Optional action suffix (e.g., "freeze", "unfreeze")
89
+ # @return [Boolean] true if the token is found
90
+ def contains_token?(token, action: nil)
91
+ return false unless token
92
+
93
+ pattern = if action
94
+ /#{Regexp.escape(token)}:#{action}/i
95
+ else
96
+ /#{Regexp.escape(token)}/i
97
+ end
98
+ text.match?(pattern)
99
+ end
100
+
101
+ # Check if this comment contains a freeze marker.
102
+ #
103
+ # @param freeze_token [String] The freeze token to look for
104
+ # @return [Boolean] true if this comment contains a freeze marker
105
+ def freeze_marker?(freeze_token)
106
+ return false unless freeze_token
107
+
108
+ pattern = /#{Regexp.escape(freeze_token)}:(freeze|unfreeze)/i
109
+ text.match?(pattern)
110
+ end
111
+
112
+ # @return [String] Human-readable representation
113
+ def inspect
114
+ "#<Comment::Line line=#{line_number} style=#{style.name} #{text.inspect}>"
115
+ end
116
+
117
+ private
118
+
119
+ # Resolve the style parameter to a Style instance.
120
+ #
121
+ # @param style [Style, Symbol, nil] Style configuration
122
+ # @return [Style] Resolved style instance
123
+ def resolve_style(style)
124
+ case style
125
+ when Style
126
+ style
127
+ when Symbol
128
+ Style.for(style)
129
+ when nil
130
+ Style.for(Style::DEFAULT_STYLE)
131
+ else
132
+ raise ArgumentError, "Invalid style: #{style.inspect}"
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end