cataract 0.1.3 → 0.1.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci-manual-rubies.yml +27 -0
  3. data/.overcommit.yml +1 -1
  4. data/.rubocop.yml +62 -0
  5. data/.rubocop_todo.yml +186 -0
  6. data/BENCHMARKS.md +60 -139
  7. data/CHANGELOG.md +10 -0
  8. data/README.md +30 -2
  9. data/Rakefile +49 -22
  10. data/cataract.gemspec +4 -1
  11. data/ext/cataract/cataract.c +47 -47
  12. data/ext/cataract/css_parser.c +17 -33
  13. data/ext/cataract/merge.c +6 -0
  14. data/lib/cataract/at_rule.rb +8 -9
  15. data/lib/cataract/declaration.rb +18 -0
  16. data/lib/cataract/import_resolver.rb +3 -4
  17. data/lib/cataract/pure/byte_constants.rb +69 -0
  18. data/lib/cataract/pure/helpers.rb +35 -0
  19. data/lib/cataract/pure/imports.rb +255 -0
  20. data/lib/cataract/pure/merge.rb +1146 -0
  21. data/lib/cataract/pure/parser.rb +1236 -0
  22. data/lib/cataract/pure/serializer.rb +590 -0
  23. data/lib/cataract/pure/specificity.rb +206 -0
  24. data/lib/cataract/pure.rb +130 -0
  25. data/lib/cataract/rule.rb +22 -13
  26. data/lib/cataract/stylesheet.rb +14 -9
  27. data/lib/cataract/version.rb +1 -1
  28. data/lib/cataract.rb +18 -5
  29. metadata +12 -25
  30. data/benchmarks/benchmark_harness.rb +0 -193
  31. data/benchmarks/benchmark_merging.rb +0 -121
  32. data/benchmarks/benchmark_optimization_comparison.rb +0 -168
  33. data/benchmarks/benchmark_parsing.rb +0 -153
  34. data/benchmarks/benchmark_ragel_removal.rb +0 -56
  35. data/benchmarks/benchmark_runner.rb +0 -70
  36. data/benchmarks/benchmark_serialization.rb +0 -180
  37. data/benchmarks/benchmark_shorthand.rb +0 -109
  38. data/benchmarks/benchmark_shorthand_expansion.rb +0 -176
  39. data/benchmarks/benchmark_specificity.rb +0 -124
  40. data/benchmarks/benchmark_string_allocation.rb +0 -151
  41. data/benchmarks/benchmark_stylesheet_to_s.rb +0 -62
  42. data/benchmarks/benchmark_to_s_cached.rb +0 -55
  43. data/benchmarks/benchmark_value_splitter.rb +0 -54
  44. data/benchmarks/benchmark_yjit.rb +0 -158
  45. data/benchmarks/benchmark_yjit_workers.rb +0 -61
  46. data/benchmarks/profile_to_s.rb +0 -23
  47. data/benchmarks/speedup_calculator.rb +0 -83
  48. data/benchmarks/system_metadata.rb +0 -81
  49. data/benchmarks/templates/benchmarks.md.erb +0 -221
  50. data/benchmarks/yjit_tests.rb +0 -141
  51. data/scripts/fuzzer/run.rb +0 -828
  52. data/scripts/fuzzer/worker.rb +0 -99
  53. data/scripts/generate_benchmarks_md.rb +0 -155
data/Rakefile CHANGED
@@ -28,15 +28,51 @@ end
28
28
  # rake-compiler already adds: tmp/, lib/**/*.{so,bundle}, etc.
29
29
  CLEAN.include('ext/**/Makefile', 'ext/**/*.o')
30
30
 
