cataract 0.1.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 (90) hide show
  1. checksums.yaml +7 -0
  2. data/.clang-tidy +30 -0
  3. data/.github/workflows/ci-macos.yml +12 -0
  4. data/.github/workflows/ci.yml +77 -0
  5. data/.github/workflows/test.yml +76 -0
  6. data/.gitignore +45 -0
  7. data/.overcommit.yml +38 -0
  8. data/.rubocop.yml +83 -0
  9. data/BENCHMARKS.md +201 -0
  10. data/CHANGELOG.md +1 -0
  11. data/Gemfile +27 -0
  12. data/LICENSE +21 -0
  13. data/RAGEL_MIGRATION.md +60 -0
  14. data/README.md +292 -0
  15. data/Rakefile +209 -0
  16. data/benchmarks/benchmark_harness.rb +193 -0
  17. data/benchmarks/benchmark_merging.rb +121 -0
  18. data/benchmarks/benchmark_optimization_comparison.rb +168 -0
  19. data/benchmarks/benchmark_parsing.rb +153 -0
  20. data/benchmarks/benchmark_ragel_removal.rb +56 -0
  21. data/benchmarks/benchmark_runner.rb +70 -0
  22. data/benchmarks/benchmark_serialization.rb +180 -0
  23. data/benchmarks/benchmark_shorthand.rb +109 -0
  24. data/benchmarks/benchmark_shorthand_expansion.rb +176 -0
  25. data/benchmarks/benchmark_specificity.rb +124 -0
  26. data/benchmarks/benchmark_string_allocation.rb +151 -0
  27. data/benchmarks/benchmark_stylesheet_to_s.rb +62 -0
  28. data/benchmarks/benchmark_to_s_cached.rb +55 -0
  29. data/benchmarks/benchmark_value_splitter.rb +54 -0
  30. data/benchmarks/benchmark_yjit.rb +158 -0
  31. data/benchmarks/benchmark_yjit_workers.rb +61 -0
  32. data/benchmarks/profile_to_s.rb +23 -0
  33. data/benchmarks/speedup_calculator.rb +83 -0
  34. data/benchmarks/system_metadata.rb +81 -0
  35. data/benchmarks/templates/benchmarks.md.erb +221 -0
  36. data/benchmarks/yjit_tests.rb +141 -0
  37. data/cataract.gemspec +34 -0
  38. data/cliff.toml +92 -0
  39. data/examples/color_conversion_visual_test/color_conversion_test.html +3603 -0
  40. data/examples/color_conversion_visual_test/generate.rb +202 -0
  41. data/examples/color_conversion_visual_test/template.html.erb +259 -0
  42. data/examples/css_analyzer/analyzer.rb +164 -0
  43. data/examples/css_analyzer/analyzers/base.rb +33 -0
  44. data/examples/css_analyzer/analyzers/colors.rb +133 -0
  45. data/examples/css_analyzer/analyzers/important.rb +88 -0
  46. data/examples/css_analyzer/analyzers/properties.rb +61 -0
  47. data/examples/css_analyzer/analyzers/specificity.rb +68 -0
  48. data/examples/css_analyzer/templates/report.html.erb +575 -0
  49. data/examples/css_analyzer.rb +69 -0
  50. data/examples/github_analysis.html +5343 -0
  51. data/ext/cataract/cataract.c +1086 -0
  52. data/ext/cataract/cataract.h +174 -0
  53. data/ext/cataract/css_parser.c +1435 -0
  54. data/ext/cataract/extconf.rb +48 -0
  55. data/ext/cataract/import_scanner.c +174 -0
  56. data/ext/cataract/merge.c +973 -0
  57. data/ext/cataract/shorthand_expander.c +902 -0
  58. data/ext/cataract/specificity.c +213 -0
  59. data/ext/cataract/value_splitter.c +116 -0
  60. data/ext/cataract_color/cataract_color.c +16 -0
  61. data/ext/cataract_color/color_conversion.c +1687 -0
  62. data/ext/cataract_color/color_conversion.h +136 -0
  63. data/ext/cataract_color/color_conversion_lab.c +571 -0
  64. data/ext/cataract_color/color_conversion_named.c +259 -0
  65. data/ext/cataract_color/color_conversion_oklab.c +547 -0
  66. data/ext/cataract_color/extconf.rb +23 -0
  67. data/ext/cataract_old/cataract.c +393 -0
  68. data/ext/cataract_old/cataract.h +250 -0
  69. data/ext/cataract_old/css_parser.c +933 -0
  70. data/ext/cataract_old/extconf.rb +67 -0
  71. data/ext/cataract_old/import_scanner.c +174 -0
  72. data/ext/cataract_old/merge.c +776 -0
  73. data/ext/cataract_old/shorthand_expander.c +902 -0
  74. data/ext/cataract_old/specificity.c +213 -0
  75. data/ext/cataract_old/stylesheet.c +290 -0
  76. data/ext/cataract_old/value_splitter.c +116 -0
  77. data/lib/cataract/at_rule.rb +97 -0
  78. data/lib/cataract/color_conversion.rb +18 -0
  79. data/lib/cataract/declarations.rb +332 -0
  80. data/lib/cataract/import_resolver.rb +210 -0
  81. data/lib/cataract/rule.rb +131 -0
  82. data/lib/cataract/stylesheet.rb +716 -0
  83. data/lib/cataract/stylesheet_scope.rb +257 -0
  84. data/lib/cataract/version.rb +5 -0
  85. data/lib/cataract.rb +107 -0
  86. data/lib/tasks/gem.rake +158 -0
  87. data/scripts/fuzzer/run.rb +828 -0
  88. data/scripts/fuzzer/worker.rb +99 -0
  89. data/scripts/generate_benchmarks_md.rb +155 -0
  90. metadata +135 -0
