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::Colon, 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>> CursorSink for CursorCompactWriteSink<'a, T> {
68	fn append(&mut self, c: Cursor) {
69		self.write(SourceCursor::from(c, c.str_slice(self.source_text)))
70	}
71}
72
73impl<'a, T: SourceCursorSink<'a>> SourceCursorSink<'a> for CursorCompactWriteSink<'a, T> {
74	fn append(&mut self, c: SourceCursor<'a>) {
75		self.write(c)
76	}
77}
78
79#[cfg(test)]
80mod test {
81	use super::*;
82	use crate::{ComponentValues, EmptyAtomSet, Parser, ToCursors};
83	use bumpalo::Bump;
84	use css_lexer::Lexer;
85
86	macro_rules! assert_format {
87		($before: literal, $after: literal) => {
88			assert_format!(ComponentValues, $before, $after);
89		};
90		($struct: ident, $before: literal, $after: literal) => {
91			let source_text = $before;
92			let bump = Bump::default();
93			let mut sink = String::new();
94			let mut stream = CursorCompactWriteSink::new(source_text, &mut sink);
95			let lexer = Lexer::new(&EmptyAtomSet::ATOMS, source_text);
96			let mut parser = Parser::new(&bump, source_text, lexer);
97			parser.parse_entirely::<$struct>().output.unwrap().to_cursors(&mut stream);
98			assert_eq!(sink, $after.trim());
99		};
100	}
101
102	#[test]
103	fn test_basic() {
104		assert_format!("foo{bar: baz();}", r#"foo{bar:baz()}"#);
105	}
106
107	#[test]
108	fn test_removes_redundant_semis() {
109		assert_format!("foo{bar: 1;;;;bing: 2;;;}", r#"foo{bar:1;bing:2}"#);
110	}
111
112	#[test]
113	fn normalizes_quotes() {
114		assert_format!("bar:'baz';bing:'quux';x:url('foo')", r#"bar:"baz";bing:"quux";x:url("foo")"#);
115	}
116
117	#[test]
118	fn test_does_not_ignore_whitespace_component_values() {
119		assert_format!("div dialog:modal > td p a", "div dialog:modal > td p a");
120	}
121
122	#[test]
123	fn test_compacts_whitespace() {
124		assert_format!(
125			r#"
126		body   >   div {
127			bar:  baz
128		}
129		"#,
130			"body > div{bar:baz}"
131		);
132	}
133
134	#[test]
135	fn test_does_not_compact_whitespace_resulting_in_new_ident() {
136		assert_format!("12px - 1px", "12px - 1px");
137	}
138
139	#[test]
140	fn test_removes_whitespace_after_comma() {
141		assert_format!("foo(a, b, c)", "foo(a,b,c)");
142		assert_format!("rgb(255, 128, 0)", "rgb(255,128,0)");
143	}
144
145	#[test]
146	fn test_removes_whitespace_after_right_paren() {
147		assert_format!("foo() bar", "foo()bar");
148		assert_format!("rgb(0, 0, 0) solid", "rgb(0,0,0)solid");
149	}
150
151	#[test]
152	fn test_removes_whitespace_after_right_curly() {
153		assert_format!("@media screen{} .foo{}", "@media screen{}.foo{}");
154	}
155
156	#[test]
157	fn test_compacts_numbers_with_leading_zero() {
158		assert_format!("opacity: 0.8", "opacity:.8");
159		assert_format!("opacity: 0.5", "opacity:.5");
160		assert_format!("opacity: 0.123", "opacity:.123");
161	}
162
163	#[test]
164	fn test_compacts_numbers_with_trailing_zeros() {
165		assert_format!("width: 1.0px", "width:1px");
166		assert_format!("width: 1.500px", "width:1.5px");
167		assert_format!("width: 2.000px", "width:2px");
168	}
169
170	#[test]
171	fn test_compacts_numbers_with_sign() {
172		assert_format!("margin: -0.5px", "margin:-.5px");
173		assert_format!("margin: +1.5px", "margin:1.5px");
174		assert_format!("margin: +0.8px", "margin:.8px");
175	}
176
177	#[test]
178	fn test_compacts_edge_case_numbers() {
179		assert_format!("opacity: 0.0", "opacity:0");
180		assert_format!("opacity: 0", "opacity:0");
181		assert_format!("opacity: 1", "opacity:1");
182	}
183
184	#[test]
185	fn test_does_not_change_numbers_without_optimization() {
186		assert_format!("width: 123px", "width:123px");
187		assert_format!("width: .5px", "width:.5px");
188	}
189}