cataract 0.2.4 → 0.3.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +6 -6
  3. data/.gitignore +17 -3
  4. data/.rubocop.yml +1 -0
  5. data/BENCHMARKS.md +24 -6
  6. data/CHANGELOG.md +29 -1
  7. data/Gemfile +2 -2
  8. data/examples/css_analyzer/analyzer.rb +9 -3
  9. data/examples/css_analyzer/analyzers/base.rb +1 -1
  10. data/ext/cataract/cataract.c +251 -458
  11. data/ext/cataract/cataract.h +127 -16
  12. data/ext/cataract/css_parser.c +1029 -700
  13. data/ext/cataract/extconf.rb +1 -2
  14. data/ext/cataract/flatten.c +279 -991
  15. data/ext/cataract/shorthand_expander.c +80 -102
  16. data/ext/cataract_color/color_conversion.c +1 -1
  17. data/lib/cataract/declarations.rb +13 -7
  18. data/lib/cataract/error.rb +49 -0
  19. data/lib/cataract/import_resolver.rb +36 -16
  20. data/lib/cataract/pure/byte_constants.rb +4 -1
  21. data/lib/cataract/pure/flatten.rb +143 -145
  22. data/lib/cataract/pure/parser.rb +623 -388
  23. data/lib/cataract/pure/serializer.rb +109 -104
  24. data/lib/cataract/pure/specificity.rb +112 -156
  25. data/lib/cataract/pure.rb +1 -6
  26. data/lib/cataract/stylesheet.rb +480 -410
  27. data/lib/cataract/version.rb +1 -1
  28. data/lib/cataract.rb +1 -0
  29. metadata +3 -13
  30. data/ext/cataract/import_scanner.c +0 -174
  31. data/ext/cataract_old/cataract.c +0 -393
  32. data/ext/cataract_old/cataract.h +0 -250
  33. data/ext/cataract_old/css_parser.c +0 -933
  34. data/ext/cataract_old/extconf.rb +0 -67
  35. data/ext/cataract_old/import_scanner.c +0 -174
  36. data/ext/cataract_old/merge.c +0 -776
  37. data/ext/cataract_old/shorthand_expander.c +0 -902
  38. data/ext/cataract_old/specificity.c +0 -213
  39. data/ext/cataract_old/stylesheet.c +0 -290
  40. data/ext/cataract_old/value_splitter.c +0 -116
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90e5adcec05553f73e5829b769d010ecda9ca5bf5eb1b2cc621b74707758c913
4
- data.tar.gz: 4a82a843a72f2cc3c944798af3b7e7a8271b5ad696c1ed53247854d5ecdda7bb
3
+ metadata.gz: 6a6424d26e77e0dd4e0167324dc6190f03a9ca041e51fa9e61a78163f6fe312b
4
+ data.tar.gz: 7c538f41ebd8e93a53caed4a5f066eea076277c26174bff7d22837730f03d90d
5
5
  SHA512:
6
- metadata.gz: afb9718d653c7bc0c905eb3d273afec8a364ae37de2952ea3285ecf3ef7f5ff29b40887348ef3646adedfab0baeff873211a883750e7c9b992f3e6a25b14cbb0
7
- data.tar.gz: c40ba4cefa95024f34c7d9306d93ad07b8f448e847c51231381fa83833f2b97e314e5fbab9d7fb2a8edd1fe45fea0760c6db514c79225829e397c339fd5951a6
6
+ metadata.gz: d2297f4139e8025ac39b9467eaa4bd0541683af7e4870813159c557df813f411d389b59f5b28adb9c46f5390aba73405b937e946d9097b943e0ba70f6cb37963
7
+ data.tar.gz: 0b4d9b6ec564fd674ad8558b7b4f309494c0ca12628947a8501b17ccb3567e1a2667dd0ea89f88ad82f688ac580122ec7e9505b8149433504ef40d923228b75b
@@ -18,7 +18,7 @@ jobs:
18
18
  strategy:
19
19
  fail-fast: false
20
20
  matrix:
21
- ruby: ['3.1', '3.2', '3.3', '3.4']
21
+ ruby: ['3.1', '3.2', '3.3', '3.4', '4.0']
22
22
 
23
23
  steps:
24
24
  - uses: actions/checkout@v4
@@ -37,8 +37,8 @@ jobs:
37
37
  env:
38
38
  COVERAGE: 1
39
39
 