31
- Rake::TestTask.new(:test) do |t|
32
- t.libs << 'test'
33
- t.libs << 'lib'
34
- # Load test_helper before running tests (handles SimpleCov setup)
35
- t.ruby_opts << '-rtest_helper'
36
- # Exclude css_parser_compat directory (reference tests only, not run)
37
- t.test_files = FileList['test/**/test_*.rb'].exclude('test/css_parser_compat/**/*')
31
+ namespace :test do
32
+ desc 'Run tests with C extension (default)'
33
+ Rake::TestTask.new(:c) do |t|
34
+ t.libs << 'test'
35
+ t.libs << 'lib'
36
+ t.ruby_opts << '-rtest_helper'
37
+ t.test_files = FileList['test/**/test_*.rb'].exclude('test/css_parser_compat/**/*', 'test/color/**/*')
38
+ end
39
+
40
+ desc 'Run tests with pure Ruby implementation'
41
+ task :pure do
42
+ # Run in subprocess to avoid conflicts with C extension
43
+ success = system({ 'CATARACT_PURE' => '1' }, 'rake', 'test:c')
44
+ abort('Pure Ruby tests failed!') unless success
45
+ end
46
+
47
+ desc 'Run tests for both C extension and pure Ruby'
48
+ task :all do
49
+ impls = ['pure ruby']
50
+
51
+ unless RUBY_ENGINE == 'jruby'
52
+ puts "\n#{'=' * 80}"
53
+ puts 'Running tests for C EXTENSION'
54
+ puts '=' * 80
55
+ impls << 'C extension'
56
+
57
+ Rake::Task['test:c'].invoke
58
+ end
59
+
60
+ puts "\n#{'=' * 80}"
61
+ puts 'Running tests for PURE RUBY implementation'
62
+ puts '=' * 80
63
+
64
+ Rake::Task['test:pure'].invoke
65
+
66
+ puts "\n#{'=' * 80}"
67
+ puts "✓ All tests passed for #{impls.join(' and ')}"
68
+ puts '=' * 80
69
+ end
38
70
  end
39
71
 
72
+ # Default test task runs both implementations
73
+ desc 'Run tests for both C extension and pure Ruby (default)'
74
+ task test: 'test:all'
75
+
40
76
  desc 'Run all benchmarks'
41
77
  task :benchmark do
42
78
  Rake::Task[:compile].invoke
@@ -44,7 +80,6 @@ task :benchmark do
44
80
  Rake::Task['benchmark:serialization'].invoke
45
81
  Rake::Task['benchmark:specificity'].invoke
46
82
  Rake::Task['benchmark:merging'].invoke
47
- Rake::Task['benchmark:yjit'].invoke
48
83
  puts "\n#{'-' * 80}"
49
84
  puts 'All benchmarks complete!'
50
85
  puts 'Generate documentation with: rake benchmark:generate_docs'
@@ -53,35 +88,29 @@ end
53
88
 
54
89
  namespace :benchmark do
55
90
  desc 'Benchmark CSS parsing performance'
56
- task :parsing do
91
+ task parsing: :compile do
57
92
  puts 'Running parsing benchmark...'
58
93
  ruby 'benchmarks/benchmark_parsing.rb'
59
94
  end
60
95
 
61
96
  desc 'Benchmark CSS serialization (to_s) performance'
62
- task :serialization do
97
+ task serialization: :compile do
63
98
  puts 'Running serialization benchmark...'
64
99
  ruby 'benchmarks/benchmark_serialization.rb'
65
100
  end
66
101
 
67
102
  desc 'Benchmark specificity calculation performance'
68
- task :specificity do
103
+ task specificity: :compile do
69
104
  puts 'Running specificity benchmark...'
70
105
  ruby 'benchmarks/benchmark_specificity.rb'
71
106
  end
72
107
 
73
108
  desc 'Benchmark CSS merging performance'
74
- task :merging do
109
+ task merging: :compile do
75
110
  puts 'Running merging benchmark...'
76
111
  ruby 'benchmarks/benchmark_merging.rb'
77
112
  end
78
113
 
