cataract 0.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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/.clang-tidy +30 -0
  3. data/.github/workflows/ci-macos.yml +12 -0
  4. data/.github/workflows/ci.yml +77 -0
  5. data/.github/workflows/test.yml +76 -0
  6. data/.gitignore +45 -0
  7. data/.overcommit.yml +38 -0
  8. data/.rubocop.yml +83 -0
  9. data/BENCHMARKS.md +201 -0
  10. data/CHANGELOG.md +1 -0
  11. data/Gemfile +27 -0
  12. data/LICENSE +21 -0
  13. data/RAGEL_MIGRATION.md +60 -0
  14. data/README.md +292 -0
  15. data/Rakefile +209 -0
  16. data/benchmarks/benchmark_harness.rb +193 -0
  17. data/benchmarks/benchmark_merging.rb +121 -0
  18. data/benchmarks/benchmark_optimization_comparison.rb +168 -0
  19. data/benchmarks/benchmark_parsing.rb +153 -0
  20. data/benchmarks/benchmark_ragel_removal.rb +56 -0
  21. data/benchmarks/benchmark_runner.rb +70 -0
  22. data/benchmarks/benchmark_serialization.rb +180 -0
  23. data/benchmarks/benchmark_shorthand.rb +109 -0
  24. data/benchmarks/benchmark_shorthand_expansion.rb +176 -0
  25. data/benchmarks/benchmark_specificity.rb +124 -0
  26. data/benchmarks/benchmark_string_allocation.rb +151 -0
  27. data/benchmarks/benchmark_stylesheet_to_s.rb +62 -0
  28. data/benchmarks/benchmark_to_s_cached.rb +55 -0
  29. data/benchmarks/benchmark_value_splitter.rb +54 -0
  30. data/benchmarks/benchmark_yjit.rb +158 -0
  31. data/benchmarks/benchmark_yjit_workers.rb +61 -0
  32. data/benchmarks/profile_to_s.rb +23 -0
  33. data/benchmarks/speedup_calculator.rb +83 -0
  34. data/benchmarks/system_metadata.rb +81 -0
  35. data/benchmarks/templates/benchmarks.md.erb +221 -0
  36. data/benchmarks/yjit_tests.rb +141 -0
  37. data/cataract.gemspec +34 -0
  38. data/cliff.toml +92 -0
  39. data/examples/color_conversion_visual_test/color_conversion_test.html +3603 -0
  40. data/examples/color_conversion_visual_test/generate.rb +202 -0
  41. data/examples/color_conversion_visual_test/template.html.erb +259 -0
  42. data/examples/css_analyzer/analyzer.rb +164 -0
  43. data/examples/css_analyzer/analyzers/base.rb +33 -0
  44. data/examples/css_analyzer/analyzers/colors.rb +133 -0
  45. data/examples/css_analyzer/analyzers/important.rb +88 -0
  46. data/examples/css_analyzer/analyzers/properties.rb +61 -0
  47. data/examples/css_analyzer/analyzers/specificity.rb +68 -0
  48. data/examples/css_analyzer/templates/report.html.erb +575 -0
  49. data/examples/css_analyzer.rb +69 -0
  50. data/examples/github_analysis.html +5343 -0
  51. data/ext/cataract/cataract.c +1086 -0
  52. data/ext/cataract/cataract.h +174 -0
  53. data/ext/cataract/css_parser.c +1435 -0
  54. data/ext/cataract/extconf.rb +48 -0
  55. data/ext/cataract/import_scanner.c +174 -0
  56. data/ext/cataract/merge.c +973 -0
  57. data/ext/cataract/shorthand_expander.c +902 -0
  58. data/ext/cataract/specificity.c +213 -0
  59. data/ext/cataract/value_splitter.c +116 -0
  60. data/ext/cataract_color/cataract_color.c +16 -0
  61. data/ext/cataract_color/color_conversion.c +1687 -0
  62. data/ext/cataract_color/color_conversion.h +136 -0
  63. data/ext/cataract_color/color_conversion_lab.c +571 -0
  64. data/ext/cataract_color/color_conversion_named.c +259 -0
  65. data/ext/cataract_color/color_conversion_oklab.c +547 -0
  66. data/ext/cataract_color/extconf.rb +23 -0
  67. data/ext/cataract_old/cataract.c +393 -0
  68. data/ext/cataract_old/cataract.h +250 -0
  69. data/ext/cataract_old/css_parser.c +933 -0
  70. data/ext/cataract_old/extconf.rb +67 -0
  71. data/ext/cataract_old/import_scanner.c +174 -0
  72. data/ext/cataract_old/merge.c +776 -0
  73. data/ext/cataract_old/shorthand_expander.c +902 -0
  74. data/ext/cataract_old/specificity.c +213 -0
  75. data/ext/cataract_old/stylesheet.c +290 -0
  76. data/ext/cataract_old/value_splitter.c +116 -0
  77. data/lib/cataract/at_rule.rb +97 -0
  78. data/lib/cataract/color_conversion.rb +18 -0
  79. data/lib/cataract/declarations.rb +332 -0
  80. data/lib/cataract/import_resolver.rb +210 -0
  81. data/lib/cataract/rule.rb +131 -0
  82. data/lib/cataract/stylesheet.rb +716 -0
  83. data/lib/cataract/stylesheet_scope.rb +257 -0
  84. data/lib/cataract/version.rb +5 -0
  85. data/lib/cataract.rb +107 -0
  86. data/lib/tasks/gem.rake +158 -0
  87. data/scripts/fuzzer/run.rb +828 -0
  88. data/scripts/fuzzer/worker.rb +99 -0
  89. data/scripts/generate_benchmarks_md.rb +155 -0
  90. metadata +135 -0
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cataract
4
+ # Chainable query scope for filtering Stylesheet rules.
5
+ #
6
+ # Inspired by ActiveRecord's Relation, StylesheetScope provides a fluent
7
+ # interface for filtering and querying CSS rules. Scopes are lazy - filters
8
+ # are only applied during iteration.
9
+ #
10
+ # @example Chaining filters
11
+ # sheet.with_media(:print).with_specificity(10..).select(&:selector?)
12
+ #
13
+ # @example Inspect shows results
14
+ # scope = sheet.with_media(:screen)
15
+ # scope.inspect #=> "#<Cataract::StylesheetScope [...]>"
16
+ class StylesheetScope
17
+ include Enumerable
18
+
19
+ # @private
20
+ def initialize(stylesheet, filters = {})
21
+ @stylesheet = stylesheet
22
+ @filters = filters
23
+ end
24
+
25
+ # Filter by media query symbol(s).
26
+ #
27
+ # @param media [Symbol, Array<Symbol>] Media query symbol(s)
28
+ # @return [StylesheetScope] New scope with media filter applied
29
+ #
30
+ # @example
31
+ # sheet.with_media(:print).with_media(:screen) # overwrites to :screen
32
+ def with_media(media)
33
+ StylesheetScope.new(@stylesheet, @filters.merge(media: media))
34
+ end
35
+
36
+ # Filter by CSS specificity.
37
+ #
38
+ # @param specificity [Integer, Range] Specificity value or range
39
+ # @return [StylesheetScope] New scope with specificity filter applied
40
+ #
41
+ # @example
42
+ # sheet.with_specificity(10) # exactly 10
43
+ # sheet.with_specificity(10..) # 10 or higher
44
+ # sheet.with_specificity(5...10) # between 5 and 9
45
+ def with_specificity(specificity)
46
+ StylesheetScope.new(@stylesheet, @filters.merge(specificity: specificity))
47
+ end
48
+
49
+ # Filter by CSS selector.
50
+ #
51
+ # @param selector [String, Regexp] CSS selector to match (exact string or pattern)
52
+ # @return [StylesheetScope] New scope with selector filter applied
53
+ #
54
+ # @example Exact string match
55
+ # sheet.with_selector('body')
56
+ # sheet.with_media(:print).with_selector('.header')
57
+ #
58
+ # @example Pattern matching
59
+ # sheet.with_selector(/\.btn-/) # All .btn-* classes
60
+ # sheet.with_selector(/^#/) # All ID selectors
61
+ def with_selector(selector)
62
+ StylesheetScope.new(@stylesheet, @filters.merge(selector: selector))
63
+ end
64
+
65
+ # Filter by CSS property name and optional value.
66
+ #
67
+ # @param property [String] CSS property name to match
68
+ # @param value [String, nil] Optional property value to match
69
+ # @return [StylesheetScope] New scope with property filter applied
70
+ #
71
+ # @example Find rules with color property
72
+ # sheet.with_property('color')
73
+ #
74
+ # @example Find rules with specific property value
75
+ # sheet.with_property('position', 'absolute')
76
+ # sheet.with_property('color', 'red')
77
+ def with_property(property, value = nil)
78
+ StylesheetScope.new(@stylesheet, @filters.merge(property: property, property_value: value))
79
+ end
80
+
81
+ # Filter to only base rules (rules not inside any @media query).
82
+ #
83
+ # @return [StylesheetScope] New scope with base_only filter applied
84
+ #
85
+ # @example Get base rules only
86
+ # sheet.base_only.map(&:selector)
87
+ # sheet.base_only.with_property('color').to_a
88
+ def base_only
89
+ StylesheetScope.new(@stylesheet, @filters.merge(base_only: true))
90
+ end
91
+
92
+ # Filter by at-rule type.
93
+ #
94
+ # @param type [Symbol] At-rule type to match (:keyframes, :font_face, etc.)
95
+ # @return [StylesheetScope] New scope with at-rule type filter applied
96
+ #
97
+ # @example Find all @keyframes
98
+ # sheet.with_at_rule_type(:keyframes)
99
+ #
100
+ # @example Find all @font-face
101
+ # sheet.with_at_rule_type(:font_face)
102
+ def with_at_rule_type(type)
103
+ StylesheetScope.new(@stylesheet, @filters.merge(at_rule_type: type))
104
+ end
105
+
106
+ # Filter to rules with !important declarations.
107
+ #
108
+ # @param property [String, nil] Optional property name to match
109
+ # @return [StylesheetScope] New scope with important filter applied
110
+ #
111
+ # @example Find all rules with any !important
112
+ # sheet.with_important
113
+ #
114
+ # @example Find rules with color !important
115
+ # sheet.with_important('color')
116
+ def with_important(property = nil)
117
+ StylesheetScope.new(@stylesheet, @filters.merge(important: true, important_property: property))
118
+ end
119
+
120
+ # Iterate over filtered rules.
121
+ #
122
+ # @yield [rule] Each rule matching the filters
123
+ # @yieldparam rule [Rule, AtRule] The rule object
124
+ # @return [Enumerator] Enumerator if no block given
125
+ def each
126
+ return enum_for(:each) unless block_given?
127
+
128
+ # Get base rules set
129
+ rules = if @filters[:base_only]
130
+ # Get rules not in any media query
131
+ media_index = @stylesheet.instance_variable_get(:@_media_index)
132
+ media_rule_ids = media_index.values.flatten.uniq
133
+ @stylesheet.rules.select.with_index { |_rule, idx| !media_rule_ids.include?(idx) }
134
+ elsif @filters[:media]
135
+ media_array = Array(@filters[:media])
136
+
137
+ # :all is a special case meaning "all rules"
138
+ if media_array.include?(:all)
139
+ @stylesheet.rules
140
+ else
141
+ media_index = @stylesheet.instance_variable_get(:@_media_index)
142
+ rule_ids = media_array.flat_map { |m| media_index[m] || [] }.uniq
143
+ rule_ids.map { |id| @stylesheet.rules[id] }
144
+ end
145
+ else
146
+ @stylesheet.rules
147
+ end
148
+
149
+ # Apply additional filters during iteration
150
+ rules.each do |rule|
151
+ # Specificity filter
152
+ if @filters[:specificity]
153
+ next if rule.specificity.nil? # AtRules have nil specificity
154
+ next unless case @filters[:specificity]
155
+ when Range
156
+ @filters[:specificity].cover?(rule.specificity)
157
+ else
158
+ @filters[:specificity] == rule.specificity
159
+ end
160
+ end
161
+
162
+ # Selector filter (String or Regexp)
163
+ if @filters[:selector] && !case @filters[:selector]
164
+ when String
165
+ rule.selector == @filters[:selector]
166
+ when Regexp
167
+ @filters[:selector] =~ rule.selector
168
+ end
169
+ next
170
+ end
171
+
172
+ # Property filter
173
+ if @filters[:property] && !rule.has_property?(@filters[:property], @filters[:property_value])
174
+ next
175
+ end
176
+
177
+ # At-rule type filter
178
+ if @filters[:at_rule_type] && !rule.at_rule_type?(@filters[:at_rule_type])
179
+ next
180
+ end
181
+
182
+ # Important filter
183
+ if @filters[:important] && !rule.has_important?(@filters[:important_property])
184
+ next
185
+ end
186
+
187
+ yield rule
188
+ end
189
+ end
190
+
191
+ # Get the number of rules matching the filters.
192
+ #
193
+ # Forces evaluation of the scope.
194
+ #
195
+ # @return [Integer] Number of matching rules
196
+ def size
197
+ to_a.size
198
+ end
199
+ alias length size
200
+
201
+ # Access a rule by index.
202
+ #
203
+ # Forces evaluation of the scope.
204
+ #
205
+ # @param index [Integer] Index of the rule to access
206
+ # @return [Rule, AtRule, nil] Rule at the given index, or nil
207
+ def [](index)
208
+ to_a[index]
209
+ end
210
+
211
+ # Check if the scope has no matching rules.
212
+ #
213
+ # Forces evaluation of the scope.
214
+ #
215
+ # @return [Boolean] true if no rules match the filters
216
+ def empty?
217
+ to_a.empty?
218
+ end
219
+
220
+ # Compare the scope to another object.
221
+ #
222
+ # Forces evaluation of the scope and compares as an array.
223
+ #
224
+ # @param other [Object] Object to compare with
225
+ # @return [Boolean] true if equal
226
+ def ==(other)
227
+ to_a == other
228
+ end
229
+
230
+ # Implicit conversion to Array for Ruby coercion.
231
+ #
232
+ # This allows StylesheetScope to be used transparently as an Array
233
+ # in comparisons and other operations.
234
+ #
235
+ # @return [Array] Array of matching rules
236
+ # @api private
237
+ def to_ary
238
+ to_a
239
+ end
240
+
241
+ # Human-readable representation showing filtered results.
242
+ #
243
+ # Forces evaluation of the scope and displays results.
244
+ #
245
+ # @return [String] Inspection string
246
+ def inspect
247
+ rules = to_a
248
+ if rules.empty?
249
+ '#<Cataract::StylesheetScope []>'
250
+ else
251
+ preview = rules.first(3).map(&:selector).join(', ')
252
+ more = rules.length > 3 ? ', ...' : ''
253
+ "#<Cataract::StylesheetScope [#{preview}#{more}] (#{rules.length} rules)>"
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cataract
4
+ VERSION = '0.1.0'
5
+ end
data/lib/cataract.rb ADDED
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cataract/version'
4
+ require_relative 'cataract/cataract' # Load C extension (defines Rule, Declaration, AtRule structs)
5
+ require_relative 'cataract/rule' # Add Ruby methods to Rule
6
+ require_relative 'cataract/at_rule' # Add Ruby methods to AtRule
7
+ require_relative 'cataract/stylesheet_scope'
8
+ require_relative 'cataract/stylesheet'
9
+ require_relative 'cataract/declarations'
10
+ require_relative 'cataract/import_resolver'
11
+
12
+ # Cataract is a high-performance CSS parser written in C with a Ruby interface.
13
+ #
14
+ # It provides fast CSS parsing, rule querying, cascade merging, and serialization.
15
+ # Designed for performance-critical applications that need to process large amounts of CSS.
16
+ #
17
+ # @example Basic usage
18
+ # require 'cataract'
19
+ #
20
+ # # Parse CSS
21
+ # sheet = Cataract.parse_css("body { color: red; } h1 { color: blue; }")
22
+ #
23
+ # # Query rules
24
+ # sheet.select(&:selector?).each { |rule| puts "#{rule.selector}: #{rule.declarations}" }
25
+ #
26
+ # # Merge with cascade rules
27
+ # merged = sheet.merge
28
+ #
29
+ # @see Stylesheet Main class for working with parsed CSS
30
+ # @see Rule Represents individual CSS rules
31
+ module Cataract
32
+ class << self
33
+ # Parse a CSS string into a Stylesheet object.
34
+ #
35
+ # This is the main entry point for parsing CSS. It returns a Stylesheet
36
+ # object that can be queried, modified, and serialized.
37
+ #
38
+ # @param css [String] The CSS string to parse
39
+ # @param imports [Boolean, Hash] Whether to resolve @import statements.
40
+ # Pass true to enable with defaults, or a hash with options:
41
+ # - allowed_schemes: Array of allowed URI schemes (default: ['https'])
42
+ # - extensions: Array of allowed file extensions (default: ['css'])
43
+ # - max_depth: Maximum import nesting depth (default: 5)
44
+ # - base_path: Base directory for resolving relative imports
45
+ # @return [Stylesheet] A new Stylesheet containing the parsed CSS rules
46
+ # @raise [IOError] If import resolution fails and io_exceptions option is enabled
47
+ #
48
+ # @example Parse simple CSS
49
+ # sheet = Cataract.parse_css("body { color: red; }")
50
+ # sheet.size #=> 1
51
+ #
52
+ # @example Parse with imports
53
+ # sheet = Cataract.parse_css("@import 'style.css';", imports: true)
54
+ #
55
+ # @example Parse with import options
56
+ # sheet = Cataract.parse_css(css, imports: {
57
+ # allowed_schemes: ['https', 'file'],
58
+ # base_path: '/path/to/css'
59
+ # })
60
+ #
61
+ # @see Stylesheet#parse
62
+ # @see Stylesheet.parse
63
+ def parse_css(css, imports: false)
64
+ # Resolve @import statements if requested
65
+ css = ImportResolver.resolve(css, imports) if imports
66
+
67
+ Stylesheet.parse(css)
68
+ end
69
+
70
+ # Merge CSS rules according to CSS cascade rules.
71
+ #
72
+ # Takes a Stylesheet or CSS string and merges all rules according to CSS cascade
73
+ # precedence rules. Returns a new Stylesheet with a single merged rule containing
74
+ # the final computed declarations.
75
+ #
76
+ # @param stylesheet_or_css [Stylesheet, String] The stylesheet to merge, or a CSS string to parse and merge
77
+ # @return [Stylesheet] A new Stylesheet with merged rules
78
+ #
79
+ # Merge rules (in order of precedence):
80
+ # 1. !important declarations win over non-important
81
+ # 2. Higher specificity wins
82
+ # 3. Later declarations with same specificity and importance win
83
+ # 4. Shorthand properties are created from longhand when possible (e.g., margin-* -> margin)
84
+ #
85
+ # @example Merge a stylesheet
86
+ # sheet = Cataract.parse_css(".test { color: red; } #test { color: blue; }")
87
+ # merged = Cataract.merge(sheet)
88
+ # merged.rules.first.declarations #=> [#<Declaration property="color" value="blue" important=false>]
89
+ #
90
+ # @example Merge with !important
91
+ # sheet = Cataract.parse_css(".test { color: red !important; } #test { color: blue; }")
92
+ # merged = Cataract.merge(sheet)
93
+ # merged.rules.first.declarations #=> [#<Declaration property="color" value="red" important=true>]
94
+ #
95
+ # @example Shorthand creation
96
+ # css = ".test { margin-top: 10px; margin-right: 10px; margin-bottom: 10px; margin-left: 10px; }"
97
+ # merged = Cataract.merge(Cataract.parse_css(css))
98
+ # # merged contains single "margin: 10px" declaration instead of four longhand properties
99
+ #
100
+ # @note This is a module-level convenience method. The same functionality is available
101
+ # as an instance method: `stylesheet.merge`
102
+ # @note Implemented in C (see ext/cataract/merge.c)
103
+ #
104
+ # @see Stylesheet#merge
105
+ # Cataract.merge is defined in C via rb_define_module_function
106
+ end
107
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem release tasks
4
+ namespace :gem do
5
+ desc 'Prepare gem for release (compile, test, lint)'
6
+ task prep: %i[compile test lint] do
7
+ puts "\n#{'=' * 80}"
8
+ puts '✓ Gem preparation complete!'
9
+ puts ' - Code compiled successfully'
10
+ puts ' - All tests passed'
11
+ puts ' - Linting passed'
12
+ puts '=' * 80
13
+ puts "\nReady for release! Next steps:"
14
+ puts ' 1. Update version in lib/cataract/version.rb'
15
+ puts ' 2. Update CHANGELOG.md'
16
+ puts ' 3. Commit changes: git commit -am "Release vX.Y.Z"'
17
+ puts ' 4. Run: rake release (creates tag, builds gem, pushes to rubygems)'
18
+ puts '=' * 80
19
+ end
20
+
21
+ desc 'Build and test gem locally (sanity check before release)'
22
+ task build_test: :build do
23
+ require_relative '../cataract/version'
24
+ version = Cataract::VERSION
25
+ gem_file = "pkg/cataract-#{version}.gem"
26
+
27
+ puts "\nTesting gem installation locally..."
28
+ sh "gem install #{gem_file} --local"
29
+
30
+ puts "\nRunning smoke test..."
31
+ ruby_code = <<~RUBY
32
+ require 'cataract'
33
+ sheet = Cataract::Stylesheet.parse('body { color: red; }')
34
+ raise 'Smoke test failed!' unless sheet.rules_count == 1
35
+ puts '✓ Smoke test passed'
36
+ RUBY
37
+ sh "ruby -e \"#{ruby_code}\""
38
+
39
+ puts "\n✓ Local gem test successful: #{gem_file}"
40
+ puts "\nTo release, run: rake release"
41
+ end
42
+
43
+ desc 'Bump version (usage: rake gem:bump[major|minor|patch])'
44
+ task :bump, [:type] do |_t, args|
45
+ type = args[:type] || 'patch'
46
+ unless %w[major minor patch].include?(type)
47
+ abort "Invalid version bump type: #{type}. Use: major, minor, or patch"
48
+ end
49
+
50
+ version_file = 'lib/cataract/version.rb'
51
+ content = File.read(version_file)
52
+
53
+ # Extract current version
54
+ current_version = content[/VERSION = ['"](.+?)['"]/, 1]
55
+ major, minor, patch = current_version.split('.').map(&:to_i)
56
+
57
+ # Bump version
58
+ case type
59
+ when 'major'
60
+ major += 1
61
+ minor = 0
62
+ patch = 0
63
+ when 'minor'
64
+ minor += 1
65
+ patch = 0
66
+ when 'patch'
67
+ patch += 1
68
+ end
69
+
70
+ new_version = "#{major}.#{minor}.#{patch}"
71
+
72
+ # Update file
73
+ new_content = content.gsub(/VERSION = ['"]#{Regexp.escape(current_version)}['"]/, "VERSION = '#{new_version}'")
74
+ File.write(version_file, new_content)
75
+
76
+ puts "Version bumped: #{current_version} → #{new_version}"
77
+ puts "\nNext steps:"
78
+ puts " 1. Review changes: git diff #{version_file}"
79
+ puts ' 2. Update CHANGELOG.md'
80
+ puts " 3. Commit: git commit -am 'Bump version to #{new_version}'"
81
+ end
82
+
83
+ desc 'Prepare release commit (prep, bump version, commit)'
84
+ task :release_commit, [:type] => :prep do |_t, args|
85
+ type = args[:type] || 'patch'
86
+
87
+ # Check for uncommitted changes (ignore untracked files)
88
+ modified_files = `git status --porcelain`.lines.reject { |line| line.start_with?('??') }
89
+ unless modified_files.empty?
90
+ abort 'ERROR: Working directory has uncommitted changes. Commit or stash them first.'
91
+ end
92
+
93
+ # Check we're on main branch
94
+ current_branch = `git rev-parse --abbrev-ref HEAD`.strip
95
+ unless current_branch == 'main'
96
+ puts "WARNING: You're on branch '#{current_branch}', not 'main'"
97
+ print 'Continue anyway? (y/N): '
98
+ response = $stdin.gets.chomp
99
+ abort 'Aborted.' unless response.downcase == 'y'
100
+ end
101
+
102
+ # Bump version
103
+ Rake::Task['gem:bump'].invoke(type)
104
+
105
+ # Reload version
106
+ load 'lib/cataract/version.rb'
107
+ new_version = Cataract::VERSION
108
+
109
+ # Auto-generate CHANGELOG with git-cliff
110
+ puts "\n#{'=' * 80}"
111
+ puts 'Generating CHANGELOG.md with git-cliff...'
112
+ puts '=' * 80
113
+
114
+ if system('which git-cliff > /dev/null 2>&1')
115
+ sh "git-cliff --tag v#{new_version} --output CHANGELOG.md"
116
+ puts '✓ CHANGELOG.md generated'
117
+
118
+ # Show the changelog for review
119
+ puts "\nGenerated CHANGELOG (preview):"
120
+ puts '-' * 80
121
+ system('head -n 50 CHANGELOG.md')
122
+ puts '-' * 80
123
+
124
+ print "\nAccept this CHANGELOG? (Y/n): "
125
+ response = $stdin.gets.chomp
126
+ abort 'Aborted. Edit CHANGELOG.md manually if needed.' if response.downcase == 'n'
127
+ else
128
+ puts 'WARNING: git-cliff not found. Please update CHANGELOG.md manually.'
129
+ puts 'Install: cargo install git-cliff'
130
+ puts "\nPress Enter when ready to commit..."
131
+ $stdin.gets
132
+
133
+ # Check if CHANGELOG was actually updated
134
+ changelog_diff = `git diff CHANGELOG.md`
135
+ if changelog_diff.strip.empty?
136
+ puts 'WARNING: CHANGELOG.md was not modified'
137
+ print 'Continue anyway? (y/N): '
138
+ response = $stdin.gets.chomp
139
+ abort 'Aborted. Please update CHANGELOG.md first.' unless response.downcase == 'y'
140
+ end
141
+ end
142
+
143
+ # Commit changes with Release Bot as author
144
+ commit_message = "Release v#{new_version}"
145
+ git_email = `git config user.email`.strip
146
+ sh 'git add lib/cataract/version.rb CHANGELOG.md'
147
+ sh "git commit --author 'Release Bot <#{git_email}>' -m '#{commit_message}'"
148
+
149
+ puts "\n#{'=' * 80}"
150
+ puts "✓ Release commit created: #{commit_message}"
151
+ puts '=' * 80
152
+ puts "\nNext steps:"
153
+ puts ' 1. Review commit: git show'
154
+ puts ' 2. Push to GitHub: git push'
155
+ puts ' 3. Create release: rake release (builds gem, creates tag, pushes to rubygems)'
156
+ puts '=' * 80
157
+ end
158
+ end