Skip to main content

css_parse/
cursor_compact_write_sink.rs

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