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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +46 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/ast/merge/ast_node.rb +87 -0
- data/lib/ast/merge/comment/block.rb +195 -0
- data/lib/ast/merge/comment/empty.rb +78 -0
- data/lib/ast/merge/comment/line.rb +138 -0
- data/lib/ast/merge/comment/parser.rb +278 -0
- data/lib/ast/merge/comment/style.rb +282 -0
- data/lib/ast/merge/comment.rb +36 -0
- data/lib/ast/merge/conflict_resolver_base.rb +399 -0
- data/lib/ast/merge/debug_logger.rb +271 -0
- data/lib/ast/merge/fenced_code_block_detector.rb +211 -0
- data/lib/ast/merge/file_analyzable.rb +307 -0
- data/lib/ast/merge/freezable.rb +82 -0
- data/lib/ast/merge/freeze_node_base.rb +434 -0
- data/lib/ast/merge/match_refiner_base.rb +312 -0
- data/lib/ast/merge/match_score_base.rb +135 -0
- data/lib/ast/merge/merge_result_base.rb +169 -0
- data/lib/ast/merge/merger_config.rb +258 -0
- data/lib/ast/merge/node_typing.rb +373 -0
- data/lib/ast/merge/region.rb +124 -0
- data/lib/ast/merge/region_detector_base.rb +114 -0
- data/lib/ast/merge/region_mergeable.rb +364 -0
- data/lib/ast/merge/rspec/shared_examples/conflict_resolver_base.rb +416 -0
- data/lib/ast/merge/rspec/shared_examples/debug_logger.rb +174 -0
- data/lib/ast/merge/rspec/shared_examples/file_analyzable.rb +193 -0
- data/lib/ast/merge/rspec/shared_examples/freeze_node_base.rb +219 -0
- data/lib/ast/merge/rspec/shared_examples/merge_result_base.rb +106 -0
- data/lib/ast/merge/rspec/shared_examples/merger_config.rb +202 -0
- data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +115 -0
- data/lib/ast/merge/rspec/shared_examples.rb +26 -0
- data/lib/ast/merge/rspec.rb +4 -0
- data/lib/ast/merge/section_typing.rb +303 -0
- data/lib/ast/merge/smart_merger_base.rb +417 -0
- data/lib/ast/merge/text/conflict_resolver.rb +161 -0
- data/lib/ast/merge/text/file_analysis.rb +168 -0
- data/lib/ast/merge/text/line_node.rb +142 -0
- data/lib/ast/merge/text/merge_result.rb +42 -0
- data/lib/ast/merge/text/section.rb +93 -0
- data/lib/ast/merge/text/section_splitter.rb +397 -0
- data/lib/ast/merge/text/smart_merger.rb +141 -0
- data/lib/ast/merge/text/word_node.rb +86 -0
- data/lib/ast/merge/text.rb +35 -0
- data/lib/ast/merge/toml_frontmatter_detector.rb +88 -0
- data/lib/ast/merge/version.rb +12 -0
- data/lib/ast/merge/yaml_frontmatter_detector.rb +108 -0
- data/lib/ast/merge.rb +165 -0
- data/lib/ast-merge.rb +4 -0
- data/sig/ast/merge.rbs +195 -0
- data.tar.gz.sig +0 -0
- metadata +326 -0
- 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
|