1use crate::{AssociatedWhitespaceRules, Cursor, CursorSink, Kind, KindSet, SourceCursor, SourceCursorSink, Token};
2
3pub struct CursorCompactWriteSink<'a, T: SourceCursorSink<'a>> {
8 source_text: &'a str,
9 sink: T,
10 last_token: Option<Token>,
11 pending: Option<SourceCursor<'a>>,
12 pending_comment: Option<SourceCursor<'a>>,
13}
14
15const PENDING_KINDSET: KindSet = KindSet::new(&[Kind::Semicolon, Kind::Whitespace]);
17const REDUNDANT_SEMI_KINDSET: KindSet = KindSet::new(&[Kind::Semicolon, Kind::RightCurly]);
19const NO_WHITESPACE_BEFORE_KINDSET: KindSet = KindSet::new(&[
23 Kind::Whitespace,
24 Kind::Comma,
25 Kind::Semicolon,
26 Kind::LeftCurly,
27 Kind::RightCurly,
28 Kind::RightParen,
29 Kind::RightSquare,
30 Kind::Eof,
31]);
32const NO_WHITESPACE_AFTER_KINDSET: KindSet = KindSet::new(&[
35 Kind::Comma,
36 Kind::Semicolon,
37 Kind::Colon,
38 Kind::LeftCurly,
39 Kind::RightCurly,
40 Kind::LeftParen,
41 Kind::LeftSquare,
42]);
43
44impl<'a, T: SourceCursorSink<'a>> CursorCompactWriteSink<'a, T> {
45 pub fn new(source_text: &'a str, sink: T) -> Self {
46 Self { source_text, sink, last_token: None, pending: None, pending_comment: None }
47 }
48
49 fn needs_separator(&self, next: Token) -> bool {
51 self.last_token.is_some_and(|t| t.needs_separator_for(next))
52 }
53
54 fn last_forbids_ws_after(&self) -> bool {
57 self.last_token.is_some_and(|t| t == NO_WHITESPACE_AFTER_KINDSET)
58 }
59
60 fn emit(&mut self, c: SourceCursor<'a>) {
61 self.last_token = Some(c.token());
62 self.sink.append(c);
63 }
64
65 fn write(&mut self, c: SourceCursor<'a>) {
66 let kind = c.token().kind();
67 if kind == Kind::Comment {
68 let can_separate =
69 self.pending.is_none() && self.pending_comment.is_none() && !self.last_forbids_ws_after();
70 if can_separate {
71 self.pending_comment = Some(c);
72 }
73 return;
74 }
75 if self.pending_comment.take().is_some()
76 && kind != Kind::Whitespace
77 && kind != Kind::Eof
78 && self.needs_separator(c.token())
79 {
80 self.emit(SourceCursor::EMPTY_COMMENT);
81 }
82
83 if kind == Kind::Whitespace && self.pending.is_some_and(|c| c == Kind::Semicolon) {
84 return;
85 }
86
87 let enforce_before = c.token() == AssociatedWhitespaceRules::EnforceBefore;
88 let suppress_separator = self.last_forbids_ws_after() && !enforce_before;
89 let significant_pending_whitespace = self.pending.is_some_and(|p| p.token().whitespace_is_significant());
90 if let Some(prev) = self.pending.take() {
91 let keep = match prev.token().kind() {
92 Kind::Semicolon => {
93 kind != REDUNDANT_SEMI_KINDSET && self.last_token.is_some_and(|t| t != REDUNDANT_SEMI_KINDSET)
94 }
95 _ if suppress_separator || (!enforce_before && kind == NO_WHITESPACE_BEFORE_KINDSET) => false,
96 _ => (significant_pending_whitespace && self.last_token.is_some()) || self.needs_separator(c.token()),
97 };
98 if keep {
99 self.emit(prev.compact());
100 }
101 }
102
103 if kind == PENDING_KINDSET {
104 self.pending = Some(if significant_pending_whitespace && kind == Kind::Whitespace {
106 c.with_significant_whitespace(true)
107 } else {
108 c
109 });
110 return;
111 }
112 if kind == Kind::Eof {
113 return;
114 }
115
116 if !suppress_separator && self.needs_separator(c.token()) {
117 self.sink.append(SourceCursor::SPACE);
118 }
119
120 let out = if kind == Kind::String { c.with_quotes(c.fewest_escapes_quotes()).compact() } else { c.compact() };
121 self.emit(out);
122 }
123}
124
125impl<'a, T: SourceCursorSink<'a>> Drop for CursorCompactWriteSink<'a, T> {
126 fn drop(&mut self) {
127 if let Some(prev) = self.pending.take()
128 && prev == Kind::Semicolon
129 {
130 self.emit(prev);
131 }
132 }
133}
134
135impl<'a, T: SourceCursorSink<'a>> CursorSink for CursorCompactWriteSink<'a, T> {
136 fn append(&mut self, c: Cursor) {
137 self.write(SourceCursor::from(c, c.str_slice(self.source_text)))
138 }
139}
140
141impl<'a, T: SourceCursorSink<'a>> SourceCursorSink<'a> for CursorCompactWriteSink<'a, T> {
142 fn append(&mut self, c: SourceCursor<'a>) {
143 self.write(c)
144 }
145}
146
147#[cfg(test)]
148mod test {
149 use super::*;
150 use crate::Arena;
151 use crate::{ComponentValues, EmptyAtomSet, Parser, ToCursors};
152 use css_lexer::Lexer;
153
154 macro_rules! assert_format {
155 ($before: literal, $after: literal) => {
156 assert_format!(ComponentValues, $before, $after);
157 };
158 ($struct: ident, $before: literal, $after: literal) => {
159 let source_text = $before;
160 let alloc = Arena::default();
161 let mut sink = String::new();
162 {
163 let mut stream = CursorCompactWriteSink::new(source_text, &mut sink);
164 let lexer = Lexer::new(&EmptyAtomSet::ATOMS, source_text);
165 let mut parser = Parser::new(&alloc, source_text, lexer);
166 parser.parse_entirely::<$struct>().with_trivia().to_cursors(&mut stream);
167 }
168 assert_eq!(sink, $after.trim());
169 };
170 }
171
172 #[test]
173 fn test_basic() {
174 assert_format!("foo{bar: baz();}", r#"foo{bar:baz()}"#);
175 }
176
177 #[test]
178 fn test_removes_redundant_semis() {
179 assert_format!("foo{bar: 1;;;;bing: 2;;;}", r#"foo{bar:1;bing:2}"#);
180 }
181
182 #[test]
183 fn normalizes_quotes() {
184 assert_format!("bar:'baz';bing:'quux';x:url('foo')", r#"bar:"baz";bing:"quux";x:url("foo")"#);
185 }
186
187 #[test]
188 fn test_does_not_ignore_whitespace_component_values() {
189 assert_format!("div dialog:modal > td p a", "div dialog:modal > td p a");
190 }
191
192 #[test]
193 fn test_compacts_whitespace() {
194 assert_format!(
195 r#"
196 body > div {
197 bar: baz
198 }
199 "#,
200 "body > div{bar:baz}"
201 );
202 assert_format!(".a .b", ".a .b");
203 }
204
205 #[test]
206 fn test_does_not_compact_whitespace_resulting_in_new_ident() {
207 assert_format!("12px - 1px", "12px - 1px");
208 }
209
210 #[test]
211 fn test_removes_whitespace_after_comma() {
212 assert_format!("foo(a, b, c)", "foo(a,b,c)");
213 assert_format!("rgb(255, 128, 0)", "rgb(255,128,0)");
214 }
215
216 #[test]
217 fn test_keeps_whitespace_after_right_paren_and_square() {
218 assert_format!("foo() bar", "foo() bar");
219 assert_format!("rgb(0, 0, 0) solid", "rgb(0,0,0) solid");
220 assert_format!("[x] bar", "[x] bar");
221 }
222
223 #[test]
224 fn test_removes_whitespace_after_right_curly() {
225 assert_format!("@media screen{} .foo{}", "@media screen{}.foo{}");
226 }
227
228 #[test]
229 fn test_compacts_numbers_with_leading_zero() {
230 assert_format!("opacity: 0.8", "opacity:.8");
231 assert_format!("opacity: 0.5", "opacity:.5");
232 assert_format!("opacity: 0.123", "opacity:.123");
233 }
234
235 #[test]
236 fn test_compacts_numbers_with_trailing_zeros() {
237 assert_format!("width: 1.0px", "width:1px");
238 assert_format!("width: 1.500px", "width:1.5px");
239 assert_format!("width: 2.000px", "width:2px");
240 }
241
242 #[test]
243 fn test_compacts_numbers_with_sign() {
244 assert_format!("margin: -0.5px", "margin:-.5px");
245 assert_format!("margin: +1.5px", "margin:1.5px");
246 assert_format!("margin: +0.8px", "margin:.8px");
247 }
248
249 #[test]
250 fn test_compacts_edge_case_numbers() {
251 assert_format!("opacity: 0", "opacity:0");
252 assert_format!("opacity: 1", "opacity:1");
253 }
254
255 #[test]
256 fn test_does_not_change_numbers_without_optimization() {
257 assert_format!("width: 123px", "width:123px");
258 assert_format!("width: .5px", "width:.5px");
259 }
260
261 #[test]
262 fn test_keeps_numbers_beyond_f32_precision() {
263 assert_format!("width: 4e38px", "width:4e38px");
264 assert_format!("opacity: -4e38", "opacity:-4e38");
265 assert_format!("width: 3e38px", "width:3e38px");
266 assert_format!("z-index: 16777217", "z-index:16777217");
267 assert_format!("z-index: -16777217", "z-index:-16777217");
268 assert_format!("z-index: 16777215", "z-index:16777215");
269 }
270
271 #[test]
272 fn test_compacts_numbers_to_scientific_notation_when_shorter() {
273 assert_format!("width: 0.000001px", "width:1e-6px");
274 assert_format!("width: 0.000012px", "width:12e-6px");
275 assert_format!("width: -0.0001px", "width:-1e-4px");
276 assert_format!("width: 0.001px", "width:.001px");
277 assert_format!("width: 100px", "width:100px");
278 assert_format!("width: 1000px", "width:1e3px");
279 assert_format!("width: 1234px", "width:1234px");
280 assert_format!("width: 12000000px", "width:12e6px");
281 assert_format!("width: 1e-7px", "width:1e-7px");
282 assert_format!("opacity: 0.00001", "opacity:1e-5");
283 assert_format!("opacity: 1000.0", "opacity:1e3");
284 assert_format!("margin: -0px", "margin:-0px");
285 }
286
287 #[test]
288 fn test_preserves_numeric_type_of_unknown_values() {
289 assert_format!("z-index: 1000000", "z-index:1000000");
290 assert_format!("z-index: 1.0", "z-index:1.0");
291 assert_format!("z-index: 1.000", "z-index:1.0");
292 assert_format!("z-index: 0.0", "z-index:0.0");
293 assert_format!("z-index: -2.0", "z-index:-2.0");
294 assert_format!("z-index: 100.0", "z-index:1e2");
295 assert_format!("a: 1000n", "a:1000n");
296 assert_format!("a: 1000N", "a:1000N");
297 assert_format!("a: 1000n-1", "a:1000n-1");
298 assert_format!("a: 2.0n", "a:2.0n");
299 assert_format!("a: 2.0px", "a:2px");
300 }
301
302 #[test]
303 fn test_preserves_trailing_semicolons() {
304 assert_format!("foo;", "foo;");
305 }
306
307 #[test]
308 fn test_removes_trailing_semis_when_after_curly() {
309 assert_format!("{foo};", "{foo}");
310 }
311
312 #[test]
313 fn test_drops_trailing_whitespace() {
314 assert_format!("foo ", "foo");
315 assert_format!("foo; ", "foo;");
316 }
317
318 #[test]
319 fn test_preserves_comment_absence_in_custom_properties() {
320 assert_format!("div{--bar:a/**/b}", "div{--bar:a/**/b}");
321 assert_format!("div { --bar: a/**/b }", "div{--bar:a/**/b}");
322 assert_format!("div{--bar:a /* comment */ b}", "div{--bar:a b}");
323 assert_format!("div{--bar:a/**//**/b}", "div{--bar:a/**/b}");
324 assert_format!("div{--bar:a /* x */ /* y */ b}", "div{--bar:a b}");
325 assert_format!("div{/*comment*/--bar:a}", "div{--bar:a}");
326 assert_format!("div{--bar:a/*comment*/}", "div{--bar:a}");
327 assert_format!("div{--bar:a /**/ b}", "div{--bar:a b}");
328 assert_format!("@container style(--bar:a/**/b){}", "@container style(--bar:a/**/b){}");
329 assert_format!("@container style(--bar:a/**/b){}", "@container style(--bar:a/**/b){}");
330 assert_format!("foo /**/bar", "foo bar");
331 assert_format!("foo{/**/bar}", "foo{bar}");
332 assert_format!("foo:/**/bar", "foo:bar");
333 assert_format!("foo(/**/bar)", "foo(bar)");
334 assert_format!("foo/**/,bar", "foo,bar");
335 assert_format!("/**/foo", "foo");
336 assert_format!("div{--bar:a/*some really long comment text*/b}", "div{--bar:a/**/b}");
337 }
338
339 #[test]
340 fn test_at_rule_no_space_before_paren() {
341 assert_format!(
342 "@media(prefers-reduced-motion:no-preference){:root{}}",
343 "@media(prefers-reduced-motion:no-preference){:root{}}"
344 );
345 assert_format!(
346 "@media (prefers-reduced-motion:no-preference){:root{}}",
347 "@media (prefers-reduced-motion:no-preference){:root{}}"
348 );
349 assert_format!("@media(min-width:576px){}", "@media(min-width:576px){}");
350 }
351}