@aihu/css-engine 0.3.0 → 0.4.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 (49) hide show
  1. package/README.md +11 -11
  2. package/crates/aihu-css-core/src/apply.rs +314 -0
  3. package/crates/aihu-css-core/src/bin/main.rs +8 -7
  4. package/crates/aihu-css-core/src/cache.rs +8 -5
  5. package/crates/aihu-css-core/src/emit.rs +110 -30
  6. package/crates/aihu-css-core/src/lib.rs +10 -2
  7. package/crates/aihu-css-core/src/palette.rs +301 -0
  8. package/crates/aihu-css-core/src/style_parser.rs +587 -0
  9. package/crates/aihu-css-core/src/tokens.rs +625 -29
  10. package/crates/aihu-css-core/src/variants.rs +154 -7
  11. package/crates/aihu-css-core/tests/apply.rs +203 -0
  12. package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
  13. package/crates/aihu-css-core/tests/binary_error.rs +61 -0
  14. package/crates/aihu-css-core/tests/cache.rs +8 -8
  15. package/crates/aihu-css-core/tests/emit.rs +95 -36
  16. package/crates/aihu-css-core/tests/parity.rs +274 -0
  17. package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
  18. package/crates/aihu-css-core/tests/scoped_snapshot.rs +49 -11
  19. package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
  20. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
  21. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
  22. package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
  23. package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
  24. package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
  25. package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
  26. package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
  27. package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
  28. package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
  29. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
  30. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
  31. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
  32. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
  33. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +1 -1
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +1 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +1 -0
  37. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +1 -1
  38. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
  39. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
  40. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
  41. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
  42. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
  43. package/crates/aihu-css-core/tests/style_parser.rs +257 -0
  44. package/crates/aihu-css-core/tests/tokens.rs +52 -0
  45. package/dist/index.d.ts +0 -9
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +26 -18
  48. package/dist/index.js.map +1 -1
  49. package/package.json +6 -6
