cataract 0.2.2 → 0.2.4

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.
@@ -131,10 +131,8 @@ module Cataract
131
131
 
132
132
  # Get base rules set
133
133
  rules = if @filters[:base_only]
134
- # Get rules not in any media query
135
- media_index = @stylesheet.instance_variable_get(:@_media_index)
136
- media_rule_ids = media_index.values.flatten.uniq
137
- @stylesheet.rules.select.with_index { |_rule, idx| !media_rule_ids.include?(idx) }
134
+ # Get rules not in any media query (media_query_id is nil)
135
+ @stylesheet.rules.select { |r| r.is_a?(Rule) && r.media_query_id.nil? }
138
136
  elsif @filters[:media]
139
137
  media_array = Array(@filters[:media])
140
138
 
@@ -142,9 +140,14 @@ module Cataract
142
140
  if media_array.include?(:all)
143
141
  @stylesheet.rules
144
142
  else
145
- media_index = @stylesheet.instance_variable_get(:@_media_index)
146
- rule_ids = media_array.flat_map { |m| media_index[m] || [] }.uniq
147
- rule_ids.map { |id| @stylesheet.rules[id] }
143
+ # Use media_index for efficient lookup (it handles compound media queries)
144
+ matching_rule_ids = Set.new
145
+ media_array.each do |media_sym|
146
+ rule_ids = @stylesheet.media_index[media_sym]
147
+ matching_rule_ids.merge(rule_ids) if rule_ids
148
+ end
149
+ # Filter rules by ID
150
+ @stylesheet.rules.select { |r| matching_rule_ids.include?(r.id) }
148
151
  end
149
152
  else
150
153
  @stylesheet.rules
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cataract
4
- VERSION = '0.2.2'
4
+ VERSION = '0.2.4'
5
5
  end
data/lib/cataract.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'cataract/version'
4
+ require_relative 'cataract/constants'
4
5
 
5
6
  # Load struct definitions first (before C extension or pure Ruby)
6
7
  require_relative 'cataract/declaration'
7
8
  require_relative 'cataract/rule'
8
9
  require_relative 'cataract/at_rule'
10
+ require_relative 'cataract/media_query'
9
11
  require_relative 'cataract/import_statement'
10
12
 
11
13
  # Load pure Ruby or C extension based on ENV var
@@ -73,14 +75,8 @@ module Cataract
73
75
  # @see Stylesheet#parse
74
76
  # @see Stylesheet.parse
75
77
  unless method_defined?(:parse_css)
76
- def parse_css(css, imports: false)
77
- # Pass import options to Stylesheet.parse
78
- # The new flow: parse first (extract @import), then resolve them
79
- if imports
80
- Stylesheet.parse(css, import: imports)
81
- else
82
- Stylesheet.parse(css)
83
- end
78
+ def parse_css(css, **options)
79
+ Stylesheet.parse(css, **options)
84
80
  end
85
81
  end