40
- - name: Run debug test (Ruby 3.4 only)
41
- if: matrix.ruby == '3.4'
40
+ - name: Run debug test (Ruby 4.0 only)
41
+ if: matrix.ruby == '4.0'
42
42
  run: |
43
43
  bundle exec rake clean compile
44
44
  bundle exec ruby -Ilib:test test/test_stylesheet.rb
@@ -46,7 +46,7 @@ jobs:
46
46
  CONFIGURE_ARGS: --enable-debug
47
47
 
48
48
  - name: Upload coverage to Codecov
49
- if: runner.os == 'Linux' && matrix.ruby == '3.4'
49
+ if: runner.os == 'Linux' && matrix.ruby == '4.0'
50
50
  uses: codecov/codecov-action@v5
51
51
  with:
52
52
  fail_ci_if_error: false
@@ -54,7 +54,7 @@ jobs:
54
54
  verbose: true
55
55
 
56
56
  - name: Upload coverage report artifact
57
- if: runner.os == 'Linux' && matrix.ruby == '3.4'
57
+ if: runner.os == 'Linux' && matrix.ruby == '4.0'
58
58
  uses: actions/upload-artifact@v4
59
59
  with:
60
60
  name: coverage-report
@@ -69,7 +69,7 @@ jobs:
69
69
  - name: Set up Ruby
70
70
  uses: ruby/setup-ruby@v1
71
71
  with:
72
- ruby-version: '3.4'
72
+ ruby-version: '4.0'
73
73
  bundler-cache: true
74
74
 
75
75
  - name: Run rubocop
data/.gitignore CHANGED
@@ -10,6 +10,8 @@ ext/**/cataract.c
10
10
  # Compiled extensions
11
11
  lib/**/*.so
12
12
  lib/**/*.bundle
13
+ ext/**/*.bundle
14
+ ext/**/*.dSYM/
13
15
 
14
16
  # Makefile generated by extconf.rb
15
17
  ext/**/Makefile
@@ -29,19 +31,31 @@ Thumbs.db
29
31
  vendor/
30
32
  .bundle/
31
33
 
34
+ # Node (this is a Ruby gem; no JS toolchain is tracked, but guard against stray npm installs)
35
+ node_modules/
36
+
32
37
  # Test artifacts
33
38
  coverage/
34
39
  test/.benchmark_results/
35
- test/fuzz_last_input.css
36
- test/fuzz_crash_*.css
37
- test/fuzz_crash_*.log
40
+
41
+ # Fuzzer output (crash inputs, logs, recent-input dumps)
42
+ scripts/fuzzer/output/
38
43
 
39
44
  # Benchmark results
40
45
  benchmarks/.results/
41
46
  benchmarks/.benchmark_results/
42
47
 
48
+ # CSS analyzer example debug output
49
+ examples/css_analyzer/output/
50
+
43
51
  # Documentation (generated by YARD)
44
52
  docs/
45
53
  !docs/files/
46
54
  !docs/files/EXAMPLE.md
47
55
  .yardoc/
56
+
57
+ # Claude Code local project instructions (personal, not shared)
58
+ .claude/CLAUDE.md
59
+
60
+ .cache/
61
+ .cocci/
data/.rubocop.yml CHANGED
@@ -178,4 +178,5 @@ Cataract/BanAssertIncludes:
178
178
  - 'test/color/**/*.rb'
179
179
  - 'test/test_benchmark_doc_generator.rb'
180
180
  - 'test/test_speedup_calculator.rb'
181
+ - 'test/test_parse_errors.rb' # Parse error tests check error messages with assert_match
181
182
  - 'test/support/**/*' # Support files define assert_contains which uses assert_includes internally
data/BENCHMARKS.md CHANGED
@@ -20,17 +20,35 @@ Time to parse CSS into internal data structures
20
20
 
21
21
  | Test Case | Native | Pure (no YJIT) | Pure (YJIT) |
22
22
  |-----------|--------|----------------|-------------|
23
- | Small CSS (64 lines, 1.0KB) | 37.4K i/s | 3.25K i/s | 13.43K i/s |
24
- | Medium CSS with @media (139 lines, 1.6KB) | 34.71K i/s | 2.04K i/s | 9.15K i/s |
25
- | Selector lists (3500 lines, 62.5KB, 500 lists) | 471.8 i/s | 56.7 i/s | 214.1 i/s |
23
+ | Small CSS (64 lines, 1.0KB) | 37.27K i/s | 3.32K i/s | 13.48K i/s |
24
+ | Medium CSS with @media (139 lines, 1.6KB) | 34.66K i/s | 2.06K i/s | 8.94K i/s |
25
+ | Selector lists (3500 lines, 62.5KB, 500 lists) | 485.7 i/s | 55.7 i/s | 213.6 i/s |
26
26
 