@@ -0,0 +1,902 @@
1
+ /*
2
+ * shorthand_expander.c - CSS shorthand property expansion and creation
3
+ *
4
+ * Handles expansion of shorthand properties (margin, padding, border, etc.)
5
+ * and creation of shorthands from longhand properties.
6
+ *
7
+ * NOTE: value_splitter has been migrated to pure C (value_splitter.c)
8
+ */
9
+
10
+ #include "cataract.h"
11
+
12
+ /*
13
+ * Helper: Check if string ends with !important and strip it
14
+ * Returns 1 if important, 0 otherwise
15
+ * Updates len to exclude !important if present
16
+ */
17
+ static int check_and_strip_important(const char *str, size_t *len) {
18
+ if (*len < 10) return 0; // Need at least "!important"
19
+
20
+ const char *p = str + *len - 1;
21
+
22
+ // Skip trailing whitespace
23
+ while (p > str && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) {
24
+ p--;
25
+ }
26
+
27
+ // Check if it ends with "!important" (case-insensitive would be: strncasecmp)
28
+ if (p - str >= 9) {
29
+ if (strncmp(p - 9, "!important", 10) == 0) {
30
+ // Found it - update length to exclude !important and trailing whitespace
31
+ p -= 10;
32
+ while (p >= str && (*p == ' ' || *p == '\t')) p--;
33
+ *len = (p - str) + 1;
34
+ return 1;
35
+ }
36
+ }
37
+ return 0;
38
+ }
39
+
40
+ /*
41
+ * Helper: Expand dimension shorthand (margin, padding, border-color, etc.)
42
+ */
43
+ static VALUE expand_dimensions(VALUE parts, const char *property, const char *suffix) {
44
+ long len = RARRAY_LEN(parts);
45
+ VALUE result = rb_hash_new();
46
+
47
+ if (len == 0) return result;
48
+
49
+ // Sanity check: property and suffix should be reasonable length
50
+ if (strlen(property) > 32) {
51
+ rb_raise(rb_eArgError, "Property name too long (max 32 chars)");
52
+ }
53
+ if (suffix && strlen(suffix) > 32) {
54
+ rb_raise(rb_eArgError, "Suffix name too long (max 32 chars)");
55
+ }
56
+
57
+ // Check if last part has !important
58
+ int is_important = 0;
59
+ if (len > 0) {
60
+ VALUE last_part = rb_ary_entry(parts, len - 1);
61
+ const char *last_str = RSTRING_PTR(last_part);
62
+ size_t last_len = RSTRING_LEN(last_part);
63
+
64
+ if (check_and_strip_important(last_str, &last_len)) {
65
+ is_important = 1;
66
+ // Update the array with stripped value
67
+ if (last_len > 0) {
68
+ rb_ary_store(parts, len - 1, rb_str_new(last_str, last_len));
69
+ } else {
70
+ // The value was just "!important" - reduce array length
71
+ len--;
72
+ }
73
+ }
74
+ }
75
+
76
+ VALUE sides[4];
77
+ if (len == 1) {
78
+ VALUE v = rb_ary_entry(parts, 0);
79
+ sides[0] = sides[1] = sides[2] = sides[3] = v;
80
+ } else if (len == 2) {
81
+ sides[0] = sides[2] = rb_ary_entry(parts, 0); // top, bottom
82
+ sides[1] = sides[3] = rb_ary_entry(parts, 1); // right, left
83
+ } else if (len == 3) {
84
+ sides[0] = rb_ary_entry(parts, 0); // top
85
+ sides[1] = sides[3] = rb_ary_entry(parts, 1); // right, left
86
+ sides[2] = rb_ary_entry(parts, 2); // bottom
87
+ } else if (len == 4) {
88
+ sides[0] = rb_ary_entry(parts, 0);
89
+ sides[1] = rb_ary_entry(parts, 1);
90
+ sides[2] = rb_ary_entry(parts, 2);
91
+ sides[3] = rb_ary_entry(parts, 3);
92
+ } else {
93
+ return result; // Invalid
94
+ }
95
+
96
+ const char *side_names[] = {"top", "right", "bottom", "left"};
97
+ for (int i = 0; i < 4; i++) {
98
+ char key[128];
99
+ if (suffix) {
100
+ snprintf(key, sizeof(key), "%s-%s-%s", property, side_names[i], suffix);
101
+ } else {
102
+ snprintf(key, sizeof(key), "%s-%s", property, side_names[i]);
103
+ }
104
+
105
+ // Append !important if needed
106
+ VALUE final_value;
107
+ if (is_important) {
108
+ const char *val = StringValueCStr(sides[i]);
109
+ char buf[256];
110
+ snprintf(buf, sizeof(buf), "%s !important", val);
111
+ final_value = STR_NEW_CSTR(buf);
112
+ } else {
113
+ final_value = sides[i];
114
+ }
115
+
116
+ rb_hash_aset(result, STR_NEW_CSTR(key), final_value);
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ /*
123
+ * Expand margin shorthand: "10px 20px 30px 40px"
124
+ */
125
+ VALUE cataract_expand_margin(VALUE self, VALUE value) {
126
+ VALUE parts = cataract_split_value(self, value);
127
+ return expand_dimensions(parts, "margin", NULL);
128
+ }
129
+
130
+ /*
131
+ * Expand padding shorthand: "10px 20px 30px 40px"
132
+ */
133
+ VALUE cataract_expand_padding(VALUE self, VALUE value) {
134
+ VALUE parts = cataract_split_value(self, value);
135
+ return expand_dimensions(parts, "padding", NULL);
136
+ }
137
+
138
+ /*
139
+ * Expand border-color shorthand: "red green blue yellow"
140
+ */
141
+ VALUE cataract_expand_border_color(VALUE self, VALUE value) {
142
+ VALUE parts = cataract_split_value(self, value);
143
+ return expand_dimensions(parts, "border", "color");
144
+ }
145
+
146
+ /*
147
+ * Expand border-style shorthand: "solid dashed dotted double"
148
+ */
149
+ VALUE cataract_expand_border_style(VALUE self, VALUE value) {
150
+ VALUE parts = cataract_split_value(self, value);
151
+ return expand_dimensions(parts, "border", "style");
152
+ }
153
+
154
+ /*
155
+ * Expand border-width shorthand: "1px 2px 3px 4px"
156
+ */
157
+ VALUE cataract_expand_border_width(VALUE self, VALUE value) {
158
+ VALUE parts = cataract_split_value(self, value);
159
+ return expand_dimensions(parts, "border", "width");
160
+ }
161
+
162
+ /*
163
+ * Check if string matches border width keyword or starts with digit
164
+ */
165
+ static int is_border_width(const char *str) {
166
+ const char *keywords[] = {"thin", "medium", "thick", "inherit", NULL};
167
+ for (int i = 0; keywords[i]; i++) {
168
+ if (strcmp(str, keywords[i]) == 0) return 1;
169
+ }
170
+ return (str[0] >= '0' && str[0] <= '9');
171
+ }
172
+
173
+ /*
174
+ * Check if string matches border style keyword
175
+ */
176
+ static int is_border_style(const char *str) {
177
+ const char *keywords[] = {"none", "hidden", "dotted", "dashed", "solid",
178
+ "double", "groove", "ridge", "inset", "outset", "inherit", NULL};
179
+ for (int i = 0; keywords[i]; i++) {
180
+ if (strcmp(str, keywords[i]) == 0) return 1;
181
+ }
182
+ return 0;
183
+ }
184
+
185
+ /*
186
+ * Expand border shorthand: "1px solid red"
187
+ */
188
+ VALUE cataract_expand_border(VALUE self, VALUE value) {
189
+ VALUE parts = cataract_split_value(self, value);
190
+ long len = RARRAY_LEN(parts);
191
+ VALUE result = rb_hash_new();
192
+
193
+ VALUE width = Qnil;
194
+ VALUE style = Qnil;
195
+ VALUE color = Qnil;
196
+
197
+ for (long i = 0; i < len; i++) {
198
+ VALUE part = rb_ary_entry(parts, i);
199
+ const char *str = StringValueCStr(part);
200
+
201
+ if (width == Qnil && is_border_width(str)) {
202
+ width = part;
203
+ } else if (style == Qnil && is_border_style(str)) {
204
+ style = part;
205
+ } else if (color == Qnil) {
206
+ color = part;
207
+ }
208
+ }
209
+
210
+ const char *sides[] = {"top", "right", "bottom", "left"};
211
+ for (int i = 0; i < 4; i++) {
212
+ if (width != Qnil) {
213
+ char key[64];
214
+ snprintf(key, sizeof(key), "border-%s-width", sides[i]);
215
+ rb_hash_aset(result, STR_NEW_CSTR(key), width);
216
+ }
217
+ if (style != Qnil) {
218
+ char key[64];
219
+ snprintf(key, sizeof(key), "border-%s-style", sides[i]);
220
+ rb_hash_aset(result, STR_NEW_CSTR(key), style);
221
+ }
222
+ if (color != Qnil) {
223
+ char key[64];
224
+ snprintf(key, sizeof(key), "border-%s-color", sides[i]);
225
+ rb_hash_aset(result, STR_NEW_CSTR(key), color);
226
+ }
227
+ }
228
+
229
+ return result;
230
+ }
231
+
232
+ /*
233
+ * Expand border-{side} shorthand: "2px dashed blue"
234
+ */
235
+ VALUE cataract_expand_border_side(VALUE self, VALUE side, VALUE value) {
236
+ VALUE parts = cataract_split_value(self, value);
237
+ long len = RARRAY_LEN(parts);
238
+ VALUE result = rb_hash_new();
239
+ const char *side_str = StringValueCStr(side);
240
+
241
+ // Validate side is one of the valid CSS sides
242
+ const char *valid_sides[] = {"top", "right", "bottom", "left", NULL};
243
+ int valid = 0;
244
+ for (int i = 0; valid_sides[i]; i++) {
245
+ if (strcmp(side_str, valid_sides[i]) == 0) {
246
+ valid = 1;
247
+ break;
248
+ }
249
+ }
250
+ if (!valid) {
251
+ rb_raise(rb_eArgError, "Invalid side '%s'. Must be one of: top, right, bottom, left", side_str);
252
+ }
253
+
254
+ VALUE width = Qnil;
255
+ VALUE style = Qnil;
256
+ VALUE color = Qnil;
257
+
258
+ for (long i = 0; i < len; i++) {
259
+ VALUE part = rb_ary_entry(parts, i);
260
+ const char *str = StringValueCStr(part);
261
+
262
+ if (width == Qnil && is_border_width(str)) {
263
+ width = part;
264
+ } else if (style == Qnil && is_border_style(str)) {
265
+ style = part;
266
+ } else if (color == Qnil) {
267
+ color = part;
268
+ }
269
+ }
270
+
271
+ if (width != Qnil) {
272
+ char key[64];
273
+ snprintf(key, sizeof(key), "border-%s-width", side_str);
274
+ rb_hash_aset(result, STR_NEW_CSTR(key), width);
275
+ }
276
+ if (style != Qnil) {
277
+ char key[64];
278
+ snprintf(key, sizeof(key), "border-%s-style", side_str);
279
+ rb_hash_aset(result, STR_NEW_CSTR(key), style);
280
+ }
281
+ if (color != Qnil) {
282
+ char key[64];
283
+ snprintf(key, sizeof(key), "border-%s-color", side_str);
284
+ rb_hash_aset(result, STR_NEW_CSTR(key), color);
285
+ }
286
+
287
+ return result;
288
+ }
289
+
290
+ /*
291
+ * Expand font shorthand: "bold 14px/1.5 'Helvetica Neue', sans-serif"
292
+ * Font syntax: [style] [variant] [weight] [size]/[line-height] [family]
293
+ * Only size and family are required
294
+ */
295
+ VALUE cataract_expand_font(VALUE self, VALUE value) {
296
+ // Font is complex - need to handle / separator for line-height
297
+ // Split on / first to separate size from line-height
298
+ const char *str = StringValueCStr(value);
299
+ const char *slash = strchr(str, '/');
300
+
301
+ VALUE result = rb_hash_new();
302
+ VALUE size_part, family_part;
303
+ VALUE line_height = Qnil;
304
+
305
+ if (slash) {
306
+ // Has line-height: "14px/1.5 Arial"
307
+ size_part = rb_str_new(str, slash - str);
308
+
309
+ // Find family after line-height (next space after /)
310
+ const char *after_slash = slash + 1;
311
+ while (*after_slash == ' ') after_slash++; // skip spaces
312
+ const char *family_start = after_slash;
313
+ while (*family_start && *family_start != ' ') family_start++;
314
+ while (*family_start == ' ') family_start++; // skip spaces
315
+
316
+ // Extract line-height and trim whitespace
317
+ const char *lh_start = after_slash;
318
+ const char *lh_end = family_start;
319
+ trim_leading(&lh_start, lh_end);
320
+ trim_trailing(lh_start, &lh_end);
321
+ line_height = rb_str_new(lh_start, lh_end - lh_start);
322
+
323
+ // Family is everything after line-height
324
+ if (*family_start) {
325
+ family_part = STR_NEW_CSTR(family_start);
326
+ } else {
327
+ family_part = Qnil;
328
+ }
329
+ } else {
330
+ size_part = value;
331
+ family_part = Qnil;
332
+ }
333
+
334
+ // Split size_part to extract style/variant/weight/size
335
+ VALUE parts = cataract_split_value(self, size_part);
336
+ long len = RARRAY_LEN(parts);
337
+
338
+ VALUE style = Qnil, variant = Qnil, weight = Qnil, size = Qnil, family = family_part;
339
+
340
+ // Font format: [style] [variant] [weight] SIZE [family]
341
+ // SIZE is required and has units or is a keyword
342
+ // Parse to find size first, then work around it
343
+ long size_idx = -1;
344
+ for (long i = 0; i < len; i++) {
345
+ VALUE part = rb_ary_entry(parts, i);
346
+ const char *p = StringValueCStr(part);
347
+ size_t plen = strlen(p);
348
+
349
+ // Check if it's a size keyword
350
+ if (strcmp(p, "small") == 0 || strcmp(p, "medium") == 0 || strcmp(p, "large") == 0 ||
351
+ strcmp(p, "x-small") == 0 || strcmp(p, "x-large") == 0 || strcmp(p, "xx-small") == 0 ||
352
+ strcmp(p, "xx-large") == 0 || strcmp(p, "smaller") == 0 || strcmp(p, "larger") == 0) {
353
+ size_idx = i;
354
+ size = part;
355
+ break;
356
+ }
357
+
358
+ // Check if it ends with a valid CSS unit (not just contains those characters!)
359
+ // Common absolute units: px, pt, pc, cm, mm, in
360
+ // Common relative units: em, ex, rem, ch, vw, vh, vmin, vmax, %
361
+ if (plen >= 2) {
362
+ const char *end = p + plen - 2;
363
+ if (strcmp(end, "px") == 0 || strcmp(end, "pt") == 0 || strcmp(end, "pc") == 0 ||
364
+ strcmp(end, "em") == 0 || strcmp(end, "ex") == 0 || strcmp(end, "cm") == 0 ||
365
+ strcmp(end, "mm") == 0 || strcmp(end, "in") == 0 || strcmp(end, "ch") == 0 ||
366
+ strcmp(end, "vw") == 0 || strcmp(end, "vh") == 0) {
367
+ size_idx = i;
368
+ size = part;
369
+ break;
370
+ }
371
+ }
372
+ if (plen >= 3) {
373
+ const char *end = p + plen - 3;
374
+ if (strcmp(end, "rem") == 0) {
375
+ size_idx = i;
376
+ size = part;
377
+ break;
378
+ }
379
+ }
380
+ if (plen >= 4) {
381
+ const char *end = p + plen - 4;
382
+ if (strcmp(end, "vmin") == 0 || strcmp(end, "vmax") == 0) {
383
+ size_idx = i;
384
+ size = part;
385
+ break;
386
+ }
387
+ }
388
+ // Check for percentage
389
+ if (plen >= 1 && p[plen - 1] == '%') {
390
+ size_idx = i;
391
+ size = part;
392
+ break;
393
+ }
394
+ }
395
+
396
+ // Everything before size is style/variant/weight
397
+ if (size_idx > 0) {
398
+ for (long i = 0; i < size_idx; i++) {
399
+ VALUE part = rb_ary_entry(parts, i);
400
+ const char *p = StringValueCStr(part);
401
+
402
+ // Check if it's a weight
403
+ if (weight == Qnil && (strcmp(p, "bold") == 0 || strcmp(p, "bolder") == 0 ||
404
+ strcmp(p, "lighter") == 0 || strcmp(p, "normal") == 0 ||
405
+ (p[0] >= '1' && p[0] <= '9' && strlen(p) == 3))) {
406
+ weight = part;
407
+ }
408
+ // Check if it's a style
409
+ else if (style == Qnil && (strcmp(p, "italic") == 0 || strcmp(p, "oblique") == 0)) {
410
+ style = part;
411
+ }
412
+ // Check if it's a variant
413
+ else if (variant == Qnil && strcmp(p, "small-caps") == 0) {
414
+ variant = part;
415
+ }
416
+ }
417
+ }
418
+
419
+ // Everything after size is family (if not already extracted from slash parsing)
420
+ if (family == Qnil && size_idx >= 0 && size_idx < len - 1) {
421
+ VALUE family_parts = rb_ary_new();
422
+ for (long i = size_idx + 1; i < len; i++) {
423
+ rb_ary_push(family_parts, rb_ary_entry(parts, i));
424
+ }
425
+ family = rb_ary_join(family_parts, STR_NEW_CSTR(" "));
426
+ }
427
+
428
+ // Set defaults for optional properties
429
+ if (style == Qnil) style = STR_NEW_CSTR("normal");
430
+ if (variant == Qnil) variant = STR_NEW_CSTR("normal");
431
+ if (weight == Qnil) weight = STR_NEW_CSTR("normal");
432
+ if (line_height == Qnil) line_height = STR_NEW_CSTR("normal");
433
+
434
+ rb_hash_aset(result, STR_NEW_CSTR("font-style"), style);
435
+ rb_hash_aset(result, STR_NEW_CSTR("font-variant"), variant);
436
+ rb_hash_aset(result, STR_NEW_CSTR("font-weight"), weight);
437
+ if (size != Qnil) rb_hash_aset(result, STR_NEW_CSTR("font-size"), size);
438
+ rb_hash_aset(result, STR_NEW_CSTR("line-height"), line_height);
439
+ if (family != Qnil) rb_hash_aset(result, STR_NEW_CSTR("font-family"), family);
440
+
441
+ return result;
442
+ }
443
+
444
+ /*
445
+ * Expand list-style shorthand: "square inside"
446
+ */
447
+ VALUE cataract_expand_list_style(VALUE self, VALUE value) {
448
+ VALUE parts = cataract_split_value(self, value);
449
+ long len = RARRAY_LEN(parts);
450
+ VALUE result = rb_hash_new();
451
+
452
+ const char *type_keywords[] = {"disc", "circle", "square", "decimal", "lower-roman", "upper-roman",
453
+ "lower-alpha", "upper-alpha", "none", NULL};
454
+ const char *position_keywords[] = {"inside", "outside", NULL};
455
+
456
+ VALUE type = Qnil, position = Qnil, image = Qnil;
457
+
458
+ for (long i = 0; i < len; i++) {
459
+ VALUE part = rb_ary_entry(parts, i);
460
+ const char *str = StringValueCStr(part);
461
+
462
+ // Check if it's an image (url())
463
+ if (image == Qnil && strncmp(str, "url(", 4) == 0) {
464
+ image = part;
465
+ }
466
+ // Check if it's a position
467
+ else if (position == Qnil) {
468
+ int is_pos = 0;
469
+ for (int j = 0; position_keywords[j]; j++) {
470
+ if (strcmp(str, position_keywords[j]) == 0) {
471
+ position = part;
472
+ is_pos = 1;
473
+ break;
474
+ }
475
+ }
476
+ if (is_pos) continue;
477
+ }
478
+ // Check if it's a type
479
+ if (type == Qnil) {
480
+ for (int j = 0; type_keywords[j]; j++) {
481
+ if (strcmp(str, type_keywords[j]) == 0) {
482
+ type = part;
483
+ break;
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ if (type != Qnil) rb_hash_aset(result, STR_NEW_CSTR("list-style-type"), type);
490
+ if (position != Qnil) rb_hash_aset(result, STR_NEW_CSTR("list-style-position"), position);
491
+ if (image != Qnil) rb_hash_aset(result, STR_NEW_CSTR("list-style-image"), image);
492
+
493
+ return result;
494
+ }
495
+
496
+ /*
497
+ * Expand background shorthand: "url(img.png) no-repeat center / cover"
498
+ * This is complex - background has many sub-properties and / separator for size
499
+ */
500
+ VALUE cataract_expand_background(VALUE self, VALUE value) {
501
+ // First, check if there's a / separator for background-size
502
+ const char *str = StringValueCStr(value);
503
+ const char *slash = strchr(str, '/');
504
+
505
+ VALUE main_part, size_part;
506
+ if (slash) {
507
+ // Split on /: before is position, after is size
508
+ main_part = rb_str_new(str, slash - str);
509
+
510
+ // Trim whitespace from size part
511
+ const char *size_start = slash + 1;
512
+ const char *size_end = str + strlen(str);
513
+ trim_leading(&size_start, size_end);
514
+ trim_trailing(size_start, &size_end);
515
+ size_part = rb_str_new(size_start, size_end - size_start);
516
+ } else {
517
+ main_part = value;
518
+ size_part = Qnil;
519
+ }
520
+
521
+ VALUE parts = cataract_split_value(self, main_part);
522
+ long len = RARRAY_LEN(parts);
523
+ VALUE result = rb_hash_new();
524
+
525
+ // Color keywords (simplified list)
526
+ const char *color_keywords[] = {"red", "blue", "green", "white", "black", "yellow",
527
+ "transparent", "inherit", NULL};
528
+ const char *repeat_keywords[] = {"repeat", "repeat-x", "repeat-y", "no-repeat", NULL};
529
+ const char *attachment_keywords[] = {"scroll", "fixed", NULL};
530
+ const char *position_keywords[] = {"left", "right", "top", "bottom", "center", NULL};
531
+
532
+ VALUE color = Qnil, image = Qnil, repeat = Qnil, attachment = Qnil, size = size_part;
533
+ VALUE position_parts = rb_ary_new(); // Collect all position keywords
534
+
535
+ for (long i = 0; i < len; i++) {
536
+ VALUE part = rb_ary_entry(parts, i);
537
+ const char *str = StringValueCStr(part);
538
+
539
+ // Check for image
540
+ if (image == Qnil && (strncmp(str, "url(", 4) == 0 || strcmp(str, "none") == 0)) {
541
+ image = part;
542
+ }
543
+ // Check for repeat
544
+ else if (repeat == Qnil) {
545
+ for (int j = 0; repeat_keywords[j]; j++) {
546
+ if (strcmp(str, repeat_keywords[j]) == 0) {
547
+ repeat = part;
548
+ goto next_part;
549
+ }
550
+ }
551
+ }
552
+ // Check for attachment
553
+ if (attachment == Qnil) {
554
+ for (int j = 0; attachment_keywords[j]; j++) {
555
+ if (strcmp(str, attachment_keywords[j]) == 0) {
556
+ attachment = part;
557
+ goto next_part;
558
+ }
559
+ }
560
+ }
561
+ // Check for position - collect ALL position keywords
562
+ {
563
+ for (int j = 0; position_keywords[j]; j++) {
564
+ if (strcmp(str, position_keywords[j]) == 0) {
565
+ rb_ary_push(position_parts, part);
566
+ goto next_part;
567
+ }
568
+ }
569
+ }
570
+ // Check for color (hex, rgb, or keyword)
571
+ if (color == Qnil) {
572
+ if (str[0] == '#' || strncmp(str, "rgb", 3) == 0 || strncmp(str, "hsl", 3) == 0) {
573
+ color = part;
574
+ } else {
575
+ for (int j = 0; color_keywords[j]; j++) {
576
+ if (strcmp(str, color_keywords[j]) == 0) {
577
+ color = part;
578
+ break;
579
+ }
580
+ }
581
+ }
582
+ }
583
+
584
+ next_part:;
585
+ }
586
+
587
+ // Join all position parts into a single string if any were found
588
+ VALUE position = Qnil;
589
+ if (RARRAY_LEN(position_parts) > 0) {
590
+ position = rb_ary_join(position_parts, STR_NEW_CSTR(" "));
591
+ }
592
+
593
+ if (color != Qnil) rb_hash_aset(result, STR_NEW_CSTR("background-color"), color);
594
+ if (image != Qnil) rb_hash_aset(result, STR_NEW_CSTR("background-image"), image);
595
+ if (repeat != Qnil) rb_hash_aset(result, STR_NEW_CSTR("background-repeat"), repeat);
596
+ if (attachment != Qnil) rb_hash_aset(result, STR_NEW_CSTR("background-attachment"), attachment);
597
+ if (position != Qnil) rb_hash_aset(result, STR_NEW_CSTR("background-position"), position);
598
+ if (size != Qnil) rb_hash_aset(result, STR_NEW_CSTR("background-size"), size);
599
+
600
+ return result;
601
+ }
602
+
603
+ // ============================================================================
604
+ // SHORTHAND CREATION (Inverse of expansion)
605
+ // ============================================================================
606
+
607
+ // Helper: Create dimension shorthand (margin or padding)
608
+ // Input: hash with "#{base}-top", "#{base}-right", "#{base}-bottom", "#{base}-left"
609
+ // Output: optimized shorthand string, or Qnil if not all sides present
610
+ static VALUE create_dimension_shorthand(VALUE properties, const char *base) {
611
+ char key_top[32], key_right[32], key_bottom[32], key_left[32];
612
+ snprintf(key_top, sizeof(key_top), "%s-top", base);
613
+ snprintf(key_right, sizeof(key_right), "%s-right", base);
614
+ snprintf(key_bottom, sizeof(key_bottom), "%s-bottom", base);
615
+ snprintf(key_left, sizeof(key_left), "%s-left", base);
616
+
617
+ VALUE top = rb_hash_aref(properties, STR_NEW_CSTR(key_top));
618
+ VALUE right = rb_hash_aref(properties, STR_NEW_CSTR(key_right));
619
+ VALUE bottom = rb_hash_aref(properties, STR_NEW_CSTR(key_bottom));
620
+ VALUE left = rb_hash_aref(properties, STR_NEW_CSTR(key_left));
621
+
622
+ // All four sides must be present
623
+ if (NIL_P(top) || NIL_P(right) || NIL_P(bottom) || NIL_P(left)) {
624
+ return Qnil;
625
+ }
626
+
627
+ const char *top_str = StringValueCStr(top);
628
+ const char *right_str = StringValueCStr(right);
629
+ const char *bottom_str = StringValueCStr(bottom);
630
+ const char *left_str = StringValueCStr(left);
631
+
632
+ // Optimize: if all same, use single value
633
+ if (strcmp(top_str, right_str) == 0 &&
634
+ strcmp(top_str, bottom_str) == 0 &&
635
+ strcmp(top_str, left_str) == 0) {
636
+ return rb_str_dup(top);
637
+ }
638
+
639
+ // Optimize: if top==bottom and left==right, use two values
640
+ if (strcmp(top_str, bottom_str) == 0 && strcmp(left_str, right_str) == 0) {
641
+ return rb_sprintf("%s %s", top_str, right_str);
642
+ }
643
+
644
+ // Optimize: if left==right, use three values
645
+ if (strcmp(left_str, right_str) == 0) {
646
+ return rb_sprintf("%s %s %s", top_str, right_str, bottom_str);
647
+ }
648
+
649
+ // All different: use four values
650
+ return rb_sprintf("%s %s %s %s", top_str, right_str, bottom_str, left_str);
651
+ }
652
+
653
+ // Create margin shorthand from longhand properties
654
+ // Input: hash with "margin-top", "margin-right", "margin-bottom", "margin-left"
655
+ // Output: optimized shorthand string, or Qnil if not all sides present
656
+ VALUE cataract_create_margin_shorthand(VALUE self, VALUE properties) {
657
+ return create_dimension_shorthand(properties, "margin");
658
+ }
659
+
660
+ // Create padding shorthand from longhand properties
661
+ VALUE cataract_create_padding_shorthand(VALUE self, VALUE properties) {
662
+ return create_dimension_shorthand(properties, "padding");
663
+ }
664
+
665
+ // Helper: Create border-{width,style,color} shorthand from 4 sides
666
+ // Uses stack allocation and avoids intermediate Ruby string objects for keys
667
+ static VALUE create_border_dimension_shorthand(VALUE properties, const char *suffix) {
668
+ // Build key names on stack: "border-top-{suffix}", etc.
669
+ char key_top[32]; // "border-top-" + suffix + \0
670
+ char key_right[32];
671
+ char key_bottom[32];
672
+ char key_left[32];
673
+
674
+ snprintf(key_top, sizeof(key_top), "border-top-%s", suffix);
675
+ snprintf(key_right, sizeof(key_right), "border-right-%s", suffix);
676
+ snprintf(key_bottom, sizeof(key_bottom), "border-bottom-%s", suffix);
677
+ snprintf(key_left, sizeof(key_left), "border-left-%s", suffix);
678
+
679
+ // Look up values directly with C strings (no intermediate VALUE objects)
680
+ VALUE top = rb_hash_aref(properties, STR_NEW_CSTR(key_top));
681
+ VALUE right = rb_hash_aref(properties, STR_NEW_CSTR(key_right));
682
+ VALUE bottom = rb_hash_aref(properties, STR_NEW_CSTR(key_bottom));
683
+ VALUE left = rb_hash_aref(properties, STR_NEW_CSTR(key_left));
684
+
685
+ // All four sides must be present
686
+ if (NIL_P(top) || NIL_P(right) || NIL_P(bottom) || NIL_P(left)) {
687
+ return Qnil;
688
+ }
689
+
690
+ // Extract C strings directly (no intermediate storage)
691
+ const char *top_str = StringValueCStr(top);
692
+ const char *right_str = StringValueCStr(right);
693
+ const char *bottom_str = StringValueCStr(bottom);
694
+ const char *left_str = StringValueCStr(left);
695
+
696
+ // Optimize: if all same, return single value
697
+ if (strcmp(top_str, right_str) == 0 &&
698
+ strcmp(top_str, bottom_str) == 0 &&
699
+ strcmp(top_str, left_str) == 0) {
700
+ return rb_str_dup(top);
701
+ }
702
+
703
+ // Optimize: if top==bottom and left==right, use two values
704
+ if (strcmp(top_str, bottom_str) == 0 && strcmp(left_str, right_str) == 0) {
705
+ return rb_sprintf("%s %s", top_str, right_str);
706
+ }
707
+
708
+ // Optimize: if left==right, use three values
709
+ if (strcmp(left_str, right_str) == 0) {
710
+ return rb_sprintf("%s %s %s", top_str, right_str, bottom_str);
711
+ }
712
+
713
+ // All different: use four values
714
+ return rb_sprintf("%s %s %s %s", top_str, right_str, bottom_str, left_str);
715
+ }
716
+
717
+ // Create border-width shorthand from individual sides
718
+ VALUE cataract_create_border_width_shorthand(VALUE self, VALUE properties) {
719
+ return create_border_dimension_shorthand(properties, "width");
720
+ }
721
+
722
+ // Create border-style shorthand from individual sides
723
+ VALUE cataract_create_border_style_shorthand(VALUE self, VALUE properties) {
724
+ return create_border_dimension_shorthand(properties, "style");
725
+ }
726
+
727
+ // Create border-color shorthand from individual sides
728
+ VALUE cataract_create_border_color_shorthand(VALUE self, VALUE properties) {
729
+ return create_border_dimension_shorthand(properties, "color");
730
+ }
731
+
732
+ // Create border shorthand from border-width, border-style, border-color
733
+ // Output: combined string, or Qnil if no properties present or if values are multi-value shorthands
734
+ // Note: border shorthand can only have ONE value per component (width, style, color)
735
+ // Cannot combine "border-width: 1px 0" into "border: 1px 0 solid" (invalid CSS)
736
+ VALUE cataract_create_border_shorthand(VALUE self, VALUE properties) {
737
+ VALUE width = rb_hash_aref(properties, STR_NEW_CSTR("border-width"));
738
+ VALUE style = rb_hash_aref(properties, STR_NEW_CSTR("border-style"));
739
+ VALUE color = rb_hash_aref(properties, STR_NEW_CSTR("border-color"));
740
+
741
+ // Per W3C spec, border shorthand requires style at minimum
742
+ // Valid: "border: solid", "border: 1px solid", "border: 1px solid red"
743
+ // Invalid: "border: 1px", "border: red"
744
+ if (NIL_P(style)) {
745
+ return Qnil;
746
+ }
747
+
748
+ // Can't create border shorthand if any value is multi-value (contains spaces)
749
+ // This handles real-world cases like bootstrap.css: border-width: 1px 0;
750
+ if (!NIL_P(width) && strchr(RSTRING_PTR(width), ' ') != NULL) {
751
+ return Qnil;
752
+ }
753
+ if (strchr(RSTRING_PTR(style), ' ') != NULL) {
754
+ return Qnil;
755
+ }
756
+ if (!NIL_P(color) && strchr(RSTRING_PTR(color), ' ') != NULL) {
757
+ return Qnil;
758
+ }
759
+
760
+ VALUE result = STR_NEW_WITH_CAPACITY(64);
761
+ int first = 1;
762
+
763
+ if (!NIL_P(width)) {
764
+ rb_str_append(result, width);
765
+ first = 0;
766
+ }
767
+ // Style is required, always present
768
+ if (!first) rb_str_cat2(result, " ");
769
+ rb_str_append(result, style);
770
+ first = 0;
771
+
772
+ if (!NIL_P(color)) {
773
+ if (!first) rb_str_cat2(result, " ");
774
+ rb_str_append(result, color);
775
+ }
776
+
777
+ return result;
778
+ }
779
+
780
+ // Create background shorthand from longhand properties
781
+ VALUE cataract_create_background_shorthand(VALUE self, VALUE properties) {
782
+ VALUE color = rb_hash_aref(properties, STR_NEW_CSTR("background-color"));
783
+ VALUE image = rb_hash_aref(properties, STR_NEW_CSTR("background-image"));
784
+ VALUE repeat = rb_hash_aref(properties, STR_NEW_CSTR("background-repeat"));
785
+ VALUE position = rb_hash_aref(properties, STR_NEW_CSTR("background-position"));
786
+ VALUE size = rb_hash_aref(properties, STR_NEW_CSTR("background-size"));
787
+
788
+ // Need at least one property
789
+ if (NIL_P(color) && NIL_P(image) && NIL_P(repeat) && NIL_P(position) && NIL_P(size)) {
790
+ return Qnil;
791
+ }
792
+
793
+ VALUE result = STR_NEW_WITH_CAPACITY(128);
794
+ int first = 1;
795
+
796
+ if (!NIL_P(color)) {
797
+ rb_str_append(result, color);
798
+ first = 0;
799
+ }
800
+ if (!NIL_P(image)) {
801
+ if (!first) rb_str_cat2(result, " ");
802
+ rb_str_append(result, image);
803
+ first = 0;
804
+ }
805
+ if (!NIL_P(repeat)) {
806
+ if (!first) rb_str_cat2(result, " ");
807
+ rb_str_append(result, repeat);
808
+ first = 0;
809
+ }
810
+ if (!NIL_P(position)) {
811
+ if (!first) rb_str_cat2(result, " ");
812
+ rb_str_append(result, position);
813
+ first = 0;
814
+ }
815
+ if (!NIL_P(size)) {
816
+ // size needs to be prefixed with /
817
+ if (!first) rb_str_cat2(result, " ");
818
+ rb_str_cat2(result, "/ ");
819
+ rb_str_append(result, size);
820
+ }
821
+
822
+ return result;
823
+ }
824
+
825
+ // Create font shorthand from longhand properties
826
+ // Requires: font-size and font-family
827
+ // Optional: font-style, font-weight, line-height
828
+ VALUE cataract_create_font_shorthand(VALUE self, VALUE properties) {
829
+ VALUE size = rb_hash_aref(properties, STR_NEW_CSTR("font-size"));
830
+ VALUE family = rb_hash_aref(properties, STR_NEW_CSTR("font-family"));
831
+
832
+ // font-size and font-family are required
833
+ if (NIL_P(size) || NIL_P(family)) {
834
+ return Qnil;
835
+ }
836
+
837
+ VALUE style = rb_hash_aref(properties, STR_NEW_CSTR("font-style"));
838
+ VALUE weight = rb_hash_aref(properties, STR_NEW_CSTR("font-weight"));
839
+ VALUE line_height = rb_hash_aref(properties, STR_NEW_CSTR("line-height"));
840
+
841
+ VALUE result = STR_NEW_WITH_CAPACITY(128);
842
+ int has_content = 0;
843
+
844
+ // Order: style weight size/line-height family
845
+ // Skip "normal" for style (it's the default)
846
+ if (!NIL_P(style) && strcmp(RSTRING_PTR(style), "normal") != 0) {
847
+ rb_str_append(result, style);
848
+ has_content = 1;
849
+ }
850
+ if (!NIL_P(weight) && strcmp(RSTRING_PTR(weight), "normal") != 0) {
851
+ if (has_content) rb_str_cat2(result, " ");
852
+ rb_str_append(result, weight);
853
+ has_content = 1;
854
+ }
855
+
856
+ // size is required
857
+ if (has_content) rb_str_cat2(result, " ");
858
+ rb_str_append(result, size);
859
+
860
+ // line-height goes with size using / (skip "normal")
861
+ if (!NIL_P(line_height) && strcmp(RSTRING_PTR(line_height), "normal") != 0) {
862
+ rb_str_cat2(result, "/");
863
+ rb_str_append(result, line_height);
864
+ }
865
+
866
+ // family is required
867
+ rb_str_cat2(result, " ");
868
+ rb_str_append(result, family);
869
+
870
+ return result;
871
+ }
872
+
873
+ // Create list-style shorthand from longhand properties
874
+ VALUE cataract_create_list_style_shorthand(VALUE self, VALUE properties) {
875
+ VALUE type = rb_hash_aref(properties, STR_NEW_CSTR("list-style-type"));
876
+ VALUE position = rb_hash_aref(properties, STR_NEW_CSTR("list-style-position"));
877
+ VALUE image = rb_hash_aref(properties, STR_NEW_CSTR("list-style-image"));
878
+
879
+ // Need at least one property
880
+ if (NIL_P(type) && NIL_P(position) && NIL_P(image)) {
881
+ return Qnil;
882
+ }
883
+
884
+ VALUE result = STR_NEW_WITH_CAPACITY(64);
885
+ int first = 1;
886
+
887
+ if (!NIL_P(type)) {
888
+ rb_str_append(result, type);
889
+ first = 0;
890
+ }
891
+ if (!NIL_P(position)) {
892
+ if (!first) rb_str_cat2(result, " ");
893
+ rb_str_append(result, position);
894
+ first = 0;
895
+ }
896
+ if (!NIL_P(image)) {
897
+ if (!first) rb_str_cat2(result, " ");
898
+ rb_str_append(result, image);
899
+ }
900
+
901
+ return result;
902
+ }