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 can always be removed (overrides any
22// `AssociatedWhitespaceRules::EnforceBefore` annotations carried from the source).
23const NO_WHITESPACE_BEFORE_KINDSET: KindSet =
24	KindSet::new(&[Kind::Whitespace, Kind::Colon, Kind::Delim, Kind::LeftCurly, Kind::RightCurly, Kind::Eof]);
25// Whitespace immediately after these tokens can always be removed.
26const 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	/// Would emitting `next` immediately after `last_token` change tokenisation?
35	fn needs_separator(&self, next: Token) -> bool {
36		self.last_token.is_some_and(|t| t.needs_separator_for(next))
37	}
38
39	/// True when the previously-emitted token never needs a separator after it
40	/// (e.g. `,`, `)`, `{`). Whitespace can be dropped and re-injection skipped.
41	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}