@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.
- package/README.md +11 -11
- package/crates/aihu-css-core/src/apply.rs +314 -0
- package/crates/aihu-css-core/src/bin/main.rs +8 -7
- package/crates/aihu-css-core/src/cache.rs +8 -5
- package/crates/aihu-css-core/src/emit.rs +110 -30
- package/crates/aihu-css-core/src/lib.rs +10 -2
- package/crates/aihu-css-core/src/palette.rs +301 -0
- package/crates/aihu-css-core/src/style_parser.rs +587 -0
- package/crates/aihu-css-core/src/tokens.rs +625 -29
- package/crates/aihu-css-core/src/variants.rs +154 -7
- package/crates/aihu-css-core/tests/apply.rs +203 -0
- package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
- package/crates/aihu-css-core/tests/binary_error.rs +61 -0
- package/crates/aihu-css-core/tests/cache.rs +8 -8
- package/crates/aihu-css-core/tests/emit.rs +95 -36
- package/crates/aihu-css-core/tests/parity.rs +274 -0
- package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +49 -11
- package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
- package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +1 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +1 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
- package/crates/aihu-css-core/tests/style_parser.rs +257 -0
- package/crates/aihu-css-core/tests/tokens.rs +52 -0
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -18
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
//! `style_parser.rs` — structured `@style`-rule parser (R-SHARED-PARSER).
|
|
2
|
+
//!
|
|
3
|
+
//! Parses an authored `@style` block (the CSS body that `emit_sfc_scoped` folds
|
|
4
|
+
//! into the shadow `<style>`, minus `@theme` directives) into a structured rule
|
|
5
|
+
//! tree. This is the SINGLE source of `@style` structure reused by two passes:
|
|
6
|
+
//!
|
|
7
|
+
//! - **`@apply` expansion** (`apply.rs`, T3): each rule's `@apply` directives are
|
|
8
|
+
//! replaced — base utilities inline as declarations, variant tokens lift to
|
|
9
|
+
//! nested selectors.
|
|
10
|
+
//! - **Variant validation** (`validate.rs`, PR-3): each rule's selector context
|
|
11
|
+
//! is checked against the declared `@meta.variants` axes.
|
|
12
|
+
//!
|
|
13
|
+
//! Codex flagged a naive string scanner as a trap: arbitrary-value utilities
|
|
14
|
+
//! (`bg-[#fff]`), `;` inside `url(...)`/string values, `:` inside selectors and
|
|
15
|
+
//! values, and braces inside comments/strings must NOT break tokenization. So
|
|
16
|
+
//! this is a real comment-aware, string-aware, brace-nesting-aware parse — not a
|
|
17
|
+
//! `split(';')`/`split('{')` scanner.
|
|
18
|
+
//!
|
|
19
|
+
//! ## Rule tree shape
|
|
20
|
+
//!
|
|
21
|
+
//! ```text
|
|
22
|
+
//! @style {
|
|
23
|
+
//! .a, .b { ← StyleRule { selector: ".a, .b", … }
|
|
24
|
+
//! color: red; ← declarations: [Declaration { prop: "color", value: "red" }]
|
|
25
|
+
//! @apply p-4 m-2; ← applies: [ApplyDirective { tokens: ["p-4", "m-2"] }]
|
|
26
|
+
//! @media (...) { ← nested: [StyleNode::AtRule(AtRule { prelude, body })]
|
|
27
|
+
//! .a { … }
|
|
28
|
+
//! }
|
|
29
|
+
//! }
|
|
30
|
+
//! }
|
|
31
|
+
//! ```
|
|
32
|
+
//!
|
|
33
|
+
//! `StyleSheet` is the top-level list of [`StyleNode`]s (a rule, a bare at-rule,
|
|
34
|
+
//! or — for full fidelity round-tripping — verbatim leading/trailing text such
|
|
35
|
+
//! as a stray declaration outside any rule, which authored `@style` may contain).
|
|
36
|
+
|
|
37
|
+
/// A single `prop: value` declaration inside a rule body.
|
|
38
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
39
|
+
pub struct Declaration {
|
|
40
|
+
/// The property name, trimmed (`color`, `--my-var`, `background`).
|
|
41
|
+
pub prop: String,
|
|
42
|
+
/// The value text, trimmed, WITHOUT the trailing `;`. May itself contain
|
|
43
|
+
/// `:`, `;`-in-`url()`, commas, parentheses, and quoted strings.
|
|
44
|
+
pub value: String,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// An `@apply <tokens>;` directive captured inside a rule body. The tokens are
|
|
48
|
+
/// the whitespace-separated utility class names (variant prefixes intact, e.g.
|
|
49
|
+
/// `hover:bg-accent`, `bg-[#fff]`), exactly as the scanner/emitter expect.
|
|
50
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
51
|
+
pub struct ApplyDirective {
|
|
52
|
+
pub tokens: Vec<String>,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// A style rule: a selector prelude plus a body of declarations, `@apply`
|
|
56
|
+
/// directives, and nested nodes (nested rules or nested at-rules).
|
|
57
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
58
|
+
pub struct StyleRule {
|
|
59
|
+
/// The raw selector text (everything before the `{`), trimmed. May be a
|
|
60
|
+
/// selector list (`.a, .b`) and contain `:is(...)`, attribute selectors,
|
|
61
|
+
/// pseudo-classes, combinators, and nesting `&`.
|
|
62
|
+
pub selector: String,
|
|
63
|
+
/// `prop: value` declarations directly in this rule's body, in source order.
|
|
64
|
+
pub declarations: Vec<Declaration>,
|
|
65
|
+
/// `@apply` directives directly in this rule's body, in source order.
|
|
66
|
+
pub applies: Vec<ApplyDirective>,
|
|
67
|
+
/// Nested nodes (nested rules / nested at-rules) in this rule's body.
|
|
68
|
+
pub nested: Vec<StyleNode>,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// A nested or bare at-rule (`@media`, `@supports`, `@container`, …) with a
|
|
72
|
+
/// prelude and a brace body of further [`StyleNode`]s.
|
|
73
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
74
|
+
pub struct AtRule {
|
|
75
|
+
/// The at-rule name including `@` (`@media`, `@supports`, `@container`).
|
|
76
|
+
pub name: String,
|
|
77
|
+
/// The prelude between the name and the `{` (`(min-width: 600px)`), trimmed.
|
|
78
|
+
pub prelude: String,
|
|
79
|
+
/// The body nodes inside the braces.
|
|
80
|
+
pub body: Vec<StyleNode>,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// A statement-level at-rule with no brace body (`@import url(...);`,
|
|
84
|
+
/// `@charset "utf-8";`). Captured verbatim so round-tripping is lossless.
|
|
85
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
86
|
+
pub struct AtStatement {
|
|
87
|
+
/// The full statement text WITHOUT the trailing `;` (`@import url("a.css")`).
|
|
88
|
+
pub text: String,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// A node in the `@style` tree.
|
|
92
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
93
|
+
pub enum StyleNode {
|
|
94
|
+
/// A style rule (`selector { … }`).
|
|
95
|
+
Rule(StyleRule),
|
|
96
|
+
/// A nested/bare at-rule with a brace body (`@media (...) { … }`).
|
|
97
|
+
AtRule(AtRule),
|
|
98
|
+
/// A statement at-rule with no body (`@import …;`).
|
|
99
|
+
AtStatement(AtStatement),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// The parsed `@style` block: an ordered list of top-level nodes.
|
|
103
|
+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
104
|
+
pub struct StyleSheet {
|
|
105
|
+
pub nodes: Vec<StyleNode>,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// An error raised while parsing a `@style` block — an unbalanced brace, an
|
|
109
|
+
/// unterminated comment, or an unterminated string. Surfaced as a structured
|
|
110
|
+
/// error so callers (`@apply`, validation) can convert it into a `CompileError`.
|
|
111
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
112
|
+
pub enum StyleParseError {
|
|
113
|
+
/// A `{` with no matching `}` (or vice versa) by end of input.
|
|
114
|
+
UnbalancedBraces,
|
|
115
|
+
/// A `/* … */` comment that never closed.
|
|
116
|
+
UnterminatedComment,
|
|
117
|
+
/// A `"…"` or `'…'` string that never closed.
|
|
118
|
+
UnterminatedString,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
impl std::fmt::Display for StyleParseError {
|
|
122
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
123
|
+
let msg = match self {
|
|
124
|
+
StyleParseError::UnbalancedBraces => "unbalanced braces in @style block",
|
|
125
|
+
StyleParseError::UnterminatedComment => "unterminated comment in @style block",
|
|
126
|
+
StyleParseError::UnterminatedString => "unterminated string in @style block",
|
|
127
|
+
};
|
|
128
|
+
f.write_str(msg)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
impl std::error::Error for StyleParseError {}
|
|
133
|
+
|
|
134
|
+
/// Parse an authored `@style` body into a structured [`StyleSheet`].
|
|
135
|
+
///
|
|
136
|
+
/// Input is the raw CSS text inside the `@style { … }` block (the same text
|
|
137
|
+
/// `emit_sfc_scoped` folds), already stripped of `@theme` directives. Output is
|
|
138
|
+
/// the rule tree; [`StyleSheet::to_css`] round-trips it back to equivalent CSS.
|
|
139
|
+
pub fn parse_style(input: &str) -> Result<StyleSheet, StyleParseError> {
|
|
140
|
+
let mut parser = Parser::new(input);
|
|
141
|
+
let nodes = parser.parse_nodes(/* top_level = */ true)?;
|
|
142
|
+
Ok(StyleSheet { nodes })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// Cursor over the input that is aware of comments and strings so structural
|
|
146
|
+
/// characters (`{`, `}`, `;`, `:`) inside them are never treated as syntax.
|
|
147
|
+
struct Parser<'a> {
|
|
148
|
+
src: &'a str,
|
|
149
|
+
bytes: &'a [u8],
|
|
150
|
+
pos: usize,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
impl<'a> Parser<'a> {
|
|
154
|
+
fn new(src: &'a str) -> Self {
|
|
155
|
+
Self {
|
|
156
|
+
src,
|
|
157
|
+
bytes: src.as_bytes(),
|
|
158
|
+
pos: 0,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Parse a sequence of nodes until either end of input (`top_level`) or an
|
|
163
|
+
/// unmatched `}` that closes the enclosing block (consumed by the caller).
|
|
164
|
+
fn parse_nodes(&mut self, top_level: bool) -> Result<Vec<StyleNode>, StyleParseError> {
|
|
165
|
+
let mut nodes = Vec::new();
|
|
166
|
+
loop {
|
|
167
|
+
self.skip_trivia()?;
|
|
168
|
+
if self.pos >= self.bytes.len() {
|
|
169
|
+
if top_level {
|
|
170
|
+
return Ok(nodes);
|
|
171
|
+
}
|
|
172
|
+
// Reached EOF inside a block with no closing brace.
|
|
173
|
+
return Err(StyleParseError::UnbalancedBraces);
|
|
174
|
+
}
|
|
175
|
+
if self.bytes[self.pos] == b'}' {
|
|
176
|
+
if top_level {
|
|
177
|
+
// A stray `}` at top level is unbalanced.
|
|
178
|
+
return Err(StyleParseError::UnbalancedBraces);
|
|
179
|
+
}
|
|
180
|
+
// Leave the `}` for the caller to consume.
|
|
181
|
+
return Ok(nodes);
|
|
182
|
+
}
|
|
183
|
+
if let Some(node) = self.parse_statement()? {
|
|
184
|
+
nodes.push(node);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/// Parse one statement: a declaration, an `@apply`/at-statement, an at-rule
|
|
190
|
+
/// with a body, or a style rule. Returns `None` for an empty `;`.
|
|
191
|
+
fn parse_statement(&mut self) -> Result<Option<StyleNode>, StyleParseError> {
|
|
192
|
+
// Scan the "prelude" (everything up to the next top-level `{`, `;`, or
|
|
193
|
+
// `}`) while honouring comments/strings/nested brackets/parens.
|
|
194
|
+
let start = self.pos;
|
|
195
|
+
let terminator = self.scan_to_terminator()?;
|
|
196
|
+
let prelude = self.src[start..self.pos].trim().to_string();
|
|
197
|
+
|
|
198
|
+
match terminator {
|
|
199
|
+
Terminator::Brace => {
|
|
200
|
+
// `prelude {` — a rule or a body-bearing at-rule.
|
|
201
|
+
self.pos += 1; // consume `{`
|
|
202
|
+
let body = self.parse_nodes(false)?;
|
|
203
|
+
// consume the matching `}`
|
|
204
|
+
if self.pos >= self.bytes.len() || self.bytes[self.pos] != b'}' {
|
|
205
|
+
return Err(StyleParseError::UnbalancedBraces);
|
|
206
|
+
}
|
|
207
|
+
self.pos += 1;
|
|
208
|
+
|
|
209
|
+
if prelude.starts_with('@') {
|
|
210
|
+
let (name, rest) = split_at_name(&prelude);
|
|
211
|
+
Ok(Some(StyleNode::AtRule(AtRule {
|
|
212
|
+
name,
|
|
213
|
+
prelude: rest,
|
|
214
|
+
body,
|
|
215
|
+
})))
|
|
216
|
+
} else {
|
|
217
|
+
Ok(Some(StyleNode::Rule(build_rule(prelude, body))))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
Terminator::Semicolon | Terminator::Eof => {
|
|
221
|
+
// A statement ending in `;` (or trailing at EOF with no `;`).
|
|
222
|
+
if terminator == Terminator::Semicolon {
|
|
223
|
+
self.pos += 1; // consume `;`
|
|
224
|
+
}
|
|
225
|
+
if prelude.is_empty() {
|
|
226
|
+
return Ok(None);
|
|
227
|
+
}
|
|
228
|
+
if prelude.starts_with('@') {
|
|
229
|
+
// A statement at-rule (e.g. `@import …`). `@apply` is handled
|
|
230
|
+
// inside `build_rule` when it appears in a rule body; a
|
|
231
|
+
// top-level `@apply` (outside any rule) is unusual but we
|
|
232
|
+
// keep it as an at-statement so nothing is lost.
|
|
233
|
+
Ok(Some(StyleNode::AtStatement(AtStatement { text: prelude })))
|
|
234
|
+
} else if let Some((prop, value)) = split_declaration(&prelude) {
|
|
235
|
+
// A bare declaration outside a rule — authored `@style` may
|
|
236
|
+
// legitimately contain custom-property declarations at the
|
|
237
|
+
// block root. Wrap it in a selector-less rule so it round-
|
|
238
|
+
// trips, but the common in-rule case is handled in
|
|
239
|
+
// `build_rule`.
|
|
240
|
+
Ok(Some(StyleNode::Rule(StyleRule {
|
|
241
|
+
selector: String::new(),
|
|
242
|
+
declarations: vec![Declaration { prop, value }],
|
|
243
|
+
applies: Vec::new(),
|
|
244
|
+
nested: Vec::new(),
|
|
245
|
+
})))
|
|
246
|
+
} else {
|
|
247
|
+
// Unrecognized statement (no `:`): keep verbatim as an
|
|
248
|
+
// at-statement-like node only if it began with `@`; otherwise
|
|
249
|
+
// drop empty noise. Here it is non-`@`, non-declaration text
|
|
250
|
+
// — preserve as a selector-less rule with the raw text as a
|
|
251
|
+
// single "declaration"-less marker is lossy, so keep it as a
|
|
252
|
+
// rule selector to round-trip.
|
|
253
|
+
Ok(Some(StyleNode::Rule(StyleRule {
|
|
254
|
+
selector: prelude,
|
|
255
|
+
declarations: Vec::new(),
|
|
256
|
+
applies: Vec::new(),
|
|
257
|
+
nested: Vec::new(),
|
|
258
|
+
})))
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
Terminator::CloseBrace => {
|
|
262
|
+
// Hit `}` while scanning a prelude — a trailing fragment before
|
|
263
|
+
// the block closes (e.g. a final declaration with no `;`).
|
|
264
|
+
if prelude.is_empty() {
|
|
265
|
+
return Ok(None);
|
|
266
|
+
}
|
|
267
|
+
if let Some((prop, value)) = split_declaration(&prelude) {
|
|
268
|
+
Ok(Some(StyleNode::Rule(StyleRule {
|
|
269
|
+
selector: String::new(),
|
|
270
|
+
declarations: vec![Declaration { prop, value }],
|
|
271
|
+
applies: Vec::new(),
|
|
272
|
+
nested: Vec::new(),
|
|
273
|
+
})))
|
|
274
|
+
} else if prelude.starts_with('@') {
|
|
275
|
+
Ok(Some(StyleNode::AtStatement(AtStatement { text: prelude })))
|
|
276
|
+
} else {
|
|
277
|
+
Ok(Some(StyleNode::Rule(StyleRule {
|
|
278
|
+
selector: prelude,
|
|
279
|
+
declarations: Vec::new(),
|
|
280
|
+
applies: Vec::new(),
|
|
281
|
+
nested: Vec::new(),
|
|
282
|
+
})))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Advance to the next top-level structural terminator (`{`, `;`, or `}`),
|
|
289
|
+
/// stepping over comments, strings, `(...)`, and `[...]` so their inner
|
|
290
|
+
/// `{`/`;`/`:` never count. Leaves `self.pos` AT the terminator.
|
|
291
|
+
fn scan_to_terminator(&mut self) -> Result<Terminator, StyleParseError> {
|
|
292
|
+
while self.pos < self.bytes.len() {
|
|
293
|
+
let b = self.bytes[self.pos];
|
|
294
|
+
match b {
|
|
295
|
+
b'/' if self.peek(1) == Some(b'*') => self.skip_block_comment()?,
|
|
296
|
+
b'"' | b'\'' => self.skip_string(b)?,
|
|
297
|
+
b'(' => self.skip_balanced(b'(', b')')?,
|
|
298
|
+
b'[' => self.skip_balanced(b'[', b']')?,
|
|
299
|
+
b'{' => return Ok(Terminator::Brace),
|
|
300
|
+
b';' => return Ok(Terminator::Semicolon),
|
|
301
|
+
b'}' => return Ok(Terminator::CloseBrace),
|
|
302
|
+
_ => self.pos += 1,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
Ok(Terminator::Eof)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// Skip whitespace and comments (used between statements).
|
|
309
|
+
fn skip_trivia(&mut self) -> Result<(), StyleParseError> {
|
|
310
|
+
loop {
|
|
311
|
+
while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
|
|
312
|
+
self.pos += 1;
|
|
313
|
+
}
|
|
314
|
+
if self.pos < self.bytes.len()
|
|
315
|
+
&& self.bytes[self.pos] == b'/'
|
|
316
|
+
&& self.peek(1) == Some(b'*')
|
|
317
|
+
{
|
|
318
|
+
self.skip_block_comment()?;
|
|
319
|
+
} else {
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
Ok(())
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
fn skip_block_comment(&mut self) -> Result<(), StyleParseError> {
|
|
327
|
+
// self.pos is at `/`, next is `*`.
|
|
328
|
+
self.pos += 2;
|
|
329
|
+
while self.pos < self.bytes.len() {
|
|
330
|
+
if self.bytes[self.pos] == b'*' && self.peek(1) == Some(b'/') {
|
|
331
|
+
self.pos += 2;
|
|
332
|
+
return Ok(());
|
|
333
|
+
}
|
|
334
|
+
self.pos += 1;
|
|
335
|
+
}
|
|
336
|
+
Err(StyleParseError::UnterminatedComment)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
fn skip_string(&mut self, quote: u8) -> Result<(), StyleParseError> {
|
|
340
|
+
// self.pos is at the opening quote.
|
|
341
|
+
self.pos += 1;
|
|
342
|
+
while self.pos < self.bytes.len() {
|
|
343
|
+
let b = self.bytes[self.pos];
|
|
344
|
+
if b == b'\\' {
|
|
345
|
+
// Skip the escaped byte.
|
|
346
|
+
self.pos += 2;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if b == quote {
|
|
350
|
+
self.pos += 1;
|
|
351
|
+
return Ok(());
|
|
352
|
+
}
|
|
353
|
+
self.pos += 1;
|
|
354
|
+
}
|
|
355
|
+
Err(StyleParseError::UnterminatedString)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// Skip a balanced `(...)` / `[...]` span, honouring comments and strings
|
|
359
|
+
/// inside it so e.g. `url(")")` or `[data-x="}"]` do not confuse the scan.
|
|
360
|
+
fn skip_balanced(&mut self, open: u8, close: u8) -> Result<(), StyleParseError> {
|
|
361
|
+
let mut depth = 0u32;
|
|
362
|
+
while self.pos < self.bytes.len() {
|
|
363
|
+
let b = self.bytes[self.pos];
|
|
364
|
+
match b {
|
|
365
|
+
b'/' if self.peek(1) == Some(b'*') => {
|
|
366
|
+
self.skip_block_comment()?;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
b'"' | b'\'' => {
|
|
370
|
+
self.skip_string(b)?;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
_ if b == open => depth += 1,
|
|
374
|
+
_ if b == close => {
|
|
375
|
+
depth -= 1;
|
|
376
|
+
if depth == 0 {
|
|
377
|
+
self.pos += 1;
|
|
378
|
+
return Ok(());
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
_ => {}
|
|
382
|
+
}
|
|
383
|
+
self.pos += 1;
|
|
384
|
+
}
|
|
385
|
+
// Unbalanced parens/brackets: treat as unbalanced braces (structural).
|
|
386
|
+
Err(StyleParseError::UnbalancedBraces)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
fn peek(&self, ahead: usize) -> Option<u8> {
|
|
390
|
+
self.bytes.get(self.pos + ahead).copied()
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
395
|
+
enum Terminator {
|
|
396
|
+
Brace,
|
|
397
|
+
Semicolon,
|
|
398
|
+
CloseBrace,
|
|
399
|
+
Eof,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// Split a body's nodes into declarations / `@apply`s / nested nodes for a rule.
|
|
403
|
+
///
|
|
404
|
+
/// The body was parsed as a flat `Vec<StyleNode>` by [`Parser::parse_nodes`];
|
|
405
|
+
/// declaration-bearing selector-less rules and at-statements are re-classified
|
|
406
|
+
/// here. `@apply …;` arrives as a `StyleNode::AtStatement` (it has no body), so
|
|
407
|
+
/// we lift those into [`StyleRule::applies`].
|
|
408
|
+
fn build_rule(selector: String, body: Vec<StyleNode>) -> StyleRule {
|
|
409
|
+
let mut declarations = Vec::new();
|
|
410
|
+
let mut applies = Vec::new();
|
|
411
|
+
let mut nested = Vec::new();
|
|
412
|
+
|
|
413
|
+
for node in body {
|
|
414
|
+
match node {
|
|
415
|
+
// A selector-less rule produced by a bare declaration in the body.
|
|
416
|
+
StyleNode::Rule(r) if r.selector.is_empty() && r.nested.is_empty() => {
|
|
417
|
+
declarations.extend(r.declarations);
|
|
418
|
+
applies.extend(r.applies);
|
|
419
|
+
}
|
|
420
|
+
StyleNode::AtStatement(at) => {
|
|
421
|
+
if let Some(directive) = parse_apply(&at.text) {
|
|
422
|
+
applies.push(directive);
|
|
423
|
+
} else {
|
|
424
|
+
// A non-@apply statement at-rule inside a body (rare) — keep
|
|
425
|
+
// it as a nested node so nothing is dropped.
|
|
426
|
+
nested.push(StyleNode::AtStatement(at));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
other => nested.push(other),
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
StyleRule {
|
|
434
|
+
selector,
|
|
435
|
+
declarations,
|
|
436
|
+
applies,
|
|
437
|
+
nested,
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/// If `text` is an `@apply <tokens>` directive, return the parsed tokens.
|
|
442
|
+
fn parse_apply(text: &str) -> Option<ApplyDirective> {
|
|
443
|
+
let rest = text.strip_prefix("@apply")?;
|
|
444
|
+
// Ensure it's the `@apply` keyword, not `@applyfoo`.
|
|
445
|
+
if !rest.is_empty() && !rest.as_bytes()[0].is_ascii_whitespace() {
|
|
446
|
+
return None;
|
|
447
|
+
}
|
|
448
|
+
let tokens: Vec<String> = rest.split_whitespace().map(|t| t.to_string()).collect();
|
|
449
|
+
Some(ApplyDirective { tokens })
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/// Split `@name rest` into (`@name`, `rest`) where `@name` is the at-keyword.
|
|
453
|
+
fn split_at_name(prelude: &str) -> (String, String) {
|
|
454
|
+
let end = prelude
|
|
455
|
+
.char_indices()
|
|
456
|
+
.find(|(_, c)| c.is_whitespace() || *c == '(')
|
|
457
|
+
.map(|(i, _)| i)
|
|
458
|
+
.unwrap_or(prelude.len());
|
|
459
|
+
let name = prelude[..end].to_string();
|
|
460
|
+
let rest = prelude[end..].trim().to_string();
|
|
461
|
+
(name, rest)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/// Split `prop: value` on the FIRST top-level `:` (none inside `(...)`/`[...]`
|
|
465
|
+
/// /strings). Returns `None` if there is no such `:` (not a declaration).
|
|
466
|
+
fn split_declaration(text: &str) -> Option<(String, String)> {
|
|
467
|
+
let bytes = text.as_bytes();
|
|
468
|
+
let mut i = 0;
|
|
469
|
+
let mut paren = 0u32;
|
|
470
|
+
let mut bracket = 0u32;
|
|
471
|
+
while i < bytes.len() {
|
|
472
|
+
let b = bytes[i];
|
|
473
|
+
match b {
|
|
474
|
+
b'"' | b'\'' => {
|
|
475
|
+
// Skip string.
|
|
476
|
+
let quote = b;
|
|
477
|
+
i += 1;
|
|
478
|
+
while i < bytes.len() {
|
|
479
|
+
if bytes[i] == b'\\' {
|
|
480
|
+
i += 2;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if bytes[i] == quote {
|
|
484
|
+
i += 1;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
i += 1;
|
|
488
|
+
}
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
b'(' => paren += 1,
|
|
492
|
+
b')' => paren = paren.saturating_sub(1),
|
|
493
|
+
b'[' => bracket += 1,
|
|
494
|
+
b']' => bracket = bracket.saturating_sub(1),
|
|
495
|
+
b':' if paren == 0 && bracket == 0 => {
|
|
496
|
+
let prop = text[..i].trim().to_string();
|
|
497
|
+
let value = text[i + 1..].trim().to_string();
|
|
498
|
+
if prop.is_empty() {
|
|
499
|
+
return None;
|
|
500
|
+
}
|
|
501
|
+
return Some((prop, value));
|
|
502
|
+
}
|
|
503
|
+
_ => {}
|
|
504
|
+
}
|
|
505
|
+
i += 1;
|
|
506
|
+
}
|
|
507
|
+
None
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
impl StyleSheet {
|
|
511
|
+
/// Render the rule tree back to CSS. Round-trips a parsed unchanged block to
|
|
512
|
+
/// an equivalent (re-formatted) stylesheet. Used by `@apply` after rewriting
|
|
513
|
+
/// directives, and by tests asserting round-trip fidelity.
|
|
514
|
+
pub fn to_css(&self) -> String {
|
|
515
|
+
let mut out = String::new();
|
|
516
|
+
for node in &self.nodes {
|
|
517
|
+
write_node(&mut out, node, 0);
|
|
518
|
+
}
|
|
519
|
+
out
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
fn write_node(out: &mut String, node: &StyleNode, depth: usize) {
|
|
524
|
+
let pad = " ".repeat(depth);
|
|
525
|
+
match node {
|
|
526
|
+
StyleNode::Rule(rule) => {
|
|
527
|
+
if rule.selector.is_empty()
|
|
528
|
+
&& rule.nested.is_empty()
|
|
529
|
+
&& rule.applies.is_empty()
|
|
530
|
+
&& rule.declarations.len() == 1
|
|
531
|
+
{
|
|
532
|
+
// A root-level bare declaration: emit without a wrapping block.
|
|
533
|
+
let d = &rule.declarations[0];
|
|
534
|
+
out.push_str(&pad);
|
|
535
|
+
out.push_str(&d.prop);
|
|
536
|
+
out.push_str(": ");
|
|
537
|
+
out.push_str(&d.value);
|
|
538
|
+
out.push_str(";\n");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
out.push_str(&pad);
|
|
542
|
+
out.push_str(&rule.selector);
|
|
543
|
+
if !rule.selector.is_empty() {
|
|
544
|
+
out.push(' ');
|
|
545
|
+
}
|
|
546
|
+
out.push_str("{\n");
|
|
547
|
+
let inner = " ".repeat(depth + 1);
|
|
548
|
+
for d in &rule.declarations {
|
|
549
|
+
out.push_str(&inner);
|
|
550
|
+
out.push_str(&d.prop);
|
|
551
|
+
out.push_str(": ");
|
|
552
|
+
out.push_str(&d.value);
|
|
553
|
+
out.push_str(";\n");
|
|
554
|
+
}
|
|
555
|
+
for a in &rule.applies {
|
|
556
|
+
out.push_str(&inner);
|
|
557
|
+
out.push_str("@apply ");
|
|
558
|
+
out.push_str(&a.tokens.join(" "));
|
|
559
|
+
out.push_str(";\n");
|
|
560
|
+
}
|
|
561
|
+
for n in &rule.nested {
|
|
562
|
+
write_node(out, n, depth + 1);
|
|
563
|
+
}
|
|
564
|
+
out.push_str(&pad);
|
|
565
|
+
out.push_str("}\n");
|
|
566
|
+
}
|
|
567
|
+
StyleNode::AtRule(at) => {
|
|
568
|
+
out.push_str(&pad);
|
|
569
|
+
out.push_str(&at.name);
|
|
570
|
+
if !at.prelude.is_empty() {
|
|
571
|
+
out.push(' ');
|
|
572
|
+
out.push_str(&at.prelude);
|
|
573
|
+
}
|
|
574
|
+
out.push_str(" {\n");
|
|
575
|
+
for n in &at.body {
|
|
576
|
+
write_node(out, n, depth + 1);
|
|
577
|
+
}
|
|
578
|
+
out.push_str(&pad);
|
|
579
|
+
out.push_str("}\n");
|
|
580
|
+
}
|
|
581
|
+
StyleNode::AtStatement(at) => {
|
|
582
|
+
out.push_str(&pad);
|
|
583
|
+
out.push_str(&at.text);
|
|
584
|
+
out.push_str(";\n");
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|