79
- desc 'Benchmark Ruby-side operations with YJIT on vs off'
80
- task :yjit do
81
- puts 'Running YJIT benchmark...'
82
- ruby 'benchmarks/benchmark_yjit.rb'
83
- end
84
-
85
114
  desc 'Benchmark string allocation optimization (buffer vs dynamic)'
86
115
  task :string_allocation do
87
116
  # Clean up any existing benchmark results
@@ -189,10 +218,8 @@ begin
189
218
 
190
219
  desc 'Generate documentation and open in browser'
191
220
  task docs: :generate_example do
192
- # Generate YARD documentation
193
- YARD::CLI::Yardoc.run('--output-dir', 'docs', '--readme', 'README.md',
194
- '--title', 'Cataract - Fast CSS Parser',
195
- 'lib/**/*.rb', 'ext/**/*.c', '-', 'docs/files/EXAMPLE.md')
221
+ # Generate YARD documentation (uses .yardopts for configuration)
222
+ YARD::CLI::Yardoc.run
196
223
 
197
224
  # Open in browser (skip in CI)
198
225
  unless ENV['CI']
data/cataract.gemspec CHANGED
@@ -21,7 +21,10 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  # Specify which files should be added to the gem when it is released.
23
23
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
24
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) || f.match(/^test_.*\.rb$/) }
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ f.match(%r{^(test|spec|features|benchmarks|scripts)/}) ||
26
+ f.match(/^(test|benchmark)_.*\.rb$/)
27
+ end
25
28
  end
26
29
 
27
30
  spec.bindir = 'exe'
@@ -254,7 +254,7 @@ static void serialize_at_rule_formatted(VALUE result, VALUE at_rule, const char
254
254
  }
255
255
 
256
256
  // Helper to serialize a single rule with formatting (indented, multi-line)