@@ -32,8 +32,13 @@ pub enum Variant {
32
32
  HostContextDark,
33
33
 
34
34
  // ── standard ─────────────────────────────────────────────────────────────
35
- /// `hover:`, `focus:`, `focus-visible:`, `active:`, `disabled:` → `&:<pc>`.
35
+ /// `hover:`, `focus:`, `focus-visible:`, `active:`, `disabled:`, `first:`
36
+ /// (→ `:first-child`), `last:`, `open:` → `&:<pc>`.
36
37
  Pseudo(String),
38
+ /// `marker:` → `&::marker`, `placeholder:` → `&::placeholder`,
39
+ /// `before:`/`after:`/`selection:`/`file:` → `&::<pe>`. Pseudo-*element*
40
+ /// variants attach with a double colon (distinct from [`Variant::Pseudo`]).
41
+ PseudoElement(String),
37
42
  /// `dark:` — dark cascade (NOT `:host-context()`).
38
43
  Dark,
39
44
  /// `sm:`/`md:`/`lg:`/`xl:`/`2xl:` → `@media (min-width: …)`.
@@ -145,13 +150,25 @@ fn parse_prefix(prefix: &str) -> Option<Variant> {
145
150
  "host-context-dark" => Variant::HostContextDark,
146
151
  "slotted" => Variant::Slotted,
147
152
  "dark" => Variant::Dark,
148
- "hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited"
149
- | "checked" => Variant::Pseudo(prefix.to_string()),
153
+ "hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited" | "checked"
154
+ | "required" | "open" => Variant::Pseudo(prefix.to_string()),
155
+ // Structural pseudo-classes map to their `:…-child` / `:nth-child()` form.
156
+ "first" => Variant::Pseudo("first-child".to_string()),
157
+ "last" => Variant::Pseudo("last-child".to_string()),
158
+ "only" => Variant::Pseudo("only-child".to_string()),
159
+ "odd" => Variant::Pseudo("nth-child(odd)".to_string()),
160
+ "even" => Variant::Pseudo("nth-child(even)".to_string()),
161
+ "empty" => Variant::Pseudo("empty".to_string()),
162
+ // Pseudo-elements (double colon).
163
+ "marker" => Variant::PseudoElement("marker".to_string()),
164
+ "placeholder" => Variant::PseudoElement("placeholder".to_string()),
165
+ "before" => Variant::PseudoElement("before".to_string()),
166
+ "after" => Variant::PseudoElement("after".to_string()),
167
+ "selection" => Variant::PseudoElement("selection".to_string()),
168
+ "file" => Variant::PseudoElement("file-selector-button".to_string()),
150
169
  "sm" | "md" | "lg" | "xl" | "2xl" => Variant::Breakpoint(prefix.to_string()),
151
170
  // Container-query breakpoints: `@sm`/`@md`/`@lg`/`@xl`/`@2xl`.
152
- "@sm" | "@md" | "@lg" | "@xl" | "@2xl" => {
153
- Variant::Container(prefix[1..].to_string())
154
- }
171
+ "@sm" | "@md" | "@lg" | "@xl" | "@2xl" => Variant::Container(prefix[1..].to_string()),
155
172
  _ => {
156
173
  if let Some(tag) = prefix.strip_prefix("slotted-") {
157
174
  Variant::SlottedTag(tag.to_string())
@@ -193,11 +210,141 @@ fn peer_state(prefix: &str) -> Option<Option<String>> {
193
210
  fn relational_state(state: &str) -> Option<String> {
194
211
  matches!(
195
212
  state,
196
- "hover" | "focus" | "focus-visible" | "active" | "disabled" | "checked"
213
+ "hover" | "focus" | "focus-visible" | "active" | "disabled" | "checked" | "open"
197
214
  )
198
215
  .then(|| state.to_string())
199
216
  }
200
217
 
218
+ /// How a (possibly empty) variant list resolves against a base selector for the
219
+ /// `@apply` expansion path (`apply.rs`). This is the SHARED structural resolver
220
+ /// (R-APPLY-PARSE): `@apply hover:bg-accent` inside a `.btn { … }` rule must map
221
+ /// to a nested `&:hover { … }` block on the *recipe's own* selector — NOT to a
222
+ /// `.hover\:bg-accent:hover` class rule (Codex confirmed `emit_token` +
223
+ /// string-strip produces wrong output).
224
+ ///
225
+ /// The emitter ([`emit_token`](crate::emit)) starts from a class selector
226
+ /// (`.token`); the `@apply` path starts from `&` (the parent rule). Both walk the
227
+ /// same variant arms, so the per-variant selector transforms live here as
228
+ /// [`apply_variant_to_selector`] and the wrapping/cascade decisions in
229
+ /// [`ResolvedVariants`].
230
+ #[derive(Debug, Clone, PartialEq, Eq)]
231
+ pub struct ResolvedVariants {
232
+ /// The selector the declarations attach to, with each variant applied
233
+ /// against the starting base (`&` for `@apply`). E.g. `&:hover`,
234
+ /// `&[data-state="open"]`, `.group:hover &`.
235
+ pub selector: String,
236
+ /// A wrapping at-rule (`@media (min-width: …)` / `@container (…)`) the rule
237
+ /// nests inside, if a breakpoint/container variant was present.
238
+ pub at_rule: Option<String>,
239
+ /// True when a dark-cascade variant (`dark:`/`host-context-dark:`) was
240
+ /// present — the caller emits the Firefox-safe dark gate instead of a plain
241
+ /// rule.
242
+ pub dark_cascade: bool,
243
+ /// True when any variant implies host/`&`/relational scoping. Used to reject
244
+ /// such variants inside a `$global` `@apply` (Task 1.4 — variants that imply
245
+ /// `&`/host scoping are rejected in `$global`; base utilities allowed).
246
+ pub needs_scope: bool,
247
+ }
248
+
249
+ /// Apply one [`Variant`] to a running selector, mirroring the emitter's arms but
250
+ /// starting from an arbitrary base (`&` for the `@apply` path). Returns the new
251
+ /// selector, plus optional `(at_rule)` / `dark_cascade` / `needs_scope` signals
252
+ /// via the accumulator the caller threads.
253
+ fn apply_variant_to_selector(
254
+ v: &Variant,
255
+ selector: String,
256
+ theme: &crate::theme::ThemeRegistry,
257
+ acc: &mut ResolvedVariants,
258
+ ) -> String {
259
+ match v {
260
+ Variant::Host => {
261
+ acc.needs_scope = true;
262
+ format!(":host({selector})")
263
+ }
264
+ Variant::Slotted => {
265
+ acc.needs_scope = true;
266
+ format!("::slotted({selector})")
267
+ }
268
+ Variant::SlottedTag(tag) => {
269
+ acc.needs_scope = true;
270
+ format!("::slotted({tag}{selector})")
271
+ }
272
+ Variant::Part(name) => {
273
+ acc.needs_scope = true;
274
+ format!("::part({name})")
275
+ }
276
+ Variant::Pseudo(pc) => {
277
+ acc.needs_scope = true;
278
+ format!("{selector}:{pc}")
279
+ }
280
+ Variant::PseudoElement(pe) => {
281
+ acc.needs_scope = true;
282
+ format!("{selector}::{pe}")
283
+ }
284
+ Variant::ArbitrarySelector(sel) => {
285
+ acc.needs_scope = true;
286
+ sel.replace('&', &selector)
287
+ }
288
+ Variant::Group(Some(state)) => {
289
+ acc.needs_scope = true;
290
+ format!(".group:{state} {selector}")
291
+ }
292
+ Variant::Peer(Some(state)) => {
293
+ acc.needs_scope = true;
294
+ format!(".peer:{state} ~ {selector}")
295
+ }
296
+ Variant::Group(None) | Variant::Peer(None) => selector,
297
+ Variant::Aria(m) => {
298
+ acc.needs_scope = true;
299
+ format!("{selector}{}", crate::emit::attr_selector("aria", m))
300
+ }
301
+ Variant::Data(m) => {
302
+ acc.needs_scope = true;
303
+ format!("{selector}{}", crate::emit::attr_selector("data", m))
304
+ }
305
+ Variant::Breakpoint(bp) => {
306
+ if let Some(min) = theme.breakpoint(bp) {
307
+ acc.at_rule = Some(format!("@media (min-width: {min})"));
308
+ }
309
+ selector
310
+ }
311
+ Variant::Container(bp) => {
312
+ if let Some(min) = theme.container_breakpoint(bp) {
313
+ acc.at_rule = Some(format!("@container (min-width: {min})"));
314
+ }
315
+ selector
316
+ }
317
+ Variant::Dark | Variant::HostContextDark => {
318
+ // The dark cascade rewrites `&` to a `:host([data-theme])`/`:root.dark`
319
+ // gate — host/root scope that a `$global` `@apply` cannot express.
320
+ acc.dark_cascade = true;
321
+ acc.needs_scope = true;
322
+ selector
323
+ }
324
+ }
325
+ }
326
+
327
+ /// Resolve a variant list against `base` (`&` for `@apply`) into the structural
328
+ /// selector + wrapping decisions shared by `@apply` expansion.
329
+ pub fn resolve_variants(
330
+ variants: &[Variant],
331
+ base: &str,
332
+ theme: &crate::theme::ThemeRegistry,
333
+ ) -> ResolvedVariants {
334
+ let mut acc = ResolvedVariants {
335
+ selector: base.to_string(),
336
+ at_rule: None,
337
+ dark_cascade: false,
338
+ needs_scope: false,
339
+ };
340
+ let mut selector = base.to_string();
341
+ for v in variants {
342
+ selector = apply_variant_to_selector(v, selector, theme, &mut acc);
343
+ }
344
+ acc.selector = selector;
345
+ acc
346
+ }
347
+
201
348
  /// Parse the payload after `aria-`/`data-` into an [`AttrMatch`].
202
349
  ///
203
350
  /// Two shapes:
@@ -0,0 +1,203 @@
1
+ //! `@apply` expansion edge matrix (Task 1.4 — R-APPLY-PARSE, R-APPLY-TESTS).
2
+ //!
3
+ //! Drives `compile_sfc_scoped` with authored `@style` blocks containing `@apply`
4
+ //! directives and snapshots the folded output. The matrix (per R-APPLY-TESTS):
5
+ //! base; single variant → nested; multi-token `@apply a b c`; multiple `@apply`
6
+ //! per rule; `@apply` in a nested rule; arbitrary-value `bg-[#fff]`; unknown-
7
+ //! utility error; `$global` variant rejection.
8
+
9
+ use aihu_css_core::{compile_sfc_scoped, parse_ast, CompileError, SfcStyleScope};
10
+
11
+ /// Build an SFC AST with the given authored `@style` content + scope. No
12
+ /// template classes so the snapshot is JUST theme tokens + the folded `@style`,
13
+ /// isolating the `@apply` expansion.
14
+ fn sfc_style(content: &str, scope: &str) -> aihu_css_core::SfcAst {
15
+ let json = format!(
16
+ r#"{{"tag":"X","astVersion":1,
17
+ "style":{{"content":{content},"scope":"{scope}"}},
18
+ "meta":{{"name":"X"}},"template":null}}"#,
19
+ content = serde_json::to_string(content).unwrap()
20
+ );
21
+ parse_ast(&json).unwrap()
22
+ }
23
+
24
+ fn scoped(content: &str) -> String {
25
+ compile_sfc_scoped(&sfc_style(content, "scoped")).unwrap()
26
+ }
27
+
28
+ /// Compile via `expand_apply` directly (parser → expand → css) so the snapshot
29
+ /// is just the expanded `@style`, with no theme-token preamble.
30
+ fn expand(content: &str, scope: SfcStyleScope) -> Result<String, CompileError> {
31
+ aihu_css_core::expand_apply(content, scope, &aihu_css_core::ThemeRegistry::with_aihu_defaults())
32
+ }
33
+
34
+ // ── base utility inline ──────────────────────────────────────────────────────
35
+
36
+ #[test]
37
+ fn base_utility_inlines_declarations() {
38
+ let css = expand(".btn { @apply inline-flex items-center; }", SfcStyleScope::Scoped).unwrap();
39
+ insta::assert_snapshot!(css);
40
+ }
41
+
42
+ // ── single variant → nested rule ─────────────────────────────────────────────
43
+
44
+ #[test]
45
+ fn single_variant_lifts_to_nested_rule() {
46
+ // hover: → &:hover { … } on the recipe's OWN selector (NOT a
47
+ // `.hover\:bg-accent:hover` class rule).
48
+ let css = expand(".btn { @apply hover:bg-accent; }", SfcStyleScope::Scoped).unwrap();
49
+ assert!(css.contains("&:hover"), "variant lifts to nested &:hover: {css}");
50
+ assert!(
51
+ !css.contains(r"\:"),
52
+ "must NOT emit an escaped class selector: {css}"
53
+ );
54
+ insta::assert_snapshot!(css);
55
+ }
56
+
57
+ // ── multi-token @apply a b c ─────────────────────────────────────────────────
58
+
59
+ #[test]
60
+ fn multi_token_apply() {
61
+ let css = expand(
62
+ ".btn { @apply inline-flex items-center justify-center rounded-md; }",
63
+ SfcStyleScope::Scoped,
64
+ )
65
+ .unwrap();
66
+ insta::assert_snapshot!(css);
67
+ }
68
+
69
+ // ── multiple @apply per rule ─────────────────────────────────────────────────
70
+
71
+ #[test]
72
+ fn multiple_apply_directives_per_rule() {
73
+ let css = expand(
74
+ ".btn {\n @apply inline-flex;\n @apply rounded-md;\n @apply hover:bg-accent;\n}",
75
+ SfcStyleScope::Scoped,
76
+ )
77
+ .unwrap();
78
+ insta::assert_snapshot!(css);
79
+ }
80
+
81
+ // ── @apply in a nested rule ──────────────────────────────────────────────────
82
+
83
+ #[test]
84
+ fn apply_inside_nested_rule() {
85
+ let css = expand(
86
+ ".card {\n display: grid;\n & .title { @apply font-medium; }\n}",
87
+ SfcStyleScope::Scoped,
88
+ )
89
+ .unwrap();
90
+ assert!(css.contains("font-weight: 500"), "nested @apply inlined: {css}");
91
+ insta::assert_snapshot!(css);
92
+ }
93
+
94
+ // ── arbitrary-value utility ──────────────────────────────────────────────────
95
+
96
+ #[test]
97
+ fn arbitrary_value_utility_in_apply() {
98
+ let css = expand(".swatch { @apply bg-[#fff]; }", SfcStyleScope::Scoped).unwrap();
99
+ assert!(
100
+ css.contains("background-color: #fff"),
101
+ "arbitrary value inlined verbatim: {css}"
102
+ );
103
+ insta::assert_snapshot!(css);
104
+ }
105
+
106
+ #[test]
107
+ fn arbitrary_value_variant_in_apply() {
108
+ let css = expand(".swatch { @apply hover:bg-[#fff]; }", SfcStyleScope::Scoped).unwrap();
109
+ assert!(css.contains("&:hover"));
110
+ assert!(css.contains("background-color: #fff"));
111
+ insta::assert_snapshot!(css);
112
+ }
113
+
114
+ // ── responsive + data + group variants ───────────────────────────────────────
115
+
116
+ #[test]
117
+ fn responsive_variant_wraps_media() {
118
+ let css = expand(".grid { @apply md:flex; }", SfcStyleScope::Scoped).unwrap();
119
+ assert!(css.contains("@media (min-width:"), "md: wraps @media: {css}");
120
+ insta::assert_snapshot!(css);
121
+ }
122
+
123
+ #[test]
124
+ fn data_attribute_variant() {
125
+ let css = expand(
126
+ r#".btn { @apply data-[state=open]:bg-accent; }"#,
127
+ SfcStyleScope::Scoped,
128
+ )
129
+ .unwrap();
130
+ assert!(css.contains(r#"&[data-state="open"]"#), "{css}");
131
+ insta::assert_snapshot!(css);
132
+ }
133
+
134
+ #[test]
135
+ fn dark_variant_cascade_in_apply() {
136
+ let css = expand(".panel { @apply dark:bg-surface; }", SfcStyleScope::Scoped).unwrap();
137
+ assert!(
138
+ !css.contains(":host-context("),
139
+ "Firefox-safe: no :host-context(): {css}"
140
+ );
141
+ assert!(css.contains(r#":host([data-theme="dark"])"#), "{css}");
142
+ assert!(css.contains(":root.dark"), "{css}");
143
+ insta::assert_snapshot!(css);
144
+ }
145
+
146
+ // ── unknown utility error ────────────────────────────────────────────────────
147
+
148
+ #[test]
149
+ fn unknown_utility_is_compile_error() {
150
+ let err = expand(".btn { @apply totally-not-a-utility; }", SfcStyleScope::Scoped)
151
+ .expect_err("unknown utility must hard-error");
152
+ match err {
153
+ CompileError::UnknownApplyUtility { ref token } => {
154
+ assert_eq!(token, "totally-not-a-utility")
155
+ }
156
+ other => panic!("expected UnknownApplyUtility, got {other:?}"),
157
+ }
158
+ assert!(err.to_string().contains("unknown utility in @apply"));
159
+ }
160
+
161
+ #[test]
162
+ fn unknown_utility_propagates_through_compile_sfc_scoped() {
163
+ let err = compile_sfc_scoped(&sfc_style(".btn { @apply nope-nope; }", "scoped"))
164
+ .expect_err("must propagate");
165
+ assert!(matches!(err, CompileError::UnknownApplyUtility { .. }));
166
+ }
167
+
168
+ // ── $global variant rejection ────────────────────────────────────────────────
169
+
170
+ #[test]
171
+ fn global_apply_rejects_scope_implying_variant() {
172
+ let err = expand("body { @apply hover:bg-accent; }", SfcStyleScope::Global)
173
+ .expect_err("$global may not use a scope-implying variant");
174
+ match err {
175
+ CompileError::GlobalApplyVariant { ref token } => assert_eq!(token, "hover:bg-accent"),
176
+ other => panic!("expected GlobalApplyVariant, got {other:?}"),
177
+ }
178
+ }
179
+
180
+ #[test]
181
+ fn global_apply_allows_base_utility() {
182
+ // Base utilities are fine in $global — they inline as flat declarations.
183
+ let css = expand("body { @apply inline-flex; }", SfcStyleScope::Global).unwrap();
184
+ assert!(css.contains("display: inline-flex"), "{css}");
185
+ }
186
+
187
+ #[test]
188
+ fn global_apply_allows_media_variant() {
189
+ // A breakpoint variant implies no host/`&` scope (it only wraps in @media),
190
+ // so it is allowed in $global.
191
+ let css = expand("body { @apply md:flex; }", SfcStyleScope::Global).unwrap();
192
+ assert!(css.contains("@media (min-width:"), "{css}");
193
+ }
194
+
195
+ // ── end-to-end through compile_sfc_scoped (theme preamble included) ───────────
196
+
197
+ #[test]
198
+ fn end_to_end_scoped_with_theme_preamble() {
199
+ let css = scoped(".btn { @apply inline-flex hover:bg-accent; }");
200
+ assert!(css.contains(":host {"), "theme tokens present: {css}");
201
+ assert!(css.contains("display: inline-flex"));
202
+ assert!(css.contains("&:hover"));
203
+ }
@@ -0,0 +1,150 @@
1
+ //! R-NO-PREMIGRATION-BREAK (CRITICAL): every current `packages/ui/registry/*`
2
+ //! recipe's authored `@style` block must pass through `@apply` expansion with no
3
+ //! hard-error. If a recipe uses a utility token the table does not cover, this
4
+ //! test fails — and the fix is to cover the token in `tokens.rs` (or fix the
5
+ //! recipe) BEFORE enabling expansion, so turning `@apply` on does not break the
6
+ //! shipped recipes.
7
+ //!
8
+ //! The test reads the `.aihu` SFC files directly (relative to the crate, two
9
+ //! levels up to the repo root), extracts each `@style { … }` block body with a
10
+ //! brace-matched scan, strips `@theme` blocks (handled separately by the
11
+ //! emitter), and runs `expand_apply` on the remainder.
12
+
13
+ use std::path::PathBuf;
14
+
15
+ use aihu_css_core::{expand_apply, SfcStyleScope, ThemeRegistry};
16
+
17
+ /// The repo root, derived from this crate's manifest dir
18
+ /// (`<root>/packages/css-engine/crates/aihu-css-core`).
19
+ fn repo_root() -> PathBuf {
20
+ let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
21
+ for _ in 0..4 {
22
+ p.pop();
23
+ }
24
+ p
25
+ }
26
+
27
+ /// Extract the body inside `@style { … }` (brace-matched), or `None` if the file
28
+ /// has no `@style` block. `$global @style` is not used by the current recipes,
29
+ /// but we treat any `@style` block as scoped for this regression (the only
30
+ /// concern is whether tokens RESOLVE, which is scope-independent for base
31
+ /// utilities; variant tokens resolve identically — only the $global *rejection*
32
+ /// path differs, and none of these recipes use $global).
33
+ fn extract_style_body(src: &str) -> Option<String> {
34
+ // Find the `@style` BLOCK opener — `@style` followed only by whitespace then
35
+ // `{`. (A naive `find("@style")` would match the word inside a comment such
36
+ // as "see @style below"; the recipes contain exactly that.)
37
+ let mut search = 0usize;
38
+ let (at, after) = loop {
39
+ let rel = src[search..].find("@style")?;
40
+ let at = search + rel;
41
+ let after = &src[at + "@style".len()..];
42
+ let trimmed = after.trim_start();
43
+ if trimmed.starts_with('{') {
44
+ break (at, after);
45
+ }
46
+ search = at + "@style".len();
47
+ };
48
+ let _ = at;
49
+ let open = after.find('{')?;
50
+ let body_start = open + 1;
51
+ let bytes = after.as_bytes();
52
+ let mut depth = 1i32;
53
+ let mut i = body_start;
54
+ while i < bytes.len() {
55
+ match bytes[i] {
56
+ b'{' => depth += 1,
57
+ b'}' => {
58
+ depth -= 1;
59
+ if depth == 0 {
60
+ return Some(after[body_start..i].to_string());
61
+ }
62
+ }
63
+ _ => {}
64
+ }
65
+ i += 1;
66
+ }
67
+ None
68
+ }
69
+
70
+ /// Remove `@theme { … }` blocks (brace-matched) — the emitter strips these
71
+ /// before folding, so the `@apply` pass never sees them.
72
+ fn strip_theme(body: &str) -> String {
73
+ let mut out = String::new();
74
+ let mut rest = body;
75
+ while let Some(at) = rest.find("@theme") {
76
+ out.push_str(&rest[..at]);
77
+ let after = &rest[at + "@theme".len()..];
78
+ let Some(open) = after.find('{') else {
79
+ out.push_str(after);
80
+ return out;
81
+ };
82
+ let bytes = after.as_bytes();
83
+ let mut depth = 1i32;
84
+ let mut i = open + 1;
85
+ while i < bytes.len() {
86
+ match bytes[i] {
87
+ b'{' => depth += 1,
88
+ b'}' => {
89
+ depth -= 1;
90
+ if depth == 0 {
91
+ break;
92
+ }
93
+ }
94
+ _ => {}
95
+ }
96
+ i += 1;
97
+ }
98
+ rest = &after[i + 1..];
99
+ }
100
+ out.push_str(rest);
101
+ out
102
+ }
103
+
104
+ #[test]
105
+ fn every_registry_recipe_apply_resolves() {
106
+ let registry = repo_root().join("packages/ui/registry");
107
+ assert!(
108
+ registry.is_dir(),
109
+ "registry dir not found at {}",
110
+ registry.display()
111
+ );
112
+
113
+ let theme = ThemeRegistry::with_aihu_defaults();
114
+ let mut checked = 0usize;
115
+
116
+ for entry in std::fs::read_dir(&registry).unwrap() {
117
+ let dir = entry.unwrap().path();
118
+ if !dir.is_dir() {
119
+ continue;
120
+ }
121
+ for file in std::fs::read_dir(&dir).unwrap() {
122
+ let path = file.unwrap().path();
123
+ if path.extension().and_then(|s| s.to_str()) != Some("aihu") {
124
+ continue;
125
+ }
126
+ let src = std::fs::read_to_string(&path).unwrap();
127
+ let Some(body) = extract_style_body(&src) else {
128
+ continue;
129
+ };
130
+ if !body.contains("@apply") {
131
+ // No @apply → nothing to expand, but still assert it parses.
132
+ continue;
133
+ }
134
+ let stripped = strip_theme(&body);
135
+ let result = expand_apply(&stripped, SfcStyleScope::Scoped, &theme);
136
+ assert!(
137
+ result.is_ok(),
138
+ "recipe {} @apply expansion failed: {:?}",
139
+ path.display(),
140
+ result.err()
141
+ );
142
+ checked += 1;
143
+ }
144
+ }
145
+
146
+ assert!(
147
+ checked > 0,
148
+ "expected at least one recipe with @apply (button) to be checked"
149
+ );
150
+ }
@@ -0,0 +1,61 @@
1
+ //! Binary-level error propagation (R-RESULT): an induced emit error must exit
2
+ //! the `aihu-css-compile` binary non-zero AND print the message to stderr.
3
+ //! Cargo sets `CARGO_BIN_EXE_aihu-css-compile` for this integration test.
4
+
5
+ use std::io::Write;
6
+ use std::process::{Command, Stdio};
7
+
8
+ fn run_ast_mode(ast_json: &str) -> std::process::Output {
9
+ let bin = env!("CARGO_BIN_EXE_aihu-css-compile");
10
+ let mut child = Command::new(bin)
11
+ .arg("--ast-json")
12
+ .stdin(Stdio::piped())
13
+ .stdout(Stdio::piped())
14
+ .stderr(Stdio::piped())
15
+ .spawn()
16
+ .expect("spawn aihu-css-compile");
17
+ child
18
+ .stdin
19
+ .as_mut()
20
+ .expect("stdin")
21
+ .write_all(ast_json.as_bytes())
22
+ .expect("write stdin");
23
+ child.wait_with_output().expect("wait")
24
+ }
25
+
26
+ #[test]
27
+ fn malformed_theme_exits_nonzero_with_stderr_message() {
28
+ // `@theme` with no `{` body → CompileError::MalformedTheme.
29
+ let json = r#"{"tag":"X","astVersion":1,
30
+ "style":{"content":"@theme --color-primary: red;","scope":"scoped"},
31
+ "meta":{"name":"X"},"template":null}"#;
32
+ let out = run_ast_mode(json);
33
+ assert!(
34
+ !out.status.success(),
35
+ "binary must exit non-zero on emit error; status={:?}",
36
+ out.status
37
+ );
38
+ let stderr = String::from_utf8_lossy(&out.stderr);
39
+ assert!(
40
+ stderr.contains("aihu-css-compile:") && stderr.contains("malformed @theme"),
41
+ "stderr must carry the actionable message, got: {stderr:?}"
42
+ );
43
+ assert!(
44
+ out.stdout.is_empty(),
45
+ "no CSS should be written on error; stdout={:?}",
46
+ String::from_utf8_lossy(&out.stdout)
47
+ );
48
+ }
49
+
50
+ #[test]
51
+ fn well_formed_input_exits_zero_and_emits_css() {
52
+ let json = r#"{"tag":"X","astVersion":1,
53
+ "style":{"content":"@theme { --color-primary: red; }","scope":"scoped"},
54
+ "meta":{"name":"X"},
55
+ "template":[{"kind":"element","tag":"div","attrs":[
56
+ {"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
57
+ let out = run_ast_mode(json);
58
+ assert!(out.status.success(), "well-formed input exits zero");
59
+ let css = String::from_utf8_lossy(&out.stdout);
60
+ assert!(css.contains("background-color: var(--color-primary)"), "{css}");
61
+ }
@@ -20,11 +20,11 @@ fn hit_returns_identical_output_and_skips_recompile() {
20
20
  let mut cache = CssCache::new();
21
21
  let ast = sfc("bg-primary p-4");
22
22
 
23
- let first = cache.compile(&ast, 1);
23
+ let first = cache.compile(&ast, 1).unwrap();
24
24
  assert_eq!(cache.recompiles(), 1, "first compile is a miss");
25
25
  assert_eq!(cache.hits(), 0);
26
26
 
27
- let second = cache.compile(&ast, 1);
27
+ let second = cache.compile(&ast, 1).unwrap();
28
28
  assert_eq!(second, first, "cache hit returns byte-identical output");
29
29
  assert_eq!(cache.recompiles(), 1, "second compile must NOT recompile");
30
30
  assert_eq!(cache.hits(), 1, "second compile is a hit");
@@ -33,8 +33,8 @@ fn hit_returns_identical_output_and_skips_recompile() {
33
33
  #[test]
34
34
  fn ast_change_invalidates_entry() {
35
35
  let mut cache = CssCache::new();
36
- cache.compile(&sfc("p-4"), 1);
37
- cache.compile(&sfc("p-8"), 1); // different class → different hash
36
+ cache.compile(&sfc("p-4"), 1).unwrap();
37
+ cache.compile(&sfc("p-8"), 1).unwrap(); // different class → different hash
38
38
  assert_eq!(cache.recompiles(), 2, "a changed AST recompiles");
39
39
  assert_eq!(cache.hits(), 0);
40
40
  }
@@ -43,8 +43,8 @@ fn ast_change_invalidates_entry() {
43
43
  fn theme_version_change_invalidates_entry() {
44
44
  let mut cache = CssCache::new();
45
45
  let ast = sfc("bg-primary");
46
- cache.compile(&ast, 1);
47
- cache.compile(&ast, 2); // theme bumped → invalidate
46
+ cache.compile(&ast, 1).unwrap();
47
+ cache.compile(&ast, 2).unwrap(); // theme bumped → invalidate
48
48
  assert_eq!(cache.recompiles(), 2, "a theme-version bump recompiles");
49
49
  }
50
50
 
@@ -53,12 +53,12 @@ fn cache_hit_is_well_under_30ms() {
53
53
  let mut cache = CssCache::new();
54
54
  // Warm the cache.
55
55
  let ast = sfc("bg-primary p-4 rounded-lg hover:bg-accent md:p-8 host:text-primary");
56
- let _ = cache.compile(&ast, 1);
56
+ let _ = cache.compile(&ast, 1).unwrap();
57
57
 
58
58
  // Time 1000 cache hits; each must be far below the 30 ms per-SFC bar.
59
59
  let start = Instant::now();
60
60
  for _ in 0..1000 {
61
- let _ = cache.compile(&ast, 1);
61
+ let _ = cache.compile(&ast, 1).unwrap();
62
62
  }
63
63
  let elapsed = start.elapsed();
64
64
  let per_hit = elapsed / 1000;