cataract 0.4.0 → 0.5.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.
@@ -26,9 +26,11 @@ typedef struct {
26
26
  VALUE imports_array; // Array of ImportStatement structs
27
27
  VALUE media_queries; // Array of MediaQuery structs
28
28
  VALUE media_query_lists; // Hash: list_id => Array of MediaQuery IDs
29
+ VALUE conditional_groups; // Array of ConditionalGroup structs (@supports/@layer/@container/@scope)
29
30
  int rule_id_counter; // Next rule ID (0-indexed)
30
31
  int next_selector_list_id; // Next selector list ID (0-indexed)
31
32
  int media_query_id_counter; // Next MediaQuery ID (0-indexed)
33
+ int conditional_group_id_counter; // Next ConditionalGroup ID (0-indexed)
32
34
  int next_media_query_list_id; // Next media query list ID (0-indexed)
33
35
  int media_query_count; // Safety limit for media queries
34
36
  BOOLEAN has_nesting; // Set to 1 if any nested rules are created
@@ -62,6 +64,7 @@ static inline ParserContext init_child_context(ParserContext *parent) {
62
64
  child.imports_array = rb_ary_new();
63
65
  child.media_queries = rb_ary_new();
64
66
  child.media_query_lists = rb_hash_new();
67
+ child.conditional_groups = rb_ary_new();
65
68
 
66
69
  child.selector_lists_enabled = parent->selector_lists_enabled;
67
70
 
@@ -885,7 +888,8 @@ static VALUE parse_declarations(const char *start, const char *end, ParserContex
885
888
 
886
889
  // Forward declarations
887
890
  static void parse_css_recursive(ParserContext *ctx, const char *css, const char *pe,
888
- VALUE parent_media_sym, VALUE parent_selector, VALUE parent_rule_id, int parent_media_query_id);
891
+ VALUE parent_media_sym, VALUE parent_selector, VALUE parent_rule_id, int parent_media_query_id,
892
+ int parent_conditional_group_id);
889
893
  static VALUE combine_media_queries(VALUE parent, VALUE child);
890
894
 
891
895
  /*
@@ -1032,7 +1036,8 @@ static void parse_single_media_query(const char *query_start, const char *query_
1032
1036
  * Returns: Array of declarations (only the declarations, not nested rules)
1033
1037
  */
1034
1038
  static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char *end,
1035
- VALUE parent_selector, VALUE parent_rule_id, VALUE parent_media_sym, int parent_media_query_id) {
1039
+ VALUE parent_selector, VALUE parent_rule_id, VALUE parent_media_sym, int parent_media_query_id,
1040
+ int parent_conditional_group_id) {
1036
1041
  // Check recursion depth to prevent stack overflow
1037
1042
  if (ctx->depth > MAX_PARSE_DEPTH) {
1038
1043
  rb_raise(eDepthError,
@@ -1156,11 +1161,13 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1156
1161
  // Parse mixed block (may contain declarations and/or nested @media)
1157
1162
  ctx->depth++;
1158
1163
  VALUE media_declarations = parse_mixed_block(ctx, media_block_start, media_block_end,
1159
- parent_selector, INT2FIX(media_rule_id), Qnil, combined_media_query_id);
1164
+ parent_selector, INT2FIX(media_rule_id), Qnil, combined_media_query_id,
1165
+ parent_conditional_group_id);
1160
1166
  ctx->depth--;
1161
1167
 
1162
1168
  // Create rule with the parent selector and declarations, associated with combined media query
1163
1169
  VALUE media_query_id_val = INT2FIX(combined_media_query_id);
1170
+ VALUE conditional_group_id_val = (parent_conditional_group_id >= 0) ? INT2FIX(parent_conditional_group_id) : Qnil;
1164
1171
  VALUE rule = rb_struct_new(cRule,
1165
1172
  INT2FIX(media_rule_id),
1166
1173
  parent_selector,
@@ -1169,7 +1176,8 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1169
1176
  parent_rule_id, // Link to parent for nested @media serialization
1170
1177
  Qnil, // nesting_style (nil for @media nesting)
1171
1178
  Qnil, // selector_list_id
1172
- media_query_id_val // media_query_id from parent context
1179
+ media_query_id_val, // media_query_id from parent context
1180
+ conditional_group_id_val // conditional_group_id from parent context
1173
1181
  );
1174
1182
 
1175
1183
  // Mark that we have nesting (only set once)
@@ -1251,11 +1259,13 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1251
1259
  // Recursively parse nested block
1252
1260
  ctx->depth++;
1253
1261
  VALUE nested_declarations = parse_mixed_block(ctx, nested_block_start, nested_block_end,
1254
- resolved_selector, INT2FIX(rule_id), parent_media_sym, parent_media_query_id);
1262
+ resolved_selector, INT2FIX(rule_id), parent_media_sym, parent_media_query_id,
1263
+ parent_conditional_group_id);
1255
1264
  ctx->depth--;
1256
1265
 
1257
1266
  // Create rule for nested selector
1258
1267
  VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
1268
+ VALUE conditional_group_id_val = (parent_conditional_group_id >= 0) ? INT2FIX(parent_conditional_group_id) : Qnil;
1259
1269
  VALUE rule = rb_struct_new(cRule,
1260
1270
  INT2FIX(rule_id),
1261
1271
  resolved_selector,
@@ -1264,7 +1274,8 @@ static VALUE parse_mixed_block(ParserContext *ctx, const char *start, const char
1264
1274
  parent_rule_id,
1265
1275
  nesting_style,
1266
1276
  Qnil, // selector_list_id
1267
- media_query_id_val // media_query_id from parent context
1277
+ media_query_id_val, // media_query_id from parent context
1278
+ conditional_group_id_val // conditional_group_id from parent context
1268
1279
  );
1269
1280
 
1270
1281
  // Mark that we have nesting (only set once)
@@ -1508,7 +1519,8 @@ static void parse_import_statement(ParserContext *ctx, const char **p_ptr, const
1508
1519
  * "@media ... { ... }" construct (including its closing '}', if found).
1509
1520
  */
1510
1521
  static void handle_media_at_rule(ParserContext *ctx, const char **p_ptr, const char *pe,
1511
- VALUE parent_media_sym, int parent_media_query_id) {
1522
+ VALUE parent_media_sym, int parent_media_query_id,
1523
+ int parent_conditional_group_id) {
1512
1524
  const char *p = *p_ptr;
1513
1525
  p += 6; // Skip "@media"
1514
1526
 
@@ -1541,7 +1553,8 @@ static void handle_media_at_rule(ParserContext *ctx, const char **p_ptr, const c
1541
1553
 
1542
1554
  // Parse block contents with NO media query context
1543
1555
  ctx->depth++;
1544
- parse_css_recursive(ctx, block_start, block_end, parent_media_sym, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, parent_media_query_id);
1556
+ parse_css_recursive(ctx, block_start, block_end, parent_media_sym, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, parent_media_query_id,
1557
+ parent_conditional_group_id);
1545
1558
  ctx->depth--;
1546
1559
 
1547
1560
  if (p < pe && *p == '}') p++;
@@ -1645,13 +1658,60 @@ static void handle_media_at_rule(ParserContext *ctx, const char **p_ptr, const c
1645
1658
 
1646
1659
  // Recursively parse @media block with new media query context
1647
1660
  ctx->depth++;
1648
- parse_css_recursive(ctx, block_start, block_end, combined_media_sym, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, current_media_query_id);
1661
+ parse_css_recursive(ctx, block_start, block_end, combined_media_sym, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, current_media_query_id,
1662
+ parent_conditional_group_id);
1649
1663
  ctx->depth--;
1650
1664
 
1651
1665
  if (p < pe && *p == '}') p++;
1652
1666
  *p_ptr = p;
1653
1667
  }
1654
1668
 
1669
+ // Case-insensitive ASCII check for "not" - used to tell an anonymous
1670
+ // @container condition starting with "not" apart from a genuine
1671
+ // container-query name (CSS reserves "not"/"and"/"or" as condition
1672
+ // keywords, so they're never valid name idents).
1673
+ static BOOLEAN is_not_keyword(const char *start, long len) {
1674
+ if (len != 3) return 0;
1675
+
1676
+ char c0 = start[0], c1 = start[1], c2 = start[2];
1677
+ if (c0 >= 'A' && c0 <= 'Z') c0 += 32;
1678
+ if (c1 >= 'A' && c1 <= 'Z') c1 += 32;
1679
+ if (c2 >= 'A' && c2 <= 'Z') c2 += 32;
1680
+ return c0 == 'n' && c1 == 'o' && c2 == 't';
1681
+ }
1682
+
1683
+ /*
1684
+ * Split a raw @container prelude into name/condition VALUEs (Qnil if
1685
+ * absent):
1686
+ * "sidebar (min-width: 400px)" -> name="sidebar", condition="(min-width: 400px)"
1687
+ * "(min-width: 400px)" -> name=nil, condition="(min-width: 400px)"
1688
+ * "sidebar" -> name="sidebar", condition=nil
1689
+ * "not (min-width: 400px)" -> name=nil, condition="not (min-width: 400px)" (anonymous)
1690
+ */
1691
+ static void split_container_prelude(const char *start, const char *end, VALUE *name_out, VALUE *condition_out) {
1692
+ if (start >= end || *start == '(') {
1693
+ *name_out = Qnil;
1694
+ *condition_out = (start < end) ? rb_utf8_str_new(start, end - start) : Qnil;
1695
+ return;
1696
+ }
1697
+
1698
+ const char *space = start;
1699
+ while (space < end && !IS_WHITESPACE(*space)) space++;
1700
+
1701
+ if (is_not_keyword(start, space - start)) {
1702
+ *name_out = Qnil;
1703
+ *condition_out = rb_utf8_str_new(start, end - start);
1704
+ return;
1705
+ }
1706
+
1707
+ *name_out = rb_utf8_str_new(start, space - start);
1708
+
1709
+ const char *rest_start = space;
1710
+ while (rest_start < end && IS_WHITESPACE(*rest_start)) rest_start++;
1711
+
1712
+ *condition_out = (rest_start < end) ? rb_utf8_str_new(rest_start, end - rest_start) : Qnil;
1713
+ }
1714
+
1655
1715
  /*
1656
1716
  * Handle a conditional-group at-rule (@supports/@layer/@container/@scope)
1657
1717
  * found at brace_depth 0. Behaves like @media but doesn't affect media
@@ -1660,29 +1720,77 @@ static void handle_media_at_rule(ParserContext *ctx, const char **p_ptr, const c
1660
1720
  * '@'; at_start/at_name_end/at_name_len describe the already-scanned
1661
1721
  * at-rule name. Advances *p_ptr past the entire construct (including its
1662
1722
  * closing '}', if found).
1723
+ *
1724
+ * @supports and @container build a real ConditionalGroup and tag their
1725
+ * child rules with a conditional_group_id - @layer/@scope still just
1726
+ * recurse with parent_conditional_group_id passed through unchanged (their
1727
+ * condition/name text is extracted below only for the missing-condition
1728
+ * validation, same as before). Each gets its own faithful representation
1729
+ * in its own follow-up bead.
1663
1730
  */
1664
1731
  static void handle_conditional_group_at_rule(ParserContext *ctx, const char **p_ptr, const char *pe,
1665
1732
  const char *at_start, const char *at_name_end, long at_name_len,
1666
1733
  VALUE parent_media_sym, VALUE parent_selector, VALUE parent_rule_id,
1667
- int parent_media_query_id) {
1734
+ int parent_media_query_id, int parent_conditional_group_id) {
1735
+ BOOLEAN is_supports = (at_name_len == 8 && strncmp(at_start, "supports", 8) == 0);
1736
+ BOOLEAN is_container = (at_name_len == 9 && strncmp(at_start, "container", 9) == 0);
1737
+ BOOLEAN is_layer = (at_name_len == 5 && strncmp(at_start, "layer", 5) == 0);
1738
+
1668
1739
  // Check if this rule requires a condition
1669
- BOOLEAN requires_condition =
1670
- (at_name_len == 8 && strncmp(at_start, "supports", 8) == 0) ||
1671
- (at_name_len == 9 && strncmp(at_start, "container", 9) == 0);
1740
+ BOOLEAN requires_condition = is_supports || is_container;
1672
1741
 
1673
1742
  // Extract condition (between at-rule name and opening brace)
1674
1743
  const char *cond_start = at_name_end;
1675
1744
  while (cond_start < pe && IS_WHITESPACE(*cond_start)) cond_start++;
1676
1745
 
1677
- // Skip to opening brace
1746
+ // Skip to whichever terminator comes first: '{' (block form) or ';'
1747
+ // (statement form - only valid for @layer, but we still stop there for
1748
+ // the others rather than scanning straight through it into whatever
1749
+ // rule happens to follow)
1678
1750
  const char *p = at_name_end;
1679
- while (p < pe && *p != '{') p++;
1751
+ while (p < pe && *p != '{' && *p != ';') p++;
1680
1752
 
1681
- if (p >= pe || *p != '{') {
1753
+ if (p >= pe) {
1682
1754
  *p_ptr = p;
1683
1755
  return; // Malformed
1684
1756
  }
1685
1757
 
1758
+ if (*p == ';') {
1759
+ // Trim trailing whitespace off the name list
1760
+ const char *stmt_end = p;
1761
+ while (stmt_end > cond_start && IS_WHITESPACE(*(stmt_end - 1))) stmt_end--;
1762
+
1763
+ if (is_layer && stmt_end > cond_start) {
1764
+ VALUE selector = rb_utf8_str_new(at_start - 1, stmt_end - (at_start - 1));
1765
+ int rule_id = ctx->rule_id_counter++;
1766
+ VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
1767
+ VALUE conditional_group_id_val = (parent_conditional_group_id >= 0) ? INT2FIX(parent_conditional_group_id) : Qnil;
1768
+ VALUE at_rule = rb_struct_new(cAtRule,
1769
+ INT2FIX(rule_id),
1770
+ selector,
1771
+ Qnil, // content - statement form has no block
1772
+ Qnil, // specificity
1773
+ media_query_id_val,
1774
+ conditional_group_id_val
1775
+ );
1776
+ rb_ary_push(ctx->rules_array, at_rule);
1777
+ RB_GC_GUARD(selector);
1778
+
1779
+ if (!NIL_P(parent_media_sym)) {
1780
+ VALUE rule_ids = rb_hash_aref(ctx->media_index, parent_media_sym);
1781
+ if (NIL_P(rule_ids)) {
1782
+ rule_ids = rb_ary_new();
1783
+ rb_hash_aset(ctx->media_index, parent_media_sym, rule_ids);
1784
+ }
1785
+ rb_ary_push(rule_ids, INT2FIX(rule_id));
1786
+ }
1787
+ }
1788
+
1789
+ p++; // Skip ';'
1790
+ *p_ptr = p;
1791
+ return;
1792
+ }
1793
+
1686
1794
  // Trim condition
1687
1795
  const char *cond_end = p;
1688
1796
  while (cond_end > cond_start && IS_WHITESPACE(*(cond_end - 1))) cond_end--;
@@ -1701,15 +1809,134 @@ static void handle_conditional_group_at_rule(ParserContext *ctx, const char **p_
1701
1809
  const char *block_end = find_matching_brace_strict(p, pe, ctx->check_unclosed_blocks);
1702
1810
  p = block_end;
1703
1811
 
1812
+ // For @supports/@container/@layer, build a ConditionalGroup (nested
1813
+ // inside the enclosing one, if any) and tag every rule parsed inside
1814
+ // this block with it. @scope passes parent_conditional_group_id through
1815
+ // unchanged until its own bead gives it the same treatment.
1816
+ int child_conditional_group_id = parent_conditional_group_id;
1817
+ if ((is_supports || is_container || is_layer) && (cond_end > cond_start || is_layer)) {
1818
+ VALUE name, condition;
1819
+ ID type_id;
1820
+
1821
+ if (is_supports) {
1822
+ name = Qnil;
1823
+ condition = rb_utf8_str_new(cond_start, cond_end - cond_start);
1824
+ type_id = rb_intern("supports");
1825
+ } else if (is_container) {
1826
+ split_container_prelude(cond_start, cond_end, &name, &condition);
1827
+ type_id = rb_intern("container");
1828
+ } else {
1829
+ name = (cond_end > cond_start) ? rb_utf8_str_new(cond_start, cond_end - cond_start) : Qnil;
1830
+ condition = Qnil;
1831
+ type_id = rb_intern("layer");
1832
+ }
1833
+
1834
+ VALUE parent_id_val = (parent_conditional_group_id >= 0) ? INT2FIX(parent_conditional_group_id) : Qnil;
1835
+ VALUE group = rb_struct_new(cConditionalGroup,
1836
+ INT2FIX(ctx->conditional_group_id_counter),
1837
+ ID2SYM(type_id),
1838
+ name,
1839
+ condition,
1840
+ parent_id_val
1841
+ );
1842
+ rb_ary_push(ctx->conditional_groups, group);
1843
+ child_conditional_group_id = ctx->conditional_group_id_counter;
1844
+ ctx->conditional_group_id_counter++;
1845
+ RB_GC_GUARD(condition);
1846
+ RB_GC_GUARD(name);
1847
+ }
1848
+
1704
1849
  // Recursively parse block content (preserve parent media context)
1705
1850
  ctx->depth++;
1706
- parse_css_recursive(ctx, block_start, block_end, parent_media_sym, parent_selector, parent_rule_id, parent_media_query_id);
1851
+ parse_css_recursive(ctx, block_start, block_end, parent_media_sym, parent_selector, parent_rule_id, parent_media_query_id,
1852
+ child_conditional_group_id);
1707
1853
  ctx->depth--;
1708
1854
 
1709
1855
  if (p < pe && *p == '}') p++;
1710
1856
  *p_ptr = p;
1711
1857
  }
1712
1858
 
1859
+ /*
1860
+ * Handle @namespace [prefix] (url(...) | "...") ; found at brace_depth 0.
1861
+ * Per css-namespaces-3 this is purely a statement - it always ends in ';'
1862
+ * and never has a block - so it's modeled exactly like @layer's statement
1863
+ * form above: captured verbatim as an AtRule with content nil, since
1864
+ * multiple @namespace declarations are legal (one default + any number of
1865
+ * prefixed ones) and there's no resolution/merge semantics to compute.
1866
+ *
1867
+ * Called with *p_ptr pointing at the '@'. Advances *p_ptr past the
1868
+ * statement (including the terminating ';').
1869
+ */
1870
+ static void handle_namespace_at_rule(ParserContext *ctx, const char **p_ptr, const char *pe,
1871
+ VALUE parent_media_sym, int parent_media_query_id,
1872
+ int parent_conditional_group_id) {
1873
+ const char *selector_start = *p_ptr; // Points to '@'
1874
+ const char *p = selector_start + 10; // Skip "@namespace"
1875
+
1876
+ // Scan to the terminating ';', honoring quoted strings and paren depth
1877
+ // so a URI given as url(...) can't have a stray ';' inside it (e.g. a
1878
+ // data: URI) end the statement early.
1879
+ char in_quote = 0;
1880
+ int paren_depth = 0;
1881
+ while (p < pe) {
1882
+ char c = *p;
1883
+ if (in_quote) {
1884
+ if (c == in_quote) {
1885
+ in_quote = 0;
1886
+ } else if (c == '\\' && p + 1 < pe) {
1887
+ p++;
1888
+ }
1889
+ } else if (c == ';' && paren_depth == 0) {
1890
+ break;
1891
+ } else if (c == '"' || c == '\'') {
1892
+ in_quote = c;
1893
+ } else if (c == '(') {
1894
+ paren_depth++;
1895
+ } else if (c == ')') {
1896
+ paren_depth--;
1897
+ }
1898
+ p++;
1899
+ }
1900
+
1901
+ if (p >= pe) {
1902
+ *p_ptr = p;
1903
+ return; // Malformed - no terminating ';'
1904
+ }
1905
+
1906
+ // Trim trailing whitespace off the captured text
1907
+ const char *stmt_end = p;
1908
+ while (stmt_end > selector_start + 10 && IS_WHITESPACE(*(stmt_end - 1))) stmt_end--;
1909
+
1910
+ if (stmt_end > selector_start + 10) {
1911
+ VALUE selector = rb_utf8_str_new(selector_start, stmt_end - selector_start);
1912
+ int rule_id = ctx->rule_id_counter++;
1913
+ VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
1914
+ VALUE conditional_group_id_val = (parent_conditional_group_id >= 0) ? INT2FIX(parent_conditional_group_id) : Qnil;
1915
+ VALUE at_rule = rb_struct_new(cAtRule,
1916
+ INT2FIX(rule_id),
1917
+ selector,
1918
+ Qnil, // content - statement form has no block
1919
+ Qnil, // specificity
1920
+ media_query_id_val,
1921
+ conditional_group_id_val
1922
+ );
1923
+ rb_ary_push(ctx->rules_array, at_rule);
1924
+ RB_GC_GUARD(selector);
1925
+
1926
+ if (!NIL_P(parent_media_sym)) {
1927
+ VALUE rule_ids = rb_hash_aref(ctx->media_index, parent_media_sym);
1928
+ if (NIL_P(rule_ids)) {
1929
+ rule_ids = rb_ary_new();
1930
+ rb_hash_aset(ctx->media_index, parent_media_sym, rule_ids);
1931
+ }
1932
+ rb_ary_push(rule_ids, INT2FIX(rule_id));
1933
+ }
1934
+ }
1935
+
1936
+ p++; // Skip ';'
1937
+ *p_ptr = p;
1938
+ }
1939
+
1713
1940
  /*
1714
1941
  * Handle an @keyframes (or -webkit-/-moz- prefixed) at-rule found at
1715
1942
  * brace_depth 0. Called with *p_ptr pointing at the '@'; at_name_end marks
@@ -1717,7 +1944,8 @@ static void handle_conditional_group_at_rule(ParserContext *ctx, const char **p_
1717
1944
  * entire construct (including its closing '}', if found).
1718
1945
  */
1719
1946
  static void handle_keyframes_at_rule(ParserContext *ctx, const char **p_ptr, const char *pe,
1720
- const char *at_name_end, VALUE parent_media_sym, int parent_media_query_id) {
1947
+ const char *at_name_end, VALUE parent_media_sym, int parent_media_query_id,
1948
+ int parent_conditional_group_id) {
1721
1949
  // Build full selector string: "@keyframes fade"
1722
1950
  const char *selector_start = *p_ptr; // Points to '@'
1723
1951
  const char *p = at_name_end;
@@ -1746,19 +1974,22 @@ static void handle_keyframes_at_rule(ParserContext *ctx, const char **p_ptr, con
1746
1974
  // NOT land in ctx->rules_array — they need their own array to become this
1747
1975
  // AtRule's `content` below, so parse into an isolated child context instead.
1748
1976
  ParserContext nested_ctx = init_child_context(ctx);
1749
- parse_css_recursive(&nested_ctx, block_start, block_end, NO_PARENT_MEDIA, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, NO_MEDIA_QUERY_ID);
1977
+ parse_css_recursive(&nested_ctx, block_start, block_end, NO_PARENT_MEDIA, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, NO_MEDIA_QUERY_ID,
1978
+ NO_CONDITIONAL_GROUP_ID);
1750
1979
 
1751
1980
  // Get rule ID and increment
1752
1981
  int rule_id = ctx->rule_id_counter++;
1753
1982
 
1754
1983
  // Create AtRule with nested rules
1755
1984
  VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
1985
+ VALUE conditional_group_id_val = (parent_conditional_group_id >= 0) ? INT2FIX(parent_conditional_group_id) : Qnil;
1756
1986
  VALUE at_rule = rb_struct_new(cAtRule,
1757
1987
  INT2FIX(rule_id),
1758
1988
  selector,
1759
1989
  nested_ctx.rules_array, // Array of Rule (keyframe blocks)
1760
1990
  Qnil, // specificity
1761
- media_query_id_val // media_query_id from parent context
1991
+ media_query_id_val, // media_query_id from parent context
1992
+ conditional_group_id_val // conditional_group_id from parent context
1762
1993
  );
1763
1994
 
1764
1995
  // Add to rules array
@@ -1785,7 +2016,8 @@ static void handle_keyframes_at_rule(ParserContext *ctx, const char **p_ptr, con
1785
2016
  * closing '}', if found).
1786
2017
  */
1787
2018
  static void handle_font_face_at_rule(ParserContext *ctx, const char **p_ptr, const char *pe,
1788
- const char *at_name_end, VALUE parent_media_sym, int parent_media_query_id) {
2019
+ const char *at_name_end, VALUE parent_media_sym, int parent_media_query_id,
2020
+ int parent_conditional_group_id) {
1789
2021
  // Build selector string: "@font-face"
1790
2022
  const char *selector_start = *p_ptr; // Points to '@'
1791
2023
  const char *p = at_name_end;
@@ -1817,12 +2049,14 @@ static void handle_font_face_at_rule(ParserContext *ctx, const char **p_ptr, con
1817
2049
 
1818
2050
  // Create AtRule with declarations
1819
2051
  VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
2052
+ VALUE conditional_group_id_val = (parent_conditional_group_id >= 0) ? INT2FIX(parent_conditional_group_id) : Qnil;
1820
2053
  VALUE at_rule = rb_struct_new(cAtRule,
1821
2054
  INT2FIX(rule_id),
1822
2055
  selector,
1823
2056
  declarations, // Array of Declaration
1824
2057
  Qnil, // specificity
1825
- media_query_id_val // media_query_id from parent context
2058
+ media_query_id_val, // media_query_id from parent context
2059
+ conditional_group_id_val // conditional_group_id from parent context
1826
2060
  );
1827
2061
 
1828
2062
  // Add to rules array
@@ -1852,7 +2086,8 @@ static void handle_font_face_at_rule(ParserContext *ctx, const char **p_ptr, con
1852
2086
  * selector_start/decl_start - the caller does that once this returns.
1853
2087
  */
1854
2088
  static void finish_rule_block(ParserContext *ctx, const char *selector_start, const char *decl_start, const char *p,
1855
- VALUE parent_selector, VALUE parent_rule_id, VALUE parent_media_sym, int parent_media_query_id) {
2089
+ VALUE parent_selector, VALUE parent_rule_id, VALUE parent_media_sym, int parent_media_query_id,
2090
+ int parent_conditional_group_id) {
1856
2091
  // We've found a complete CSS rule block - now determine if it has nesting
1857
2092
  // Example: .parent { color: red; & .child { font-size: 14px; } }
1858
2093
  // ^selector_start ^decl_start ^p (at })
@@ -2006,6 +2241,7 @@ static void finish_rule_block(ParserContext *ctx, const char *selector_start, co
2006
2241
 
2007
2242
  // Create Rule
2008
2243
  VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
2244
+ VALUE conditional_group_id_val = (parent_conditional_group_id >= 0) ? INT2FIX(parent_conditional_group_id) : Qnil;
2009
2245
  VALUE rule = rb_struct_new(cRule,
2010
2246
  INT2FIX(rule_id),
2011
2247
  resolved_selector,
@@ -2014,7 +2250,8 @@ static void finish_rule_block(ParserContext *ctx, const char *selector_start, co
2014
2250
  parent_id_val,
2015
2251
  nesting_style_val,
2016
2252
  selector_list_id_val,
2017
- media_query_id_val // media_query_id from parent context
2253
+ media_query_id_val, // media_query_id from parent context
2254
+ conditional_group_id_val // conditional_group_id from parent context
2018
2255
  );
2019
2256
 
2020
2257
  // Track rule in selector list if applicable
@@ -2119,7 +2356,8 @@ static void finish_rule_block(ParserContext *ctx, const char *selector_start, co
2119
2356
  // Nested rules will be added AFTER the placeholder
2120
2357
  ctx->depth++;
2121
2358
  VALUE parent_declarations = parse_mixed_block(ctx, decl_start, p,
2122
- resolved_current, INT2FIX(current_rule_id), parent_media_sym, parent_media_query_id);
2359
+ resolved_current, INT2FIX(current_rule_id), parent_media_sym, parent_media_query_id,
2360
+ parent_conditional_group_id);
2123
2361
  ctx->depth--;
2124
2362
 
2125
2363
  // Determine selector_list_id value
@@ -2128,6 +2366,7 @@ static void finish_rule_block(ParserContext *ctx, const char *selector_start, co
2128
2366
  // Create parent rule and replace placeholder
2129
2367
  // Always create the rule (even if empty) to avoid edge cases
2130
2368
  VALUE media_query_id_val = (parent_media_query_id >= 0) ? INT2FIX(parent_media_query_id) : Qnil;
2369
+ VALUE conditional_group_id_val = (parent_conditional_group_id >= 0) ? INT2FIX(parent_conditional_group_id) : Qnil;
2131
2370
  VALUE rule = rb_struct_new(cRule,
2132
2371
  INT2FIX(current_rule_id),
2133
2372
  resolved_current,
@@ -2136,7 +2375,8 @@ static void finish_rule_block(ParserContext *ctx, const char *selector_start, co
2136
2375
  current_parent_id,
2137
2376
  current_nesting_style,
2138
2377
  selector_list_id_val,
2139
- media_query_id_val // media_query_id from parent context
2378
+ media_query_id_val, // media_query_id from parent context
2379
+ conditional_group_id_val // conditional_group_id from parent context
2140
2380
  );
2141
2381
 
2142
2382
  // Track rule in selector list if applicable
@@ -2169,7 +2409,8 @@ static void finish_rule_block(ParserContext *ctx, const char *selector_start, co
2169
2409
  * parent_rule_id: Parent rule ID (Fixnum) for nested rules (or Qnil for top-level)
2170
2410
  */
2171
2411
  static void parse_css_recursive(ParserContext *ctx, const char *css, const char *pe,
2172
- VALUE parent_media_sym, VALUE parent_selector, VALUE parent_rule_id, int parent_media_query_id) {
2412
+ VALUE parent_media_sym, VALUE parent_selector, VALUE parent_rule_id, int parent_media_query_id,
2413
+ int parent_conditional_group_id) {
2173
2414
  // Check recursion depth to prevent stack overflow
2174
2415
  if (ctx->depth > MAX_PARSE_DEPTH) {
2175
2416
  rb_raise(eDepthError,
@@ -2215,10 +2456,22 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
2215
2456
  continue;
2216
2457
  }
2217
2458
 
2459
+ // Check for @namespace at-rule (statement-only - always ends in
2460
+ // ';', never has a block; css-namespaces-3). A quote isn't a valid
2461
+ // ident character, so a prefix or value can immediately follow
2462
+ // with no whitespace (e.g. minified `@namespace"...";` or
2463
+ // `@namespace svg"...";`) - accept that alongside whitespace/';'.
2464
+ if (RB_UNLIKELY(brace_depth == 0 && p + 10 < pe && *p == '@' &&
2465
+ strncmp(p + 1, "namespace", 9) == 0 &&
2466
+ (IS_WHITESPACE(p[10]) || p[10] == ';' || p[10] == '"' || p[10] == '\''))) {
2467
+ handle_namespace_at_rule(ctx, &p, pe, parent_media_sym, parent_media_query_id, parent_conditional_group_id);
2468
+ continue;
2469
+ }
2470
+
2218
2471
  // Check for @media at-rule (only at depth 0)
2219
2472
  if (RB_UNLIKELY(brace_depth == 0 && p + 6 < pe && *p == '@' &&
2220
- strncmp(p + 1, "media", 5) == 0 && IS_WHITESPACE(p[6]))) {
2221
- handle_media_at_rule(ctx, &p, pe, parent_media_sym, parent_media_query_id);
2473
+ strncmp(p + 1, "media", 5) == 0 && (IS_WHITESPACE(p[6]) || p[6] == '('))) {
2474
+ handle_media_at_rule(ctx, &p, pe, parent_media_sym, parent_media_query_id, parent_conditional_group_id);
2222
2475
  continue;
2223
2476
  }
2224
2477
 
@@ -2229,8 +2482,11 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
2229
2482
  const char *at_start = p + 1;
2230
2483
  const char *at_name_end = at_start;
2231
2484
 
2232
- // Find end of at-rule name (stop at whitespace or opening brace)
2233
- while (at_name_end < pe && !IS_WHITESPACE(*at_name_end) && *at_name_end != '{') {
2485
+ // Find end of at-rule name (stop at whitespace, '{', '(', or ';' -
2486
+ // an ident naturally terminates at any of these even with no
2487
+ // space, e.g. minified "@supports(display:grid){")
2488
+ while (at_name_end < pe && !IS_WHITESPACE(*at_name_end) && *at_name_end != '{' &&
2489
+ *at_name_end != '(' && *at_name_end != ';') {
2234
2490
  at_name_end++;
2235
2491
  }
2236
2492
 
@@ -2245,7 +2501,8 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
2245
2501
 
2246
2502
  if (is_conditional_group) {
2247
2503
  handle_conditional_group_at_rule(ctx, &p, pe, at_start, at_name_end, at_name_len,
2248
- parent_media_sym, parent_selector, parent_rule_id, parent_media_query_id);
2504
+ parent_media_sym, parent_selector, parent_rule_id, parent_media_query_id,
2505
+ parent_conditional_group_id);
2249
2506
  continue;
2250
2507
  }
2251
2508
 
@@ -2257,7 +2514,7 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
2257
2514
  (at_name_len == 13 && strncmp(at_start, "-moz-keyframes", 13) == 0);
2258
2515
 
2259
2516
  if (is_keyframes) {
2260
- handle_keyframes_at_rule(ctx, &p, pe, at_name_end, parent_media_sym, parent_media_query_id);
2517
+ handle_keyframes_at_rule(ctx, &p, pe, at_name_end, parent_media_sym, parent_media_query_id, parent_conditional_group_id);
2261
2518
  continue;
2262
2519
  }
2263
2520
 
@@ -2265,7 +2522,7 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
2265
2522
  BOOLEAN is_font_face = (at_name_len == 9 && strncmp(at_start, "font-face", 9) == 0);
2266
2523
 
2267
2524
  if (is_font_face) {
2268
- handle_font_face_at_rule(ctx, &p, pe, at_name_end, parent_media_sym, parent_media_query_id);
2525
+ handle_font_face_at_rule(ctx, &p, pe, at_name_end, parent_media_sym, parent_media_query_id, parent_conditional_group_id);
2269
2526
  continue;
2270
2527
  }
2271
2528
  }
@@ -2289,7 +2546,8 @@ static void parse_css_recursive(ParserContext *ctx, const char *css, const char
2289
2546
  brace_depth--;
2290
2547
  if (brace_depth == 0 && selector_start != NULL && decl_start != NULL) {
2291
2548
  finish_rule_block(ctx, selector_start, decl_start, p,
2292
- parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id);
2549
+ parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id,
2550
+ parent_conditional_group_id);
2293
2551
  selector_start = NULL;
2294
2552
  decl_start = NULL;
2295
2553
  }
@@ -2406,9 +2664,11 @@ VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_off
2406
2664
  ctx.imports_array = rb_ary_new();
2407
2665
  ctx.media_queries = rb_ary_new();
2408
2666
  ctx.media_query_lists = rb_hash_new();
2667
+ ctx.conditional_groups = rb_ary_new();
2409
2668
  ctx.rule_id_counter = rule_id_offset; // Start from offset
2410
2669
  ctx.next_selector_list_id = 0; // Start from 0
2411
2670
  ctx.media_query_id_counter = 0; // Start from 0
2671
+ ctx.conditional_group_id_counter = 0; // Start from 0
2412
2672
  ctx.next_media_query_list_id = 0; // Start from 0
2413
2673
  ctx.media_query_count = 0;
2414
2674
  ctx.has_nesting = 0; // Will be set to 1 if any nested rules are created
@@ -2429,7 +2689,7 @@ VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_off
2429
2689
 
2430
2690
  // Parse CSS (top-level, no parent context)
2431
2691
  DEBUG_PRINTF("[PARSE] Starting parse_css_recursive from: %.80s\n", p);
2432
- parse_css_recursive(&ctx, p, pe, NO_PARENT_MEDIA, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, NO_MEDIA_QUERY_ID);
2692
+ parse_css_recursive(&ctx, p, pe, NO_PARENT_MEDIA, NO_PARENT_SELECTOR, NO_PARENT_RULE_ID, NO_MEDIA_QUERY_ID, NO_CONDITIONAL_GROUP_ID);
2433
2693
 
2434
2694
  // Build result hash
2435
2695
  VALUE result = rb_hash_new();
@@ -2438,6 +2698,7 @@ VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_off
2438
2698
  rb_hash_aset(result, ID2SYM(rb_intern("media_queries")), ctx.media_queries);
2439
2699
  rb_hash_aset(result, ID2SYM(rb_intern("_selector_lists")), ctx.selector_lists);
2440
2700
  rb_hash_aset(result, ID2SYM(rb_intern("_media_query_lists")), ctx.media_query_lists);
2701
+ rb_hash_aset(result, ID2SYM(rb_intern("conditional_groups")), ctx.conditional_groups);
2441
2702
  rb_hash_aset(result, ID2SYM(rb_intern("imports")), ctx.imports_array);
2442
2703
  rb_hash_aset(result, ID2SYM(rb_intern("charset")), charset);
2443
2704
  rb_hash_aset(result, ID2SYM(rb_intern("last_rule_id")), INT2FIX(ctx.rule_id_counter));
@@ -2449,6 +2710,7 @@ VALUE parse_css_new_impl(VALUE css_string, VALUE parser_options, int rule_id_off
2449
2710
  RB_GC_GUARD(ctx.media_queries);
2450
2711
  RB_GC_GUARD(ctx.selector_lists);
2451
2712
  RB_GC_GUARD(ctx.media_query_lists);
2713
+ RB_GC_GUARD(ctx.conditional_groups);
2452
2714
  RB_GC_GUARD(ctx.imports_array);
2453
2715
  RB_GC_GUARD(ctx.base_uri);
2454
2716
  RB_GC_GUARD(ctx.uri_resolver);
@@ -644,12 +644,15 @@ static int flatten_selector_group_callback(VALUE group_key, VALUE group_indices,
644
644
 
645
645
  int new_rule_id = *ctx->rule_id_counter;
646
646
 
647
- // Extract media_query_id from first rule in group (all should have same media_query_id)
647
+ // Extract media_query_id/conditional_group_id from first rule in group
648
+ // (all should share both, same as selector_list_id above)
648
649
  VALUE media_query_id = Qnil;
650
+ VALUE conditional_group_id = Qnil;
649
651
  if (RARRAY_LEN(group_indices) > 0) {
650
652
  long first_rule_idx = FIX2LONG(RARRAY_AREF(group_indices, 0));
651
653
  VALUE first_rule = RARRAY_AREF(ctx->rules_array, first_rule_idx);
652
654
  media_query_id = rb_struct_aref(first_rule, INT2FIX(RULE_MEDIA_QUERY_ID));
655
+ conditional_group_id = rb_struct_aref(first_rule, INT2FIX(RULE_CONDITIONAL_GROUP_ID));
653
656
  }
654
657
 
655
658
  // Track old rule IDs to new rule ID mapping (only for rules in media queries)
@@ -672,7 +675,8 @@ static int flatten_selector_group_callback(VALUE group_key, VALUE group_indices,
672
675
  Qnil, // parent_rule_id
673
676
  Qnil, // nesting_style
674
677
  selector_list_id, // Preserve selector_list_id if all rules in group share same ID
675
- media_query_id // Preserve media_query_id from source rules
678
+ media_query_id, // Preserve media_query_id from source rules
679
+ conditional_group_id // Preserve conditional_group_id from source rules
676
680
  );
677
681
  rb_ary_push(ctx->merged_rules, new_rule);
678
682