86
82
 
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :profile do
4
+ desc 'Profile pure Ruby parser with bootstrap.css (outputs JSON for speedscope)'
5
+ task :parsing do
6
+ require 'fileutils'
7
+
8
+ # Check for stackprof
9
+ begin
10
+ require 'stackprof'
11
+ rescue LoadError
12
+ abort('stackprof gem not found. Install with: gem install stackprof')
13
+ end
14
+
15
+ # Ensure we're using pure Ruby implementation
16
+ ENV['CATARACT_PURE'] = '1'
17
+
18
+ fixture_path = File.expand_path('../../test/fixtures/bootstrap.css', __dir__)
19
+ unless File.exist?(fixture_path)
20
+ abort("Fixture not found: #{fixture_path}")
21
+ end
22
+
23
+ output_dir = 'tmp/profile'
24
+ FileUtils.mkdir_p(output_dir)
25
+ json_output = File.join(output_dir, 'stackprof-parsing.json')
26
+
27
+ puts 'Profiling pure Ruby parser with bootstrap.css'
28
+ puts '=' * 80
29
+ puts "Fixture: #{fixture_path}"
30
+ puts "Output: #{json_output}"
31
+ puts '=' * 80
32
+
33
+ # Load the CSS content
34
+ css_content = File.read(fixture_path)
35
+ puts "CSS size: #{css_content.bytesize} bytes"
36
+ puts
37
+
38
+ require_relative '../../lib/cataract/pure'
39
+ require 'json'
40
+
41
+ # Use higher sampling rate (interval in microseconds, default is 1000)
42
+ # Lower interval = higher sampling rate = more detailed profile
43
+ profile = StackProf.run(mode: :wall, raw: true, interval: 100) do
44
+ # Parse multiple times to get better signal
45
+ 100.times do
46
+ Cataract.parse_css(css_content)
47
+ end
48
+ end
49
+
50
+ # Write JSON output
51
+ File.write(json_output, JSON.generate(profile))
52
+
53
+ puts
54
+ puts 'Profile complete!'
55
+ puts "JSON output: #{json_output}"
56
+ puts
57
+ puts 'View in speedscope:'
58
+ puts ' 1. Visit https://www.speedscope.app/'
59
+ puts " 2. Drag and drop: #{json_output}"
60
+ puts
61
+ puts 'Or use stackprof CLI:'
62
+ puts " stackprof #{json_output} --text"
63
+ puts " stackprof #{json_output} --method 'Cataract'"
64
+ end
65
+
66
+ desc 'Profile pure Ruby flatten with bootstrap.css (outputs JSON for speedscope)'
67
+ task :flatten do
68
+ require 'fileutils'
69
+
70
+ # Check for stackprof
71
+ begin
72
+ require 'stackprof'
73
+ rescue LoadError
74
+ abort('stackprof gem not found. Install with: gem install stackprof')
75
+ end
76
+
77
+ fixture_path = File.expand_path('../../test/fixtures/bootstrap.css', __dir__)
78
+ unless File.exist?(fixture_path)
79
+ abort("Fixture not found: #{fixture_path}")
80
+ end
81
+
82
+ output_dir = 'tmp/profile'
83
+ FileUtils.mkdir_p(output_dir)
84
+ json_output = File.join(output_dir, 'stackprof-flatten.json')
85
+
86
+ puts 'Profiling pure Ruby flatten with bootstrap.css'
87
+ puts '=' * 80
88
+ puts "Fixture: #{fixture_path}"
89
+ puts "Output: #{json_output}"
90
+ puts '=' * 80
91
+
92
+ # Load the CSS content
93
+ css_content = File.read(fixture_path)
94
+ puts "CSS size: #{css_content.bytesize} bytes"
95
+ puts
96
+
97
+ require_relative '../../lib/cataract/pure'
98
+ require 'json'
99
+
100
+ # Parse once outside profiling to get stylesheet
101
+ stylesheet = Cataract.parse_css(css_content)
102
+ puts "Parsed: #{stylesheet.rules.size} rules"
103
+ puts
104
+
105
+ # Profile flatten only
106
+ # Use higher sampling rate (interval in microseconds, default is 1000)
107
+ # Lower interval = higher sampling rate = more detailed profile
108
+ profile = StackProf.run(mode: :wall, raw: true, interval: 100) do
109
+ # Flatten multiple times to get better signal
110
+ 100.times do
111
+ Cataract.flatten(stylesheet.dup)
112
+ end
113
+ end
114
+
115
+ # Write JSON output
116
+ File.write(json_output, JSON.generate(profile))
117
+
118
+ puts
119
+ puts 'Profile complete!'
120
+ puts "JSON output: #{json_output}"
121
+ puts
122
+ puts 'View in speedscope:'
123
+ puts ' 1. Visit https://www.speedscope.app/'
124
+ puts " 2. Drag and drop: #{json_output}"
125
+ puts
126
+ puts 'Or use stackprof CLI:'
127
+ puts " stackprof #{json_output} --text"
128
+ puts " stackprof #{json_output} --method 'Cataract'"
129
+ end
130
+
131
+ desc 'Profile pure Ruby serialization with bootstrap.css (outputs JSON for speedscope)'
132
+ task :serialization do
133
+ require 'fileutils'
134
+
135
+ # Check for stackprof
136
+ begin
137
+ require 'stackprof'
138
+ rescue LoadError
139
+ abort('stackprof gem not found. Install with: gem install stackprof')
140
+ end
141
+
142
+ # Ensure we're using pure Ruby implementation
143
+ ENV['CATARACT_PURE'] = '1'
144
+
145
+ fixture_path = File.expand_path('../../test/fixtures/bootstrap.css', __dir__)
146
+ unless File.exist?(fixture_path)
147
+ abort("Fixture not found: #{fixture_path}")
148
+ end
149
+
150
+ output_dir = 'tmp/profile'
151
+ FileUtils.mkdir_p(output_dir)
152
+ json_output = File.join(output_dir, 'stackprof-serialization.json')
153
+
154
+ puts 'Profiling pure Ruby serialization with bootstrap.css'
155
+ puts '=' * 80
156
+ puts "Fixture: #{fixture_path}"
157
+ puts "Output: #{json_output}"
158
+ puts '=' * 80
159
+
160
+ # Load the CSS content
161
+ css_content = File.read(fixture_path)
162
+ puts "CSS size: #{css_content.bytesize} bytes"
163
+ puts
164
+
165
+ require_relative '../../lib/cataract/pure'
166
+ require 'json'
167
+
168
+ # Parse once outside profiling to get stylesheet
169
+ stylesheet = Cataract.parse_css(css_content)
170
+ puts "Parsed: #{stylesheet.rules.size} rules"
171
+ puts
172
+
173
+ # Profile serialization only (to_s)
174
+ # Use higher sampling rate (interval in microseconds, default is 1000)
175
+ # Lower interval = higher sampling rate = more detailed profile
176
+ profile = StackProf.run(mode: :wall, raw: true, interval: 100) do
177
+ # Serialize multiple times to get better signal
178
+ 100.times do
179
+ stylesheet.dup.to_s
180
+ end
181
+ end
182
+
183
+ # Write JSON output
184
+ File.write(json_output, JSON.generate(profile))
185
+
186
+ puts
187
+ puts 'Profile complete!'
188
+ puts "JSON output: #{json_output}"
189
+ puts
190
+ puts 'View in speedscope:'
191
+ puts ' 1. Visit https://www.speedscope.app/'
192
+ puts " 2. Drag and drop: #{json_output}"
193
+ puts
194
+ puts 'Or use stackprof CLI:'
195
+ puts " stackprof #{json_output} --text"
196
+ puts " stackprof #{json_output} --method 'Cataract'"
197
+ end
198
+ end
199
+
200
+ desc 'Profile pure Ruby parser, flatten, and serialization (outputs JSON for speedscope)'
201
+ task profile: ['profile:parsing', 'profile:flatten', 'profile:serialization'] do
202
+ puts
203
+ puts '=' * 80
204
+ puts 'All profiles complete!'
205
+ puts 'Artifacts in: tmp/profile/'
206
+ puts ' - stackprof-parsing.json'
207
+ puts ' - stackprof-flatten.json'
208
+ puts ' - stackprof-serialization.json'
209
+ puts '=' * 80
210
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cataract
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Cook
@@ -80,15 +80,16 @@ files:
80
80
  - lib/cataract.rb
