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.
- checksums.yaml +4 -4
- data/.github/workflows/ci-manual-rubies.yml +27 -0
- data/.overcommit.yml +1 -1
- data/.rubocop.yml +62 -0
- data/.rubocop_todo.yml +186 -0
- data/BENCHMARKS.md +60 -139
- data/CHANGELOG.md +10 -0
- data/README.md +30 -2
- data/Rakefile +49 -22
- data/cataract.gemspec +4 -1
- data/ext/cataract/cataract.c +47 -47
- data/ext/cataract/css_parser.c +17 -33
- data/ext/cataract/merge.c +6 -0
- data/lib/cataract/at_rule.rb +8 -9
- data/lib/cataract/declaration.rb +18 -0
- data/lib/cataract/import_resolver.rb +3 -4
- data/lib/cataract/pure/byte_constants.rb +69 -0
- data/lib/cataract/pure/helpers.rb +35 -0
- data/lib/cataract/pure/imports.rb +255 -0
- data/lib/cataract/pure/merge.rb +1146 -0
- data/lib/cataract/pure/parser.rb +1236 -0
- data/lib/cataract/pure/serializer.rb +590 -0
- data/lib/cataract/pure/specificity.rb +206 -0
- data/lib/cataract/pure.rb +130 -0
- data/lib/cataract/rule.rb +22 -13
- data/lib/cataract/stylesheet.rb +14 -9
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +18 -5
- metadata +12 -25
- data/benchmarks/benchmark_harness.rb +0 -193
- data/benchmarks/benchmark_merging.rb +0 -121
- data/benchmarks/benchmark_optimization_comparison.rb +0 -168
- data/benchmarks/benchmark_parsing.rb +0 -153
- data/benchmarks/benchmark_ragel_removal.rb +0 -56
- data/benchmarks/benchmark_runner.rb +0 -70
- data/benchmarks/benchmark_serialization.rb +0 -180
- data/benchmarks/benchmark_shorthand.rb +0 -109
- data/benchmarks/benchmark_shorthand_expansion.rb +0 -176
- data/benchmarks/benchmark_specificity.rb +0 -124
- data/benchmarks/benchmark_string_allocation.rb +0 -151
- data/benchmarks/benchmark_stylesheet_to_s.rb +0 -62
- data/benchmarks/benchmark_to_s_cached.rb +0 -55
- data/benchmarks/benchmark_value_splitter.rb +0 -54
- data/benchmarks/benchmark_yjit.rb +0 -158
- data/benchmarks/benchmark_yjit_workers.rb +0 -61
- data/benchmarks/profile_to_s.rb +0 -23
- data/benchmarks/speedup_calculator.rb +0 -83
- data/benchmarks/system_metadata.rb +0 -81
- data/benchmarks/templates/benchmarks.md.erb +0 -221
- data/benchmarks/yjit_tests.rb +0 -141
- data/scripts/fuzzer/run.rb +0 -828
- data/scripts/fuzzer/worker.rb +0 -99
- 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 :
|
|
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 :
|
|
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 :
|
|
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 :
|
|
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
|
|
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
|
|
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'
|
data/ext/cataract/cataract.c
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
693
|
-
|
|
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
|
-
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
"Rule"
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
mCataract,
|
|
1032
|
-
|
|
1033
|
-
"
|
|
1034
|
-
|
|
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
|
}
|
data/ext/cataract/css_parser.c
CHANGED
|
@@ -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
|
-
//
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
data/lib/cataract/at_rule.rb
CHANGED
|
@@ -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
|
|
80
|
+
# Compare at-rules for logical equality based on CSS semantics.
|
|
83
81
|
#
|
|
84
|
-
# Two at-rules are equal if they have the same
|
|
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
|
|
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
|
-
|
|
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 [
|
|
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:
|
|
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
|
|
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
|