cataract 0.1.3 → 0.2.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci-manual-rubies.yml +44 -0
  3. data/.overcommit.yml +1 -1
  4. data/.rubocop.yml +96 -4
  5. data/.rubocop_todo.yml +186 -0
  6. data/BENCHMARKS.md +62 -141
  7. data/CHANGELOG.md +20 -0
  8. data/RAGEL_MIGRATION.md +2 -2
  9. data/README.md +37 -4
  10. data/Rakefile +72 -32
  11. data/cataract.gemspec +4 -1
  12. data/ext/cataract/cataract.c +59 -50
  13. data/ext/cataract/cataract.h +5 -3
  14. data/ext/cataract/css_parser.c +173 -65
  15. data/ext/cataract/extconf.rb +2 -2
  16. data/ext/cataract/{merge.c → flatten.c} +526 -468
  17. data/ext/cataract/shorthand_expander.c +164 -115
  18. data/lib/cataract/at_rule.rb +8 -9
  19. data/lib/cataract/declaration.rb +18 -0
  20. data/lib/cataract/import_resolver.rb +63 -43
  21. data/lib/cataract/import_statement.rb +49 -0
  22. data/lib/cataract/pure/byte_constants.rb +69 -0
  23. data/lib/cataract/pure/flatten.rb +1145 -0
  24. data/lib/cataract/pure/helpers.rb +35 -0
  25. data/lib/cataract/pure/imports.rb +268 -0
  26. data/lib/cataract/pure/parser.rb +1340 -0
  27. data/lib/cataract/pure/serializer.rb +590 -0
  28. data/lib/cataract/pure/specificity.rb +206 -0
  29. data/lib/cataract/pure.rb +153 -0
  30. data/lib/cataract/rule.rb +69 -15
  31. data/lib/cataract/stylesheet.rb +356 -49
  32. data/lib/cataract/version.rb +1 -1
  33. data/lib/cataract.rb +43 -26
  34. metadata +14 -26
  35. data/benchmarks/benchmark_harness.rb +0 -193
  36. data/benchmarks/benchmark_merging.rb +0 -121
  37. data/benchmarks/benchmark_optimization_comparison.rb +0 -168
  38. data/benchmarks/benchmark_parsing.rb +0 -153
  39. data/benchmarks/benchmark_ragel_removal.rb +0 -56
  40. data/benchmarks/benchmark_runner.rb +0 -70
  41. data/benchmarks/benchmark_serialization.rb +0 -180
  42. data/benchmarks/benchmark_shorthand.rb +0 -109
  43. data/benchmarks/benchmark_shorthand_expansion.rb +0 -176
  44. data/benchmarks/benchmark_specificity.rb +0 -124
  45. data/benchmarks/benchmark_string_allocation.rb +0 -151
  46. data/benchmarks/benchmark_stylesheet_to_s.rb +0 -62
  47. data/benchmarks/benchmark_to_s_cached.rb +0 -55
  48. data/benchmarks/benchmark_value_splitter.rb +0 -54
  49. data/benchmarks/benchmark_yjit.rb +0 -158
  50. data/benchmarks/benchmark_yjit_workers.rb +0 -61
  51. data/benchmarks/profile_to_s.rb +0 -23
  52. data/benchmarks/speedup_calculator.rb +0 -83
  53. data/benchmarks/system_metadata.rb +0 -81
  54. data/benchmarks/templates/benchmarks.md.erb +0 -221
  55. data/benchmarks/yjit_tests.rb +0 -141
  56. data/scripts/fuzzer/run.rb +0 -828
  57. data/scripts/fuzzer/worker.rb +0 -99
  58. data/scripts/generate_benchmarks_md.rb +0 -155