81
81
  - lib/cataract/at_rule.rb
82
82
  - lib/cataract/color_conversion.rb
83
+ - lib/cataract/constants.rb
83
84
  - lib/cataract/declaration.rb
84
85
  - lib/cataract/declarations.rb
85
86
  - lib/cataract/import_resolver.rb
86
87
  - lib/cataract/import_statement.rb
88
+ - lib/cataract/media_query.rb
87
89
  - lib/cataract/pure.rb
88
90
  - lib/cataract/pure/byte_constants.rb
89
91
  - lib/cataract/pure/flatten.rb
90
92
  - lib/cataract/pure/helpers.rb
91
- - lib/cataract/pure/imports.rb
92
93
  - lib/cataract/pure/parser.rb
93
94
  - lib/cataract/pure/serializer.rb
94
95
  - lib/cataract/pure/specificity.rb
@@ -97,6 +98,7 @@ files:
97
98
  - lib/cataract/stylesheet_scope.rb
98
99
  - lib/cataract/version.rb
99
100
  - lib/tasks/gem.rake
101
+ - lib/tasks/profile.rake
100
102
  homepage: https://github.com/jamescook/cataract
101
103
  licenses:
102
104
  - MIT
@@ -1,268 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Pure Ruby CSS parser - Import extraction
4
- # NO REGEXP ALLOWED - char-by-char parsing only
5
-
6
- module Cataract
7
- # Helper: Case-insensitive ASCII byte comparison
8
- # Compares bytes at given position with ASCII pattern (case-insensitive)
9
- # Safe to use even if position is in middle of multi-byte UTF-8 characters
10
- # Returns true if match, false otherwise
11
- def self.match_ascii_ci?(str, pos, pattern)
12
- pattern_len = pattern.bytesize
13
- return false if pos + pattern_len > str.bytesize
14
-
15
- i = 0
16
- while i < pattern_len
17
- str_byte = str.getbyte(pos + i)
18
- pat_byte = pattern.getbyte(i)
19
-
20
- # Convert both to lowercase for comparison (ASCII only: A-Z -> a-z)
21
- str_byte += BYTE_CASE_DIFF if str_byte >= BYTE_UPPER_A && str_byte <= BYTE_UPPER_Z
22
- pat_byte += BYTE_CASE_DIFF if pat_byte >= BYTE_UPPER_A && pat_byte <= BYTE_UPPER_Z
23
-
24
- return false if str_byte != pat_byte
25
-
26
- i += 1
27
- end
28
-
29
- true
30
- end
31
-
32
- # Extract @import statements from CSS
33
- #
34
- # @param css_string [String] CSS to scan for @imports
35
- # @return [Array<Hash>] Array of import hashes with :url, :media, :full_match
36
- def self.extract_imports(css_string)
37
- imports = []
38
-
39
- i = 0
40
- len = css_string.length
41
-
42
- while i < len
43
- # Skip whitespace and comments
44
- while i < len
45
- byte = css_string.getbyte(i)
46
- if is_whitespace?(byte)
47
- i += 1
48
- elsif i + 1 < len && css_string.getbyte(i) == BYTE_SLASH && css_string.getbyte(i + 1) == BYTE_STAR
49
- # Skip /* */ comment
50
- i += 2
51
- while i + 1 < len && !(css_string.getbyte(i) == BYTE_STAR && css_string.getbyte(i + 1) == BYTE_SLASH)
52
- i += 1
53
- end
54
- i += 2 if i + 1 < len # Skip */
55
- else
56
- break
57
- end
58
- end
59
-
60
- break if i >= len
61
-
62
- # Check for @import (case-insensitive byte comparison)
63
- if match_ascii_ci?(css_string, i, '@import')
64
- import_start = i
65
- i += 7
66
-
67
- # Skip whitespace after @import
68
- while i < len && is_whitespace?(css_string.getbyte(i))
69
- i += 1
70
- end
71
-
72
- # Check for optional url( (case-insensitive byte comparison)
73
- has_url_function = false
74
- if match_ascii_ci?(css_string, i, 'url(')
75
- has_url_function = true
76
- i += 4
77
- while i < len && is_whitespace?(css_string.getbyte(i))
78
- i += 1
79
- end
80
- end
81
-
82
- # Find opening quote
83
- byte = css_string.getbyte(i) if i < len
84
- if i >= len || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
85
- # Invalid @import, skip to next semicolon
86
- while i < len && css_string.getbyte(i) != BYTE_SEMICOLON
87
- i += 1
88
- end
89
- i += 1 if i < len # Skip semicolon
90
- next
91
- end
92
-
93
- quote_char = byte
94
- i += 1 # Skip opening quote
95
-
96
- url_start = i
97
-
98
- # Find closing quote (handle escaped quotes)
99
- while i < len && css_string.getbyte(i) != quote_char
100
- if css_string.getbyte(i) == BYTE_BACKSLASH && i + 1 < len
101
- i += 2 # Skip escaped character
102
- else
103
- i += 1
104
- end
105
- end
106
-
107
- break if i >= len # Unterminated string
108
-
109
- url_end = i
110
- i += 1 # Skip closing quote
111
-
112
- # Skip closing paren if we had url(
113
- if has_url_function
114
- while i < len && is_whitespace?(css_string.getbyte(i))
115
- i += 1
116
- end
117
- if i < len && css_string.getbyte(i) == BYTE_RPAREN
118
- i += 1
119
- end
120
- end
121
-
122
- # Skip whitespace before optional media query or semicolon
123
- while i < len && is_whitespace?(css_string.getbyte(i))
124
- i += 1
125
- end
126
-
127
- # Check for optional media query (everything until semicolon)
128
- media_start = nil
129
- media_end = nil
130
-
131
- if i < len && css_string.getbyte(i) != BYTE_SEMICOLON
132
- media_start = i
133
-
134
- # Find semicolon
135
- while i < len && css_string.getbyte(i) != BYTE_SEMICOLON
136
- i += 1
137
- end
138
-
139
- media_end = i
140
-
141
- # Trim trailing whitespace from media query
142
- while media_end > media_start && is_whitespace?(css_string.getbyte(media_end - 1))
143
- media_end -= 1
144
- end
145
- end
146
-
147
- # Skip semicolon
148
- i += 1 if i < len && css_string.getbyte(i) == BYTE_SEMICOLON
149
-
150
- import_end = i
151
-
152
- # Build result hash
153
- url = css_string[url_start...url_end]
154
- media = media_start && media_end > media_start ? css_string[media_start...media_end] : nil
155
- full_match = css_string[import_start...import_end]
156
-
157
- imports << { url: url, media: media, full_match: full_match }
158
- elsif match_ascii_ci?(css_string, i, '@charset')
159
- # Skip @charset if present - it can come before @import
160
- while i < len && css_string.getbyte(i) != BYTE_SEMICOLON
161
- i += 1
162
- end
163
- i += 1 if i < len # Skip semicolon
164
- else
165
- # If we hit any other content (rules, other at-rules), stop scanning
166
- # Per CSS spec, @import must be at the top (only @charset can come before)
167
- byte = css_string.getbyte(i) if i < len
168
- if i < len && !is_whitespace?(byte)
169
- break
170
- end
171
-
172
- i += 1
173
- end
174
- end
175
-
176
- imports
177
- end
178
-
179
- # Parse media query symbol into array of media types
180
- #
181
- # @param media_query_sym [Symbol] Media query as symbol (e.g., :screen, :"print, screen")
182
- # @return [Array<Symbol>] Array of individual media types
183
- #
184
- # @example
185
- # parse_media_types(:screen) #=> [:screen]
186
- # parse_media_types(:"print, screen") #=> [:print, :screen]
187
- def self.parse_media_types(media_query_sym)
188
- query = media_query_sym.to_s
189
- types = []
190
-
191
- i = 0
192
- len = query.length
193
-
194
- kwords = %w[and or not only]
195
-
196
- while i < len
197
- # Skip whitespace
198
- while i < len && is_whitespace?(query.getbyte(i))
199
- i += 1
200
- end
201
- break if i >= len
202
-
203
- # Check for opening paren - skip conditions like "(min-width: 768px)"
204
- if query.getbyte(i) == BYTE_LPAREN
205
- # Skip to matching closing paren
206
- paren_depth = 1
207
- i += 1
208
- while i < len && paren_depth > 0
209
- byte = query.getbyte(i)
210
- if byte == BYTE_LPAREN
211
- paren_depth += 1
212
- elsif byte == BYTE_RPAREN
213
- paren_depth -= 1
214
- end
215
- i += 1
216
- end
217
- next
218
- end
219
-
220
- # Find end of word (media type or keyword)
221
- word_start = i
222
- byte = query.getbyte(i)
223
- while i < len && !is_whitespace?(byte) && byte != BYTE_COMMA && byte != BYTE_LPAREN && byte != BYTE_COLON
224
- i += 1
225
- byte = query.getbyte(i) if i < len
226
- end
227
-
228
- if i > word_start
229
- word = query[word_start...i]
230
-
231
- # Check if this is a media feature (followed by ':')
232
- is_media_feature = (i < len && query.getbyte(i) == BYTE_COLON)
233
-
234
- # Check if it's a keyword (and, or, not, only)
235
- is_keyword = kwords.include?(word)
236
-
237
- if !is_keyword && !is_media_feature
238
- # This is a media type - add it as symbol
239
- types << word.to_sym
240
- end
241
- end
242
-
243
- # Skip to comma or end
244
- while i < len && query.getbyte(i) != BYTE_COMMA
245
- if query.getbyte(i) == BYTE_LPAREN
246
- # Skip condition
247
- paren_depth = 1
248
- i += 1
249
- while i < len && paren_depth > 0
250
- byte = query.getbyte(i)
251
- if byte == BYTE_LPAREN
252
- paren_depth += 1
253
- elsif byte == BYTE_RPAREN
254
- paren_depth -= 1
255
- end
256
- i += 1
257
- end
258
- else
259
- i += 1
260
- end
261
- end
262
-
263
- i += 1 if i < len && query.getbyte(i) == BYTE_COMMA # Skip comma
264
- end
265
-
266
- types
267
- end
268
- end