css_parse/
cursor_compact_write_sink.rs

1use crate::{Cursor, CursorSink, Kind, KindSet, QuoteStyle, 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}
13
14const PENDING_KINDSET: KindSet = KindSet::new(&[Kind::Semicolon, Kind::Whitespace]);
15const REDUNDANT_SEMI_KINDSET: KindSet = KindSet::new(&[Kind::Semicolon, Kind::RightCurly]);
16// Tokens where whitespace immediately before them can be removed
17const NO_WHITESPACE_BEFORE_KINDSET: KindSet =
18	KindSet::new(&[Kind::Whitespace, Kind::Colon, Kind::Delim, Kind::LeftCurly, Kind::RightCurly]);
19// Tokens where whitespace immediately after them can be removed
20const 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				// If we're skipping whitespace because the last token doesn't need whitespace after it,
43				// don't add it back via needs_separator_for
44				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		// Normalize quotes
59		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		// Flush any pending semicolons at the end of the stream.
70		// Trailing whitespace is genuinely redundant, but trailing semicolons may be
71		// required (e.g. `@import "foo.css";` needs the `;`).
72		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}