27
27
  ### Speedups
28
28
 
29
29
  | Comparison | Speedup |
30
30
  |------------|---------|
31
- | Native vs Pure (no YJIT) | 12.76x faster (avg) |
32
- | Native vs Pure (YJIT) | 2.99x faster (avg) |
33
- | YJIT impact on Pure Ruby | 4.22x faster (avg) |
31
+ | Native vs Pure (no YJIT) | 14.4x faster (avg) |
32
+ | Native vs Pure (YJIT) | 3.38x faster (avg) |
33
+ | YJIT impact on Pure Ruby | 4.23x faster (avg) |
34
+
35
+ ### Parse Error Checking Overhead
36
+
37
+ Parse error detection can be enabled with `raise_parse_errors: true`. This compares performance impact:
38
+
39
+ | Configuration | Native | Pure (no YJIT) | Pure (YJIT) |
40
+ |---------------|--------|----------------|-------------|
41
+ | Medium CSS (139 lines) - no error checking | 35.18K i/s | 2.07K i/s | 9.05K i/s |
42
+ | Medium CSS (139 lines) - with error checking | 34.84K i/s | 1.9K i/s | 8.19K i/s |
43
+
44
+ **Overhead Analysis:**
45
+
46
+
47
+ | Implementation | Overhead |
48
+ |----------------|----------|
49
+ | Native | ~0% (within noise) |
50
+ | Pure (no YJIT) | 8.9% slower |
51
+ | Pure (YJIT) | 10.4% slower |
34
52
 
35
53
  ---
36
54
 
data/CHANGELOG.md CHANGED
@@ -1,4 +1,32 @@
1
- ## [ Unreleased ]
1
+ ## [0.3.0] - 2026-07-06
2
+
3
+ - Security/Breaking: `Stylesheet#load_uri` and `#load_file` now reject `file://` paths under `/etc/`, `/proc/`, `/sys/`, and `/dev/` by default (override with `dangerous_path_prefixes: []`). Previously these methods used a separate, unvalidated fetch implementation; they now reuse `ImportResolver` (the same validation and fetching `@import` resolution already uses), which also fixes `load_uri` not following HTTP redirects.
4
+ - Fix: rules nested 3+ levels deep, or nested inside a top-level `@media`/`@supports`/`@container`/`@scope` block, could serialize incorrectly (silently flattened, or printed as an unrelated top-level rule) in the pure Ruby backend.
5
+ - Fix: a bare type selector nested without `&` (invalid CSS nesting) no longer corrupts serialized output with unbalanced braces, in both the C and pure Ruby parsers.
6
+ - Fix: `@keyframes` parsing no longer pollutes the outer rule list with its own inner selectors.
7
+ - Fix: at-rules (`@keyframes`, `@font-face`, etc.) nested inside `@media` blocks now correctly keep their media context across parsing, merging (`add_block`, `concat`/`+`), `@import` resolution, and serialization - previously this could be lost, misattributed, or (combined with CSS nesting) raise `NoMethodError` in the pure Ruby serializer.
8
+ - Fix: `remove_rules!` and the `-` operator no longer desync media query ids from the rules that reference them.
9
+ - Fix: resolving `@import`s no longer leaves duplicate/colliding rule ids.
10
+ - Fix: a plain `@import "file.css";` (no media qualifier) no longer loses that file's own `@media` rules.
11
+ - Fix: `Stylesheet#concat`/`+` now merges the other stylesheet's media queries and selector-list groupings too - previously this silently dropped `h1, h2`-style grouping and corrupted media attribution.
12
+ - Fix: `@import` media queries with a type keyword (e.g. `"screen and (min-width: 500px)"`) no longer keep a stray `"and"` in the parsed conditions.
13
+ - Fix: `to_s`/`to_formatted_s` media filtering is now consistent between the two - both include base (non-media) rules when filtering to a specific media type; previously `to_s` excluded them while `to_formatted_s` included them.
14
+ - Fix: values containing `url(...;...)` no longer lose everything after the first semicolon when the rule also uses CSS nesting.
15
+ - Fix: `!important` now parses correctly with whitespace between `!` and `important`, per the CSS grammar.
16
+ - Fix: expanding `border`, `border-{top,right,bottom,left}`, `font`, `background`, and `list-style` shorthands (used when comparing rules for equality) no longer silently drops `!important`.
17
+ - Fix: `Declarations`/`Stylesheet` `#dup`/`#clone` now produce fully independent copies; previously mutating a copy could mutate the original.
18
+ - Performance: CSS selector specificity calculation is ~30% faster (no longer re-allocates an internal lookup array on every call).
19
+ - Performance: native (C extension) CSS flattening is ~1-5% faster (fewer string allocations when recreating shorthand properties).
20
+
21
+ ## [0.2.5 - 2025-11-25]
22
+
23
+ - Feature: Parse error detection with `raise_parse_errors` option - validates CSS structure and raises `ParseError` exceptions for malformed input with line/column tracking
24
+ - Feature: Granular error control - enable specific checks (empty values, malformed declarations, invalid selectors, invalid selector syntax, malformed at-rules, unclosed blocks)
25
+ - Feature: Type safety validation for C extension - `Stylesheet.parse` and `Stylesheet.new` now validate argument types and raise clear `TypeError` instead of segfaulting
26
+ - Feature: Selector syntax validation using whitelist approach - catches invalid characters and sequences like `..class`, `##id`, `???`
27
+ - Fix: `add_block` with multiple `@import` statements now correctly tracks media type for each import instead of reusing the first import's media context
28
+ - Performance: Parse error checking adds minimal overhead (effectively zero for C/Pure Ruby, ~5% for Pure Ruby with YJIT)
29
+ - Testing: Fuzzer corpus enhanced with invalid CSS patterns for crash testing
2
30
 