257
- static void serialize_rule_formatted(VALUE result, VALUE rule, const char *indent) {
257
+ static void serialize_rule_formatted(VALUE result, VALUE rule, const char *indent, int is_last) {
258
258
  // Check if this is an AtRule
259
259
  if (rb_obj_is_kind_of(rule, cAtRule)) {
260
260
  serialize_at_rule_formatted(result, rule, indent);
@@ -277,9 +277,13 @@ static void serialize_rule_formatted(VALUE result, VALUE rule, const char *inden
277
277
  serialize_declarations_formatted(result, declarations, decl_indent_ptr);
278
278
  RB_GC_GUARD(decl_indent);
279
279
 
280
- // Closing brace
280
+ // Closing brace - double newline for all except last rule
281
281
  rb_str_cat2(result, indent);
282
- rb_str_cat2(result, "}\n");
282
+ if (is_last) {
283
+ rb_str_cat2(result, "}\n");
284
+ } else {
285
+ rb_str_cat2(result, "}\n\n");
286
+ }
283
287
  }
284
288
 
285
289
  // Context for building rule_to_media map
@@ -680,6 +684,7 @@ static VALUE stylesheet_to_formatted_s_original(VALUE rules_array, VALUE media_i
680
684
  VALUE rule = rb_ary_entry(rules_array, i);
681
685
  VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
682
686
  VALUE rule_media = rb_hash_aref(rule_to_media, rule_id);
687
+ int is_first_rule = (i == 0);
683
688
 
684
689
  if (NIL_P(rule_media)) {
685
690
  // Not in any media query - close any open media block first
@@ -689,19 +694,24 @@ static VALUE stylesheet_to_formatted_s_original(VALUE rules_array, VALUE media_i
689
694
  current_media = Qnil;
690
695
  }
691
696
 
692
- // Output rule with no indentation
693
- serialize_rule_formatted(result, rule, "");
697
+ // Add blank line prefix for non-first rules
698
+ if (!is_first_rule) {
699
+ rb_str_cat2(result, "\n");
700
+ }
701
+
702
+ // Output rule with no indentation (always single newline suffix)
703
+ serialize_rule_formatted(result, rule, "", 1);
694
704
  } else {
695
705
  // This rule is in a media query
696
706
  if (NIL_P(current_media) || !rb_equal(current_media, rule_media)) {
697
707
  // Close previous media block if open
698
708
  if (in_media_block) {
699
709
  rb_str_cat2(result, "}\n");
700
- } else {
701
- // Add blank line before @media if transitioning from non-media rules
702
- if (RSTRING_LEN(result) > 0) {
703
- rb_str_cat2(result, "\n");
704
- }
710
+ }
711
+
712
+ // Add blank line prefix for non-first rules
713
+ if (!is_first_rule) {
714
+ rb_str_cat2(result, "\n");
705
715
  }
706
716
 
707
717
  // Open new media block
@@ -713,7 +723,8 @@ static VALUE stylesheet_to_formatted_s_original(VALUE rules_array, VALUE media_i
713
723
  }
714
724
 
715
725
  // Serialize rule inside media block with 2-space indentation
716
- serialize_rule_formatted(result, rule, " ");
726
+ // Rules inside media blocks always get single newline (is_last=1)
727
+ serialize_rule_formatted(result, rule, " ", 1);
717
728
  }
718
729
  }
719
730
 
@@ -1013,42 +1024,25 @@ void Init_native_extension(void) {
1013
1024
  eSizeError = rb_define_class_under(mCataract, "SizeError", eCataractError);
1014
1025
  }
1015
1026
 
1016
- // Define Rule struct: (id, selector, declarations, specificity, parent_rule_id, nesting_style)
1017
- cRule = rb_struct_define_under(
1018
- mCataract,
1019
- "Rule",
1020
- "id", // Integer (0-indexed position in @rules array)
1021
- "selector", // String (fully resolved/flattened selector)
1022
- "declarations", // Array of Declaration
1023
- "specificity", // Integer (nil = not calculated yet)
1024
- "parent_rule_id", // Integer | nil (parent rule ID for nested rules)
1025
- "nesting_style", // Integer | nil (0=implicit, 1=explicit, nil=not nested)
1026
- NULL
1027
- );
1028
-
1029
- // Define Declaration struct: (property, value, important)
1030
- cDeclaration = rb_struct_define_under(
1031
- mCataract,
1032
- "Declaration",
1033
- "property", // String
1034
- "value", // String
1035
- "important", // Boolean
1036
- NULL
1037
- );
1038
-
1039
- // Define AtRule struct: (id, selector, content, specificity)
1040
- // Matches Rule interface for duck-typing
1041
- // - For @keyframes: content is Array of Rule (keyframe blocks)
1042
- // - For @font-face: content is Array of Declaration
1043
- cAtRule = rb_struct_define_under(
1044
- mCataract,
1045
- "AtRule",
1046
- "id", // Integer (0-indexed position in @rules array)
1047
- "selector", // String (e.g., "@keyframes fade", "@font-face")
1048
- "content", // Array of Rule or Declaration
1049
- "specificity", // Always nil for at-rules
1050
- NULL
1051
- );
1027
+ // Reuse Ruby-defined structs (they must be defined before loading this extension)
1028
+ // If they don't exist, someone required the extension directly instead of via lib/cataract.rb
1029
+ if (rb_const_defined(mCataract, rb_intern("Rule"))) {
1030
+ cRule = rb_const_get(mCataract, rb_intern("Rule"));
1031
+ } else {
1032
+ rb_raise(rb_eLoadError, "Cataract::Rule not defined. Do not require 'cataract/native_extension' directly, use require 'cataract'");
1033
+ }
1034
+
1035
+ if (rb_const_defined(mCataract, rb_intern("Declaration"))) {
1036
+ cDeclaration = rb_const_get(mCataract, rb_intern("Declaration"));
1037
+ } else {
1038
+ rb_raise(rb_eLoadError, "Cataract::Declaration not defined. Do not require 'cataract/native_extension' directly, use require 'cataract'");
1039
+ }
1040
+
1041
+ if (rb_const_defined(mCataract, rb_intern("AtRule"))) {
1042
+ cAtRule = rb_const_get(mCataract, rb_intern("AtRule"));
1043
+ } else {
1044
+ rb_raise(rb_eLoadError, "Cataract::AtRule not defined. Do not require 'cataract/native_extension' directly, use require 'cataract'");
1045
+ }
1052
1046
 
1053
1047
  // Define Declarations class and add to_s method
1054
1048
  VALUE cDeclarations = rb_define_class_under(mCataract, "Declarations", rb_cObject);
@@ -1086,4 +1080,10 @@ void Init_native_extension(void) {
1086
1080
  #endif
1087
1081
 
1088
1082
  rb_define_const(mCataract, "COMPILE_FLAGS", compile_flags);
1083
+
1084
+ // Flag to indicate native extension is loaded (for pure Ruby fallback detection)
1085
+ rb_define_const(mCataract, "NATIVE_EXTENSION_LOADED", Qtrue);
1086
+
1087
+ // Implementation type constant
1088
+ rb_define_const(mCataract, "IMPLEMENTATION", ID2SYM(rb_intern("native")));
1089
1089
  }
@@ -361,10 +361,9 @@ static void update_media_index(ParserContext *ctx, VALUE media_sym, int rule_id)
361
361
  return; // No media query - rule applies to all media
362
362
  }
363
363
 
364
- // Add to full query symbol
365
- add_to_media_index(ctx->media_index, media_sym, rule_id);
366
-
367
- // Extract media types and add to each (if different from full query)
364
+ // Extract media types and add to each first (if different from full query)
365
+ // We add these BEFORE the full query so that when iterating the media_index hash,
366
+ // the full query comes last and takes precedence during serialization
368
367
  VALUE media_str = rb_sym2str(media_sym);
369
368
  const char *query = RSTRING_PTR(media_str);
370
369
  long query_len = RSTRING_LEN(media_str);
@@ -380,6 +379,9 @@ static void update_media_index(ParserContext *ctx, VALUE media_sym, int rule_id)
380
379
  }
381
380
  }
382
381
 
382
+ // Add to full query symbol (after media types for insertion order)
383
+ add_to_media_index(ctx->media_index, media_sym, rule_id);
384
+
383
385
  // Guard media_str since we extracted C pointer and called extract_media_types (which allocates)
384
386
  RB_GC_GUARD(media_str);
385
387
  }
