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