3
31
  ## [0.2.4 - 2025-11-23]
4
32
  - MediaQuery first-class objects: Refactored media queries from simple symbols to proper structs with id, type, and conditions, enabling accurate
data/Gemfile CHANGED
@@ -16,10 +16,10 @@ gem 'css_parser', '~> 1.0' # for benchmarking against
16
16
  gem 'minitest'
17
17
  gem 'minitest-spec'
18
18
  gem 'nokogiri' # for docs
19
- gem 'ruby-prof', require: false # for profiling
19
+ gem 'ruby-prof', require: false, platforms: :ruby # for profiling (MRI only)
20
20
  gem 'simplecov', require: false
21
21
  gem 'simplecov-cobertura', require: false
22
- gem 'stackprof', require: false # for profiling
22
+ gem 'stackprof', require: false, platforms: :ruby # for profiling (MRI only)
23
23
  gem 'webmock', '~> 3.0' # for testing URL loading
24
24
 
25
25
  gem 'overcommit', '~> 0.64', group: :development
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'erb'
4
+ require 'fileutils'
4
5
  require 'uri'
5
6
  require_relative '../../lib/cataract'
6
7
  require_relative 'analyzers/properties'
@@ -71,13 +72,18 @@ module CSSAnalyzer
71
72
  def save_parsed_css
72
73
  # Generate a unique filename based on source
73
74
  source_slug = @source.gsub(%r{[:/]}, '_').gsub(/[^a-zA-Z0-9_.-]/, '')
74
- filename = "parsed-css-#{source_slug}.css"
75
+
76
+ # Write next to this script (not the caller's cwd) so running the
77
+ # analyzer from repo root doesn't scatter debug output there.
78
+ output_dir = File.join(__dir__, 'output')
79
+ FileUtils.mkdir_p(output_dir)
80
+ path = File.join(output_dir, "parsed-css-#{source_slug}.css")
75
81
 
76
82
  # Serialize stylesheet to CSS
77
83
  css_output = @stylesheet.to_s
78
84
 
79
- File.write(filename, css_output)
80
- warn "Parsed CSS saved to #{filename} (#{@stylesheet.size} rules, #{css_output.length} bytes)"
85
+ File.write(path, css_output)
86
+ warn "Parsed CSS saved to #{path} (#{@stylesheet.size} rules, #{css_output.length} bytes)"
81
87
  end
82
88
 
83
89
  private
@@ -26,7 +26,7 @@ module CSSAnalyzer
26
26
  # @param rule [Cataract::Rule] The rule to check
27
27
  # @return [Array<Symbol>] Array of media query symbols this rule appears in
28
28
  def media_queries_for_rule(rule)
29
- stylesheet.instance_variable_get(:@_media_index).select { |_media, ids| ids.include?(rule.id) }.keys
29
+ stylesheet.media_index.select { |_media, ids| ids.include?(rule.id) }.keys
30
30
  end
31
31
  end
32
32
  end