@@ -1,153 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'benchmark_harness'
4
-
5
- # Load the local development version, not installed gem
6
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
7
- require 'cataract'
8
-
9
- # CSS Parsing Performance Benchmark
10
- class ParsingBenchmark < BenchmarkHarness
11
- def self.benchmark_name
12
- 'parsing'
13
- end
14
-
15
- def self.description
16
- 'Time to parse CSS into internal data structures'
17
- end
18
-
19
- def self.metadata
20
- instance = new
21
- {
22
- 'test_cases' => [
23
- {
24
- 'name' => "Small CSS (#{instance.css1.lines.count} lines, #{(instance.css1.length / 1024.0).round(1)}KB)",
25
- 'fixture' => 'CSS1',
26
- 'lines' => instance.css1.lines.count,
27
- 'bytes' => instance.css1.length
28
- },
29
- {
30
- 'name' => "Medium CSS with @media (#{instance.css2.lines.count} lines, #{(instance.css2.length / 1024.0).round(1)}KB)",
31
- 'fixture' => 'CSS2',
32
- 'lines' => instance.css2.lines.count,
33
- 'bytes' => instance.css2.length
34
- }
35
- ]
36
- # speedups will be calculated automatically by harness
37
- }
38
- end
39
-
40
- # Uses default speedup_config from harness (css_parser vs cataract, test_case_key: :fixture)
41
-
42
- def sanity_checks
43
- # Check css_parser gem is available
44
- require 'css_parser'
45
-
46
- # Verify fixtures parse correctly
47
- parser = Cataract::Stylesheet.new
48
- parser.add_block(css1)
49
- raise 'CSS1 sanity check failed: expected rules' if parser.rules_count.zero?
50
-
51
- parser = Cataract::Stylesheet.new
52
- parser.add_block(css2)
53
- raise 'CSS2 sanity check failed: expected rules' if parser.rules_count.zero?
54
- end
55
-
56
- def call
57
- run_css1_benchmark
58
- run_css2_benchmark
59
- show_correctness_comparison
60
- end
61
-
62
- def css1
63
- @css1 ||= File.read(File.join(fixtures_dir, 'css1_sample.css'))
64
- end
65
-
66
- def css2
67
- @css2 ||= File.read(File.join(fixtures_dir, 'css2_sample.css'))
68
- end
69
-
70
- private
71
-
72
- def fixtures_dir
73
- @fixtures_dir ||= File.expand_path('../test/fixtures', __dir__)
74
- end
75
-
76
- def run_css1_benchmark
77
- puts '=' * 80
78
- puts "TEST: CSS1 (#{css1.lines.count} lines, #{css1.length} chars)"
79
- puts '=' * 80
80
-
81
- benchmark('css1') do |x|
82
- x.config(time: 5, warmup: 2)
83
-
84
- x.report('css_parser gem: CSS1') do
85
- parser = CssParser::Parser.new(import: false, io_exceptions: false)
86
- parser.add_block!(css1)
87
- end
88
-
89
- x.report('cataract: CSS1') do
90
- parser = Cataract::Stylesheet.new
91
- parser.add_block(css1)
92
- end
93
-
94
- x.compare!
95
- end
96
- end
97
-
98
- def run_css2_benchmark
99
- puts "\n#{'=' * 80}"
100
- puts "TEST: CSS2 with @media (#{css2.lines.count} lines, #{css2.length} chars)"
101
- puts '=' * 80
102
-
103
- benchmark('css2') do |x|
104
- x.config(time: 5, warmup: 2)
105
-
106
- x.report('css_parser gem: CSS2') do
107
- parser = CssParser::Parser.new(import: false, io_exceptions: false)
108
- parser.add_block!(css2)
109
- end
110
-
111
- x.report('cataract: CSS2') do
112
- parser = Cataract::Stylesheet.new
113
- parser.add_block(css2)
114
- end
115
-
116
- x.compare!
117
- end
118
- end
119
-
120
- def show_correctness_comparison
121
- puts "\n#{'=' * 80}"
122
- puts 'CORRECTNESS VALIDATION (CSS2)'
123
- puts '=' * 80
124
-
125
- # Test Cataract
126
- parser = Cataract::Stylesheet.new
127
- parser.add_block(css2)
128
- cataract_rules = parser.rules_count
129
- puts "Cataract found #{cataract_rules} rules"
130
-
131
- # Test css_parser
132
- css_parser = CssParser::Parser.new(import: false, io_exceptions: false)
133
- css_parser.add_block!(css2)
134
- css_parser_rules = 0
135
- css_parser.each_selector { css_parser_rules += 1 }
136
- puts "css_parser found #{css_parser_rules} rules"
137
-
138
- unless cataract_rules == css_parser_rules
139
- puts '⚠️ Different number of rules parsed'
140
- puts ' Note: css_parser has a known bug with ::after pseudo-elements'
141
- puts ' (it concatenates them with previous rules instead of parsing separately)'
142
- end
143
-
144
- # Show sample output
145
- puts "\nSample Cataract output:"
146
- parser.select(&:selector?).first(5).each do |rule|
147
- puts " #{rule.selector}: #{rule.declarations} (spec: #{rule.specificity})"
148
- end
149
- end
150
- end
151
-
152
- # Run if executed directly
153
- ParsingBenchmark.run if __FILE__ == $PROGRAM_NAME
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'benchmark/ips'
4
-
5
- # Load the local development version, not installed gem
6
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
7
- require 'cataract'
8
-
9
- # Get current branch name
10
- branch = `git rev-parse --abbrev-ref HEAD`.strip
11
-
12
- puts '=' * 80
13
- puts 'RAGEL REMOVAL BENCHMARK - Parsing Performance Comparison'
14
- puts '=' * 80
15
- puts "Current branch: #{branch}"
16
- puts
17
-
18
- # Load bootstrap.css fixture (large real-world CSS file)
19
- fixtures_dir = File.expand_path('../test/fixtures', __dir__)
20
- bootstrap_css = File.read(File.join(fixtures_dir, 'bootstrap.css'))
21
-
22
- puts "Bootstrap CSS: #{bootstrap_css.lines.count} lines, #{bootstrap_css.bytesize} bytes"
23
- puts
24
-
25
- # Verify parsing works
26
- puts 'Verifying parsing works...'
27
- parser = Cataract::Stylesheet.new
28
- parser.add_block(bootstrap_css)
29
- puts " ✅ Parsed successfully (#{parser.rules_count} rules)"
30
- puts
31
-
32
- puts '=' * 80
33
- puts 'BENCHMARK: Bootstrap CSS Parsing'
34
- puts '=' * 80
35
- puts
36
-
37
- Benchmark.ips do |x|
38
- x.config(time: 5, warmup: 2)
39
-
40
- x.report("#{branch}:parse_bootstrap") do
41
- parser = Cataract::Stylesheet.new
42
- parser.add_block(bootstrap_css)
43
- end
44
-
45
- x.compare!
46
-
47
- # Save results to file for cross-branch comparison
48
- x.save! 'test/.benchmark_results/ragel_removal.json'
49
- x.hold! 'test/.benchmark_results/ragel_removal.json'
50
- end
51
-
52
- puts
53
- puts '=' * 80
54
- puts 'Results saved to test/.benchmark_results/ragel_removal.json'
55
- puts 'Switch git branches, recompile, and run again to compare!'
56
- puts '=' * 80
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'benchmark/ips'
4
- require 'json'
5
- require 'fileutils'
6
-
7
- # Unified benchmark runner that outputs both human-readable console output
8
- # and structured JSON for documentation generation.
9
- class BenchmarkRunner
10
- RESULTS_DIR = File.expand_path('.results', __dir__)
11
-
12
- attr_reader :name, :description, :metadata
13
-
14
- # @param name [String] Short name for this benchmark (e.g., "parsing", "specificity")
15
- # @param description [String] One-line description of what's being measured
16
- # @param metadata [Hash] Additional metadata (fixture info, test cases, etc.)
17
- def initialize(name:, description:, metadata: {})
18
- @name = name
19
- @description = description
20
- @metadata = metadata
21
- @results = []
22
-
23
- FileUtils.mkdir_p(RESULTS_DIR)
24
- end
25
-
26
- # Run a benchmark-ips block and capture results
27
- # @yield [Benchmark::IPS::Job] The benchmark-ips job
28
- def run
29
- json_path = File.join(RESULTS_DIR, "#{@name}.json")
30
-
31
- Benchmark.ips do |x|
32
- # Allow benchmark to configure itself
33
- yield x
34
-
35
- # Enable JSON output
36
- x.json!(json_path)
37
- end
38
-
39
- # Read the generated JSON and enhance with metadata
40
- raw_data = JSON.parse(File.read(json_path))
41
- enhanced_data = {
42
- 'name' => @name,
43
- 'description' => @description,
44
- 'metadata' => @metadata,
45
- 'timestamp' => Time.now.iso8601,
46
- 'results' => raw_data
47
- }
48
-
49
- File.write(json_path, JSON.pretty_generate(enhanced_data))
50
- end
51
-
52
- # Helper to format results as a comparison hash
53
- # @param label [String] Label for this result
54
- # @param baseline [String] Baseline label to compare against
55
- # @return [Hash] Structured comparison data
56
- def self.format_comparison(label:, baseline:, results:)
57
- baseline_result = results.find { |r| r['name'] == baseline }
58
- comparison_result = results.find { |r| r['name'] == label }
59
-
60
- return nil unless baseline_result && comparison_result
61
-
62
- speedup = comparison_result['central_tendency'].to_f / baseline_result['central_tendency']
63
-
64
- {
65
- 'label' => label,
66
- 'baseline' => baseline,
67
- 'speedup' => speedup.round(2)
68
- }
69
- end
70
- end
@@ -1,180 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'benchmark_harness'
4
-
5
- # Load the local development version, not installed gem
6
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
7
- require 'cataract'
8
-
9
- # CSS Serialization Performance Benchmark
10
- class SerializationBenchmark < BenchmarkHarness
11
- def self.benchmark_name
12
- 'serialization'
13
- end
14
-
15
- def self.description
16
- 'Time to convert parsed CSS back to string format'
17
- end
18
-
19
- def self.metadata
20
- instance = new
21
- {
22
- 'test_cases' => [
23
- {
24
- 'name' => "Full Serialization (Bootstrap CSS - #{(instance.bootstrap_css.length / 1024.0).round}KB)",
25
- 'key' => 'all',
26
- 'bytes' => instance.bootstrap_css.length
27
- },
28
- {
29
- 'name' => 'Media Type Filtering (print only)',
30
- 'key' => 'print',
31
- 'bytes' => instance.bootstrap_css.length
32
- }
33
- ]
34
- }
35
- end
36
-
37
- # Uses default speedup_config (test_case_key differs from parsing)
38
- def self.speedup_config
39
- {
40
- baseline_matcher: SpeedupCalculator::Matchers.css_parser,
41
- comparison_matcher: SpeedupCalculator::Matchers.cataract,
42
- test_case_key: :key # serialization uses 'key' not 'fixture'
43
- }
44
- end
45
-
46
- def sanity_checks
47
- # Check css_parser gem is available
48
- require 'css_parser'
49
-
50
- # Verify Bootstrap fixture exists
51
- raise "Bootstrap CSS fixture not found at #{bootstrap_path}" unless File.exist?(bootstrap_path)
52
-
53
- # Verify parsing and serialization work
54
- cataract_sheet = Cataract.parse_css(bootstrap_css)
55
- raise 'Failed to parse Bootstrap CSS' if cataract_sheet.empty?
56
-
57
- cataract_output = cataract_sheet.to_s
58
- raise 'Serialization produced empty output' if cataract_output.empty?
59
-
60
- # Verify output can be re-parsed
61
- reparsed = Cataract.parse_css(cataract_output)
62
- raise 'Failed to re-parse serialized output' if reparsed.empty?
63
- end
64
-
65
- def call
66
- validate_correctness
67
- run_full_serialization_benchmark
68
- run_media_filtering_benchmark
69
- end
70
-
71
- def bootstrap_css
72
- @bootstrap_css ||= File.read(bootstrap_path)
73
- end
74
-
75
- private
76
-
77
- def bootstrap_path
78
- @bootstrap_path ||= File.expand_path('../test/fixtures/bootstrap.css', __dir__)
79
- end
80
-
81
- def validate_correctness
82
- puts '=' * 80
83
- puts 'CORRECTNESS VALIDATION'
84
- puts '=' * 80
85
- puts "Input: Bootstrap CSS (#{bootstrap_css.length} bytes)"
86
-
87
- # Parse with both libraries
88
- cataract_sheet = Cataract.parse_css(bootstrap_css)
89
- css_parser = CssParser::Parser.new
90
- css_parser.add_block!(bootstrap_css)
91
-
92
- # Serialize
93
- cataract_output = cataract_sheet.to_s
94
- css_parser_output = css_parser.to_s
95
-
96
- puts "Cataract output: #{cataract_output.length} bytes (#{cataract_sheet.size} rules)"
97
- puts "css_parser output: #{css_parser_output.length} bytes"
98
-
99
- # Basic sanity check - outputs should be similar in size
100
- size_ratio = cataract_output.length.to_f / css_parser_output.length
101
- unless size_ratio > 0.8 && size_ratio < 1.2
102
- puts "⚠️ Output sizes differ significantly (ratio: #{size_ratio.round(2)})"
103
- end
104
-
105
- # Check that output can be re-parsed
106
- begin
107
- reparsed = Cataract.parse_css(cataract_output)
108
- puts "Re-parsed output: #{reparsed.size} rules"
109
- rescue StandardError => e
110
- puts "❌ Failed to re-parse: #{e.message}"
111
- raise
112
- end
113
- end
114
-
115
- def run_full_serialization_benchmark
116
- puts "\n#{'=' * 80}"
117
- puts 'TEST: Full serialization (to_s)'
118
- puts '=' * 80
119
- puts '(Parsing done once before benchmark, not included in measurements)'
120
-
121
- # Pre-parse CSS once (outside benchmark loop)
122
- cataract_parsed = Cataract.parse_css(bootstrap_css)
123
- css_parser_parsed = CssParser::Parser.new
124
- css_parser_parsed.add_block!(bootstrap_css)
125
-
126
- benchmark('all') do |x|
127
- x.config(time: 5, warmup: 2)
128
-
129
- x.report('css_parser: all') do
130
- # Clear memoization if any
131
- if css_parser_parsed.instance_variable_defined?(:@css_string)
132
- css_parser_parsed.instance_variable_set(:@css_string, nil)
133
- end
134
- css_parser_parsed.to_s
135
- end
136
-
137
- x.report('cataract: all') do
138
- # Clear memoization
139
- cataract_parsed.instance_variable_set(:@serialized, nil)
140
- cataract_parsed.to_s
141
- end
142
-
143
- x.compare!
144
- end
145
- end
146
-
147
- def run_media_filtering_benchmark
148
- puts "\n#{'=' * 80}"
149
- puts 'TEST: Media type filtering - to_s(:print)'
150
- puts '=' * 80
151
- puts 'Note: Using Parser API (css_parser compatible) not Stylesheet'
152
-
153
- # Pre-parse using Parser API for media filtering
154
- cataract_parser = Cataract::Stylesheet.new
155
- cataract_parser.add_block(bootstrap_css)
156
-
157
- css_parser_for_filter = CssParser::Parser.new
158
- css_parser_for_filter.add_block!(bootstrap_css)
159
-
160
- benchmark('print') do |x|
161
- x.config(time: 5, warmup: 2)
162
-
163
- x.report('css_parser: print') do
164
- if css_parser_for_filter.instance_variable_defined?(:@css_string)
165
- css_parser_for_filter.instance_variable_set(:@css_string, nil)
166
- end
167
- css_parser_for_filter.to_s(:print)
168
- end
169
-
170
- x.report('cataract: print') do
171
- cataract_parser.to_s(media: :print)
172
- end
173
-
174
- x.compare!
175
- end
176
- end
177
- end
178
-
179
- # Run if executed directly
180
- SerializationBenchmark.run if __FILE__ == $PROGRAM_NAME
@@ -1,109 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'benchmark/ips'
4
-
5
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
- require 'cataract'
7
-
8
- # Get current git branch
9
- branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
10
- branch = 'unknown' if branch.empty?
11
-
12
- puts '=' * 60
13
- puts "Shorthand Expansion/Creation Benchmark: #{branch}"
14
- puts '=' * 60
15
- puts ''
16
-
17
- # Test cases for expansion
18
- expansion_tests = {
19
- 'margin' => '10px 20px',
20
- 'padding' => '5px 10px 15px 20px',
21
- 'border' => '1px solid red',
22
- 'border-color' => 'red blue',
23
- 'font' => 'bold 14px/1.5 Arial, sans-serif',
24
- 'background' => 'url(image.png) no-repeat center/cover'
25
- }
26
-
27
- # Test cases for shorthand creation
28
- creation_tests = {
29
- 'margin' => {
30
- 'margin-top' => '10px',
31
- 'margin-right' => '20px',
32
- 'margin-bottom' => '10px',
33
- 'margin-left' => '20px'
34
- },
35
- 'border' => {
36
- 'border-width' => '1px',
37
- 'border-style' => 'solid',
38
- 'border-color' => 'red'
39
- },
40
- 'font' => {
41
- 'font-style' => 'italic',
42
- 'font-weight' => 'bold',
43
- 'font-size' => '14px',
44
- 'line-height' => '1.5',
45
- 'font-family' => 'Arial, sans-serif'
46
- }
47
- }
48
-
49
- puts 'Expansion test cases:'
50
- expansion_tests.each do |prop, value|
51
- puts " #{prop}: '#{value}'"
52
- end
53
- puts ''
54
-
55
- puts 'Shorthand creation test cases:'
56
- creation_tests.each do |name, props|
57
- puts " #{name}: #{props.length} properties"
58
- end
59
- puts ''
60
-
61
- Benchmark.ips do |x|
62
- x.config(time: 10, warmup: 3)
63
-
64
- # Benchmark expansions
65
- x.report("#{branch}:expand_margin ") do
66
- Cataract.expand_margin(expansion_tests['margin'])
67
- end
68
-
69
- x.report("#{branch}:expand_padding ") do
70
- Cataract.expand_padding(expansion_tests['padding'])
71
- end
72
-
73
- x.report("#{branch}:expand_border ") do
74
- Cataract.expand_border(expansion_tests['border'])
75
- end
76
-
77
- x.report("#{branch}:expand_font ") do
78
- Cataract.expand_font(expansion_tests['font'])
79
- end
80
-
81
- x.report("#{branch}:expand_background ") do
82
- Cataract.expand_background(expansion_tests['background'])
83
- end
84
-
85
- # Benchmark shorthand creation
86
- x.report("#{branch}:create_margin ") do
87
- Cataract.create_margin_shorthand(creation_tests['margin'])
88
- end
89
-
90
- x.report("#{branch}:create_border ") do
91
- Cataract.create_border_shorthand(creation_tests['border'])
92
- end
93
-
94
- x.report("#{branch}:create_font ") do
95
- Cataract.create_font_shorthand(creation_tests['font'])
96
- end
97
-
98
- x.compare!
99
-
100
- # Save results to file for cross-branch comparison
101
- x.save! 'test/.benchmark_results/shorthand.json'
102
- x.hold! 'test/.benchmark_results/shorthand.json'
103
- end
104
-
105
- puts ''
106
- puts '=' * 60
107
- puts 'Results saved to test/.benchmark_results/shorthand.json'
108
- puts 'Switch git branches and run again to compare!'
109
- puts '=' * 60