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