@@ -412,8 +414,14 @@ static VALUE parse_declarations(const char *start, const char *end) {
412
414
  // Example: "color: red; ..."
413
415
  // ^pos ^pos (at :)
414
416
  const char *prop_start = pos;
415
- while (pos < end && *pos != ':') pos++;
416
- if (pos >= end) break; // No colon found
417
+ while (pos < end && *pos != ':' && *pos != ';') pos++;
418
+
419
+ // Malformed declaration - skip to next semicolon to recover
420
+ if (pos >= end || *pos != ':') {
421
+ while (pos < end && *pos != ';') pos++;
422
+ if (pos < end) pos++; // Skip the semicolon
423
+ continue;
424
+ }
417
425
 
418
426
  const char *prop_end = pos;
419
427
  // Trim whitespace from property
@@ -564,7 +572,7 @@ static VALUE combine_media_queries(VALUE parent, VALUE child) {
564
572
 
565
573
  /*
566
574
  * Intern media query string to symbol with safety check
567
- * Strips outer parentheses from standalone conditions like "(orientation: landscape)"
575
+ * Keeps media query exactly as written - parentheses are required per CSS spec
568
576
  */
569
577
  static VALUE intern_media_query_safe(ParserContext *ctx, const char *query_str, long query_len) {
570
578
  if (query_len == 0) {
@@ -578,38 +586,14 @@ static VALUE intern_media_query_safe(ParserContext *ctx, const char *query_str,
578
586
  MAX_MEDIA_QUERIES);
579
587
  }
580
588
 
581
- // Strip outer parentheses from standalone conditions
582
- // Example: "(orientation: landscape)" => "orientation: landscape"
583
- // But keep: "screen and (min-width: 500px)" as-is
589
+ // Keep media query exactly as written - parentheses are required per CSS spec
584
590
  const char *start = query_str;
585
591
  const char *end = query_str + query_len;
586
592
 
587
- // Trim whitespace
593
+ // Trim whitespace only
588
594
  while (start < end && IS_WHITESPACE(*start)) start++;
589
595
  while (end > start && IS_WHITESPACE(*(end - 1))) end--;
590
596
 
591
- if (end > start && *start == '(' && *(end - 1) == ')') {
592
- // Check if this is a simple wrapped condition (no other parens/operators)
593
- int depth = 0;
594
- int has_and_or = 0;
595
- for (const char *p = start; p < end; p++) {
596
- if (*p == '(') depth++;
597
- else if (*p == ')') depth--;
598
- // Check for "and" or "or" at depth 0 (outside our outer parens)
599
- if (depth == 0 && p + 3 < end &&
600
- (strncmp(p, " and ", 5) == 0 || strncmp(p, " or ", 4) == 0)) {
601
- has_and_or = 1;
602
- break;
603
- }
604
- }
605
-
606
- // Strip outer parens if depth stays >= 1 (no operators outside) and no and/or
607
- if (!has_and_or && depth == 0) {
608
- start++; // Skip opening (
609
- end--; // Skip closing )
610
- }
611
- }
612
-
613
597
  long final_len = end - start;
614
598
  VALUE query_string = rb_usascii_str_new(start, final_len);
615
599
  VALUE sym = ID2SYM(rb_intern_str(query_string));
data/ext/cataract/merge.c CHANGED
@@ -943,6 +943,12 @@ static VALUE merge_rules_for_selector(VALUE rules_array, VALUE rule_indices, VAL
943
943
  }
944
944
 
945
945
  // Build declarations array from properties_hash
946
+ // NOTE: We don't sort by source_order here because:
947
+ // 1. Hash iteration order reflects insertion order
948
+ // 2. Declaration order doesn't affect CSS behavior (cascade is already resolved)
949
+ // 3. Sorting would add overhead for purely aesthetic output
950
+ // The output order is roughly source order but may vary when properties are
951
+ // overridden by later rules with higher specificity or importance.
946
952
  VALUE merged_decls = rb_ary_new();
947
953
  rb_hash_foreach(properties_hash, merge_build_result_callback, merged_decls);
948
954
 
@@ -1,10 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cataract
4
- # Represents a CSS at-rule like @keyframes, @font-face, @supports, etc.
5
- #
6
- # AtRule is a C struct defined as: `Struct.new(:id, :selector, :content, :specificity)`
7
- #
8
4
  # At-rules define CSS resources or control structures rather than selecting elements.
9
5
  # Unlike regular rules, they don't have CSS specificity and are filtered out when
10
6
  # using `select(&:selector?)`.
@@ -32,6 +28,8 @@ module Cataract
32
28
  # @attr [String] selector The at-rule identifier (e.g., "@keyframes fade", "@font-face")
33
29
  # @attr [Array<Rule>, Array<Declaration>] content Nested rules or declarations
34
30
  # @attr [nil] specificity Always nil for at-rules (they don't have CSS specificity)
31
+ AtRule = Struct.new(:id, :selector, :content, :specificity) unless const_defined?(:AtRule)
32
+
35
33
  class AtRule
36
34
  # Check if this is a selector-based rule (vs an at-rule like @keyframes).
37
35
  #
@@ -79,17 +77,18 @@ module Cataract
79
77
  false
80
78
  end
81
79
 
82
- # Compare at-rules by their attributes rather than object identity.
80
+ # Compare at-rules for logical equality based on CSS semantics.
83
81
  #
84
- # Two at-rules are equal if they have the same id, selector, and content.
82
+ # Two at-rules are equal if they have the same selector and content.
83
+ # Internal implementation details (id) are not considered since they
84
+ # don't affect the CSS semantics.
85
85
  #
86
86
  # @param other [Object] Object to compare with
87
- # @return [Boolean] true if at-rules have same attributes
87
+ # @return [Boolean] true if at-rules have same selector and content
88
88
  def ==(other)
89
89
  return false unless other.is_a?(AtRule)
90
90
 
91
- id == other.id &&
92
- selector == other.selector &&
91
+ selector == other.selector &&
93
92
  content == other.content
94
93
  end
95
94
  alias eql? ==
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cataract
4
+ # Represents a CSS property declaration.
5
+ #
6
+ # Declaration is a Struct with fields: (property, value, important)
7
+ #
8
+ # @example Create a declaration
9
+ # decl = Cataract::Declaration.new('color', 'red', false)
10
+ # decl.property #=> "color"
11
+ # decl.value #=> "red"
12
+ # decl.important #=> false
13
+ #
14
+ # @attr [String] property CSS property name (lowercased)
15
+ # @attr [String] value CSS property value
16
+ # @attr [Boolean] important Whether the declaration has !important
17
+ Declaration = Struct.new(:property, :value, :important) unless const_defined?(:Declaration)
18
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'uri'
4
4
  require 'open-uri'
5
- require 'set'
6
5
 
7
6
  module Cataract
8
7
  # Error raised during import resolution
@@ -26,9 +25,9 @@ module Cataract
26
25
  # @param css [String] CSS content with @import statements
27
26
  # @param options [Hash] Import resolution options
28
27
  # @param depth [Integer] Current recursion depth (internal)
29
- # @param imported_urls [Set] Set of already imported URLs to prevent circular references
28
+ # @param imported_urls [Array] Array of already imported URLs to prevent circular references
30
29
  # @return [String] CSS with imports inlined
31
- def self.resolve(css, options = {}, depth: 0, imported_urls: Set.new)
30
+ def self.resolve(css, options = {}, depth: 0, imported_urls: [])
32
31
  # Normalize options
33
32
  opts = normalize_options(options)
34
33
 
@@ -66,7 +65,7 @@ module Cataract
66
65
 
67
66
  # Recursively resolve imports in the imported CSS
68
67
  imported_urls_copy = imported_urls.dup
69
- imported_urls_copy.add(url)
68
+ imported_urls_copy << url
70
69
  imported_css = resolve(imported_css, opts, depth: depth + 1, imported_urls: imported_urls_copy)
71
70
 
72
71
  # Wrap in @media if import had media query
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pure Ruby CSS parser - Byte constants for fast parsing
4
+ # Using getbyte() instead of String#[] to avoid allocating millions of string objects
5
+
6
+ module Cataract
7
+ # Whitespace bytes
8
+ BYTE_SPACE = 32 # ' '
9
+ BYTE_TAB = 9 # '\t'
10
+ BYTE_NEWLINE = 10 # '\n'
11
+ BYTE_CR = 13 # '\r'
12
+
13
+ # CSS structural characters
14
+ BYTE_AT = 64 # '@'
15
+ BYTE_LBRACE = 123 # '{'
16
+ BYTE_RBRACE = 125 # '}'
17
+ BYTE_LPAREN = 40 # '('
18
+ BYTE_RPAREN = 41 # ')'
19
+ BYTE_LBRACKET = 91 # '['
20
+ BYTE_RBRACKET = 93 # ']'
21
+ BYTE_SEMICOLON = 59 # ';'
22
+ BYTE_COLON = 58 # ':'
23
+ BYTE_COMMA = 44 # ','
24
+
25
+ # Comment characters
26
+ BYTE_SLASH = 47 # '/'
27
+ BYTE_STAR = 42 # '*'
28
+
29
+ # Quote characters
30
+ BYTE_SQUOTE = 39 # "'"
31
+ BYTE_DQUOTE = 34 # '"'
32
+
33
+ # Selector characters
34
+ BYTE_HASH = 35 # '#'
35
+ BYTE_DOT = 46 # '.'
36
+ BYTE_GT = 62 # '>'
37
+ BYTE_PLUS = 43 # '+'
38
+ BYTE_TILDE = 126 # '~'
39
+ BYTE_ASTERISK = 42 # '*'
40
+ BYTE_AMPERSAND = 38 # '&'
41
+
42
+ # Other
43
+ BYTE_HYPHEN = 45 # '-'
44
+ BYTE_UNDERSCORE = 95 # '_'
45
+ BYTE_BACKSLASH = 92 # '\\'
46
+ BYTE_BANG = 33 # '!'
47
+ BYTE_PERCENT = 37 # '%'
48
+ BYTE_SLASH_FWD = 47 # '/' (also defined as BYTE_SLASH above)
49
+
50
+ # Specific lowercase letters (for keyword matching)
51
+ BYTE_LOWER_U = 117 # 'u'
52
+ BYTE_LOWER_R = 114 # 'r'
53
+ BYTE_LOWER_L = 108 # 'l'
54
+
55
+ # Letter ranges (a-z, A-Z)
56
+ BYTE_LOWER_A = 97 # 'a'
57
+ BYTE_LOWER_Z = 122 # 'z'
58
+ BYTE_UPPER_A = 65 # 'A'
59
+ BYTE_UPPER_Z = 90 # 'Z'
60
+ BYTE_CASE_DIFF = 32 # Difference between lowercase and uppercase ('a' - 'A')
61
+
62
+ # Digit range (0-9)
63
+ BYTE_DIGIT_0 = 48 # '0'
64
+ BYTE_DIGIT_9 = 57 # '9'
65
+
66
+ # Nesting styles (for CSS nesting support)
67
+ NESTING_STYLE_IMPLICIT = 0 # Implicit nesting: .parent { .child { ... } } => .parent .child
68
+ NESTING_STYLE_EXPLICIT = 1 # Explicit nesting: .parent { &:hover { ... } } => .parent:hover
69
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pure Ruby CSS parser - Helper methods
4
+ # NO REGEXP ALLOWED - char-by-char parsing only
5
+
6
+ module Cataract
7
+ # Check if a byte is whitespace (space, tab, newline, CR)
8
+ # @param byte [Integer] Byte value from String#getbyte
9
+ # @return [Boolean] true if whitespace
10
+ def self.is_whitespace?(byte)
11
+ byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
12
+ end
13
+
14
+ # Check if byte is a letter (a-z, A-Z)
15
+ # @param byte [Integer] Byte value from String#getbyte
16
+ # @return [Boolean] true if letter
17
+ def self.letter?(byte)
18
+ (byte >= BYTE_LOWER_A && byte <= BYTE_LOWER_Z) ||
19
+ (byte >= BYTE_UPPER_A && byte <= BYTE_UPPER_Z)
20
+ end
21
+
22
+ # Check if byte is a digit (0-9)
23
+ # @param byte [Integer] Byte value from String#getbyte
24
+ # @return [Boolean] true if digit
25
+ def self.digit?(byte)
26
+ byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
27
+ end
28
+
29
+ # Check if byte is alphanumeric, hyphen, or underscore (CSS identifier char)
30
+ # @param byte [Integer] Byte value from String#getbyte
31
+ # @return [Boolean] true if valid identifier character
32
+ def self.ident_char?(byte)
33
+ letter?(byte) || digit?(byte) || byte == BYTE_HYPHEN || byte == BYTE_UNDERSCORE
34
+ end
35
+ end