Skip to main content

css_parse/
cursor_compact_write_sink.rs

1use crate::{
2	AssociatedWhitespaceRules, Cursor, CursorSink, Kind, KindSet, QuoteStyle, SourceCursor, SourceCursorSink, Token,
3};
4
5/// This is a [CursorSink] that wraps a sink (`impl SourceCursorSink`) and on each [CursorSink::append()] call, will write
6/// the contents of the cursor [Cursor] given into the given sink - using the given `&'a str` as the original source.
7/// Some tokens will not be output, and Whitespace tokens will always write out as a single `' '`. It can be used as a
8/// light-weight minifier for ToCursors structs.
9pub 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
17// Tokens that get buffered as `pending` rather than emitted immediately.
18const PENDING_KINDSET: KindSet = KindSet::new(&[Kind::Semicolon, Kind::Whitespace]);
19// `;` is redundant immediately before/after these.
20const REDUNDANT_SEMI_KINDSET: KindSet = KindSet::new(&[Kind::Semicolon, Kind::RightCurly]);
21// Whitespace immediately before these tokens is never meaningful in any CSS grammar, so it can always be removed -
22// even when the parser marked it significant. `:` is absent because `.a :hover` is not `.a:hover`, and `)`/`]` only
23// appear here as the *closing* token of a block, where `foo( a )` is always `foo(a)`.
24const 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]);
34// Whitespace immediately after these tokens is never meaningful. `)`/`]` are absent because `:is(a) b` and `[x] b`
35// require a space, therefore we cannot reliable elide them.
36const 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	/// Would emitting `next` immediately after `last_token` change tokenisation?
52	fn needs_separator(&self, next: Token) -> bool {
53		self.last_token.is_some_and(|t| t.needs_separator_for(next))
54	}
55
56	/// True when the previously-emitted token never needs a separator after it
57	/// (e.g. `,`, `)`, `{`). Whitespace can be dropped and re-injection skipped.
58	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			// Adjacent whitespace tokens collapse into one, which stays significant if either part was.
106			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}