1use crate::{Cursor, CursorSink, Kind, KindSet, QuoteStyle, 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}
13
14const PENDING_KINDSET: KindSet = KindSet::new(&[Kind::Semicolon, Kind::Whitespace]);
15const REDUNDANT_SEMI_KINDSET: KindSet = KindSet::new(&[Kind::Semicolon, Kind::RightCurly]);
16const NO_WHITESPACE_BEFORE_KINDSET: KindSet =
18 KindSet::new(&[Kind::Whitespace, Kind::Colon, Kind::Delim, Kind::LeftCurly, Kind::RightCurly]);
19const NO_WHITESPACE_AFTER_KINDSET: KindSet =
21 KindSet::new(&[Kind::Comma, Kind::RightParen, Kind::RightCurly, Kind::LeftCurly, Kind::Colon]);
22
23impl<'a, T: SourceCursorSink<'a>> CursorCompactWriteSink<'a, T> {
24 pub fn new(source_text: &'a str, sink: T) -> Self {
25 Self { source_text, sink, last_token: None, pending: None }
26 }
27
28 fn write(&mut self, c: SourceCursor<'a>) {
29 let mut skip_separator_check = false;
30 if let Some(prev) = self.pending {
31 self.pending = None;
32 let is_redundant_semi = prev == Kind::Semicolon
33 && (c == REDUNDANT_SEMI_KINDSET || self.last_token.is_some_and(|c| c == REDUNDANT_SEMI_KINDSET));
34 let no_whitespace_after_last =
35 prev == Kind::Whitespace && self.last_token.is_some_and(|c| c == NO_WHITESPACE_AFTER_KINDSET);
36 let is_redundant_whitespace = self.last_token.is_none()
37 || prev == Kind::Whitespace && (c == NO_WHITESPACE_BEFORE_KINDSET || no_whitespace_after_last);
38 if !is_redundant_semi && !is_redundant_whitespace {
39 self.last_token = Some(prev.token());
40 self.sink.append(prev.compact());
41 } else if no_whitespace_after_last {
42 skip_separator_check = true;
45 }
46 }
47 if c == PENDING_KINDSET {
48 self.pending = Some(c);
49 return;
50 }
51 if !skip_separator_check
52 && let Some(last) = self.last_token
53 && last.needs_separator_for(c.token())
54 {
55 self.sink.append(SourceCursor::SPACE);
56 }
57 self.last_token = Some(c.token());
58 if c == Kind::String {
60 self.sink.append(c.with_quotes(QuoteStyle::Double).compact())
61 } else {
62 self.sink.append(c.compact());
63 }
64 }
65}
66
67impl<'a, T: SourceCursorSink<'a>> Drop for CursorCompactWriteSink<'a, T> {
68 fn drop(&mut self) {
69 if let Some(prev) = self.pending.take()
73 && prev == Kind::Semicolon
74 {
75 self.last_token = Some(prev.token());
76 self.sink.append(prev.compact());
77 }
78 }
79}
80
81impl<'a, T: SourceCursorSink<'a>> CursorSink for CursorCompactWriteSink<'a, T> {
82 fn append(&mut self, c: Cursor) {
83 self.write(SourceCursor::from(c, c.str_slice(self.source_text)))
84 }
85}
86
87impl<'a, T: SourceCursorSink<'a>> SourceCursorSink<'a> for CursorCompactWriteSink<'a, T> {
88 fn append(&mut self, c: SourceCursor<'a>) {
89 self.write(c)
90 }
91}
92
93#[cfg(test)]
94mod test {
95 use super::*;
96 use crate::{ComponentValues, EmptyAtomSet, Parser, ToCursors};
97 use bumpalo::Bump;
98 use css_lexer::Lexer;
99
100 macro_rules! assert_format {
101 ($before: literal, $after: literal) => {
102 assert_format!(ComponentValues, $before, $after);
103 };
104 ($struct: ident, $before: literal, $after: literal) => {
105 let source_text = $before;
106 let bump = Bump::default();
107 let mut sink = String::new();
108 {
109 let mut stream = CursorCompactWriteSink::new(source_text, &mut sink);
110 let lexer = Lexer::new(&EmptyAtomSet::ATOMS, source_text);
111 let mut parser = Parser::new(&bump, source_text, lexer);
112 parser.parse_entirely::<$struct>().output.unwrap().to_cursors(&mut stream);
113 }
114 assert_eq!(sink, $after.trim());
115 };
116 }
117
118 #[test]
119 fn test_basic() {
120 assert_format!("foo{bar: baz();}", r#"foo{bar:baz()}"#);
121 }
122
123 #[test]
124 fn test_removes_redundant_semis() {
125 assert_format!("foo{bar: 1;;;;bing: 2;;;}", r#"foo{bar:1;bing:2}"#);
126 }
127
128 #[test]
129 fn normalizes_quotes() {
130 assert_format!("bar:'baz';bing:'quux';x:url('foo')", r#"bar:"baz";bing:"quux";x:url("foo")"#);
131 }
132
133 #[test]
134 fn test_does_not_ignore_whitespace_component_values() {
135 assert_format!("div dialog:modal > td p a", "div dialog:modal > td p a");
136 }
137
138 #[test]
139 fn test_compacts_whitespace() {
140 assert_format!(
141 r#"
142 body > div {
143 bar: baz
144 }
145 "#,
146 "body > div{bar:baz}"
147 );
148 }
149
150 #[test]
151 fn test_does_not_compact_whitespace_resulting_in_new_ident() {
152 assert_format!("12px - 1px", "12px - 1px");
153 }
154
155 #[test]
156 fn test_removes_whitespace_after_comma() {
157 assert_format!("foo(a, b, c)", "foo(a,b,c)");
158 assert_format!("rgb(255, 128, 0)", "rgb(255,128,0)");
159 }
160
161 #[test]
162 fn test_removes_whitespace_after_right_paren() {
163 assert_format!("foo() bar", "foo()bar");
164 assert_format!("rgb(0, 0, 0) solid", "rgb(0,0,0)solid");
165 }
166
167 #[test]
168 fn test_removes_whitespace_after_right_curly() {
169 assert_format!("@media screen{} .foo{}", "@media screen{}.foo{}");
170 }
171
172 #[test]
173 fn test_compacts_numbers_with_leading_zero() {
174 assert_format!("opacity: 0.8", "opacity:.8");
175 assert_format!("opacity: 0.5", "opacity:.5");
176 assert_format!("opacity: 0.123", "opacity:.123");
177 }
178
179 #[test]
180 fn test_compacts_numbers_with_trailing_zeros() {
181 assert_format!("width: 1.0px", "width:1px");
182 assert_format!("width: 1.500px", "width:1.5px");
183 assert_format!("width: 2.000px", "width:2px");
184 }
185
186 #[test]
187 fn test_compacts_numbers_with_sign() {
188 assert_format!("margin: -0.5px", "margin:-.5px");
189 assert_format!("margin: +1.5px", "margin:1.5px");
190 assert_format!("margin: +0.8px", "margin:.8px");
191 }
192
193 #[test]
194 fn test_compacts_edge_case_numbers() {
195 assert_format!("opacity: 0.0", "opacity:0");
196 assert_format!("opacity: 0", "opacity:0");
197 assert_format!("opacity: 1", "opacity:1");
198 }
199
200 #[test]
201 fn test_does_not_change_numbers_without_optimization() {
202 assert_format!("width: 123px", "width:123px");
203 assert_format!("width: .5px", "width:.5px");
204 }
205
206 #[test]
207 fn test_preserves_trailing_semicolons() {
208 assert_format!("foo;", "foo;");
209 }
210
211 #[test]
212 fn test_drops_trailing_whitespace() {
213 assert_format!("foo ", "foo");
214 assert_format!("foo; ", "foo;");
215 }
216}