Skip to main content

css_parse/traits/
semantic_eq.rs

1use super::prelude::*;
2use css_lexer::{AssociatedWhitespaceRules, Kind, KindSet, SourceCursor};
3
4/// Trait for semantic equality comparison that ignores source positions and whitespace.
5///
6/// This trait provides semantic comparison for CSS AST nodes, comparing their structural
7/// content and meaning rather than their exact representation in source code. Two nodes
8/// are semantically equal if they represent the same CSS construct, regardless of source
9/// position or trivia.
10///
11/// Both nodes must come from `source_text`, because a [Cursor] keeps only the facts which fit in a
12/// [Token][css_lexer::Token] and a name too large for an atom stays in the source text.
13pub trait SemanticEq {
14	/// Returns `true` if `self` and `other`, both read from `source_text`, are semantically equal.
15	fn semantic_eq(&self, other: &Self, source_text: &str) -> bool;
16}
17
18impl SemanticEq for Cursor {
19	fn semantic_eq(&self, other: &Self, source_text: &str) -> bool {
20		let kind = self.token().kind();
21		if kind != other.token().kind() {
22			return false;
23		}
24		if KindSet::NAMED.contains(kind) {
25			// Only a named token needs its source text, so only a named token pays for the slice.
26			let this = SourceCursor::from(*self, self.str_slice(source_text));
27			let other = SourceCursor::from(*other, other.str_slice(source_text));
28			return this.semantic_eq(&other, source_text);
29		}
30		self.token().with_associated_whitespace(AssociatedWhitespaceRules::NONE)
31			== other.token().with_associated_whitespace(AssociatedWhitespaceRules::NONE)
32	}
33}
34
35impl SemanticEq for SourceCursor<'_> {
36	fn semantic_eq(&self, other: &Self, _source_text: &str) -> bool {
37		let token = self.token();
38		let other_token = other.token();
39		let kind = token.kind();
40		if kind != other_token.kind() {
41			return false;
42		}
43		match kind {
44			Kind::Ident | Kind::Function | Kind::AtKeyword => {
45				token.is_dashed_ident() == other_token.is_dashed_ident()
46					&& match (token.atom_bits(), other_token.atom_bits()) {
47						(0, 0) => self.eq_parsed_ignore_ascii_case(other),
48						(a, b) => a == b,
49					}
50			}
51			Kind::Dimension => {
52				token.value() == other_token.value()
53					&& match (token.atom_bits(), other_token.atom_bits()) {
54						(0, 0) => self.eq_parsed_ignore_ascii_case(other),
55						// The token does not keep the dashed-ness of a unit, and the atom lookup for a
56						// dashed unit skips the leading `--`, so equal atoms must also have equal unit
57						// lengths to tell `1--px` from `1px`.
58						(a, b) => {
59							a == b && token.len() - token.leading_len() == other_token.len() - other_token.leading_len()
60						}
61					}
62			}
63			Kind::String | Kind::Url | Kind::Hash => self.eq_parsed(other),
64			Kind::UnicodeRange => {
65				token.unicode_range_start() == other_token.unicode_range_start()
66					&& token.unicode_range_end() == other_token.unicode_range_end()
67			}
68			// The remaining named kinds, such as comments and the `Bad` kinds, keep no facts of what
69			// they hold, so only their whole source text tells them apart.
70			_ if KindSet::NAMED.contains(kind) => self.source() == other.source(),
71			_ => {
72				self.token().with_associated_whitespace(AssociatedWhitespaceRules::NONE)
73					== other.token().with_associated_whitespace(AssociatedWhitespaceRules::NONE)
74			}
75		}
76	}
77}
78
79impl<T> SemanticEq for Option<T>
80where
81	T: SemanticEq,
82{
83	fn semantic_eq(&self, s: &Self, source_text: &str) -> bool {
84		match (self, s) {
85			(Some(a), Some(b)) => a.semantic_eq(b, source_text),
86			(None, None) => true,
87			(_, _) => false,
88		}
89	}
90}
91
92impl<'a, T, A: Allocator> SemanticEq for Vec<'a, T, A>
93where
94	T: SemanticEq,
95{
96	fn semantic_eq(&self, s: &Self, source_text: &str) -> bool {
97		if self.len() != s.len() {
98			return false;
99		}
100		for i in 0..self.len() {
101			if !self[i].semantic_eq(&s[i], source_text) {
102				return false;
103			}
104		}
105		true
106	}
107}
108
109macro_rules! impl_tuple {
110		($($T:ident [ $A:ident, $B:ident ]),+) => {
111        impl<$($T),*> SemanticEq for ($($T),*)
112        where
113            $($T: SemanticEq,)*
114        {
115            fn semantic_eq(&self, o: &Self, source_text: &str) -> bool {
116                let ($($A),*) = self;
117                let ($($B),*) = o;
118                $($A.semantic_eq(&$B, source_text))&&*
119            }
120        }
121    };
122}
123
124impl_tuple!(A[sa,oa], B[sb,ob]);
125impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc]);
126impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc], D[sd,od]);
127impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc], D[sd,od], E[se,oe]);
128impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc], D[sd,od], E[se,oe], F[sf,of]);
129impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc], D[sd,od], E[se,oe], F[sf,of], G[sg,og]);
130impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc], D[sd,od], E[se,oe], F[sf,of], G[sg,og], H[sh,oh]);
131impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc], D[sd,od], E[se,oe], F[sf,of], G[sg,og], H[sh,oh], I[si,oi]);
132impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc], D[sd,od], E[se,oe], F[sf,of], G[sg,og], H[sh,oh], I[si,oi], J[sj,oj]);
133impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc], D[sd,od], E[se,oe], F[sf,of], G[sg,og], H[sh,oh], I[si,oi], J[sj,oj], K[sk,ok]);
134impl_tuple!(A[sa,oa], B[sb,ob], C[sc,oc], D[sd,od], E[se,oe], F[sf,of], G[sg,og], H[sh,oh], I[si,oi], J[sj,oj], K[sk,ok], L[sl,ol]);
135
136#[cfg(test)]
137mod tests {
138	use super::*;
139	use crate::Arena;
140	use crate::{ComponentValues, Parse, Parser, SimpleBlock, T, ToCursors, assert_semantic_eq, assert_semantic_ne};
141	use css_lexer::EmptyAtomSet;
142
143	fn parse<'a, T: Parse<'a> + ToCursors>(alloc: &'a Arena, source: &'a str) -> T {
144		let lexer = css_lexer::Lexer::new(&EmptyAtomSet::ATOMS, source);
145		let mut parser = Parser::new(alloc, source, lexer);
146		let result = parser.parse_entirely::<T>();
147		result.output.unwrap()
148	}
149
150	#[test]
151	fn test_cursor_semantic_eq_ignores_offset() {
152		let token = css_lexer::Token::COMMA;
153		let cursor1 = Cursor::new(css_lexer::SourceOffset(0), token);
154		let cursor2 = Cursor::new(css_lexer::SourceOffset(100), token);
155
156		// Should be semantically equal despite different offsets
157		assert!(cursor1.semantic_eq(&cursor2, ""));
158
159		// Standard PartialEq should distinguish them
160		assert_ne!(cursor1, cursor2);
161	}
162
163	#[test]
164	fn test_cursor_semantic_eq_ignores_associated_whitespace_for_all_delim_like_kinds() {
165		// Colon, Semicolon, Comma, and the paren/curly/square brackets share Delim's bit
166		// layout and can also carry associated-whitespace formatting hints. Those hints are
167		// not semantic content, so two tokens differing only by them must compare equal.
168		for token in [
169			css_lexer::Token::COLON,
170			css_lexer::Token::SEMICOLON,
171			css_lexer::Token::COMMA,
172			css_lexer::Token::LEFT_PAREN,
173			css_lexer::Token::RIGHT_PAREN,
174			css_lexer::Token::LEFT_CURLY,
175			css_lexer::Token::RIGHT_CURLY,
176			css_lexer::Token::LEFT_SQUARE,
177			css_lexer::Token::RIGHT_SQUARE,
178		] {
179			let plain = Cursor::new(css_lexer::SourceOffset(0), token);
180			let with_whitespace_rule = Cursor::new(
181				css_lexer::SourceOffset(0),
182				token.with_associated_whitespace(css_lexer::AssociatedWhitespaceRules::EnforceBefore),
183			);
184			assert!(
185				plain.semantic_eq(&with_whitespace_rule, ""),
186				"{:?} should be semantic_eq regardless of associated whitespace",
187				token.kind()
188			);
189		}
190	}
191
192	fn pair<'a>(alloc: &'a Arena, source: &'a str) -> (ComponentValues<'a>, ComponentValues<'a>) {
193		let (first, second) = parse::<(SimpleBlock, SimpleBlock)>(alloc, source);
194		(first.values, second.values)
195	}
196
197	#[test]
198	fn test_component_values_ignores_whitespace() {
199		let alloc = Arena::new();
200		let source = "(1px solid red)(1px  solid  red)";
201		let (first, second) = pair(&alloc, source);
202		assert!(first.semantic_eq(&second, source));
203	}
204
205	#[test]
206	fn test_component_values_different_values() {
207		let alloc = Arena::new();
208		let source = "(1px solid red)(2px solid red)";
209		let (first, second) = pair(&alloc, source);
210		assert!(!first.semantic_eq(&second, source));
211	}
212
213	#[test]
214	fn test_idents_of_equal_length_are_told_apart() {
215		let alloc = Arena::new();
216		let source = "(foo)(bar)";
217		let (first, second) = pair(&alloc, source);
218		assert!(!first.semantic_eq(&second, source));
219
220		let source = "(foo)(foo)";
221		let (first, second) = pair(&alloc, source);
222		assert!(first.semantic_eq(&second, source));
223	}
224
225	#[test]
226	fn test_strings_of_equal_length_are_told_apart() {
227		let alloc = Arena::new();
228		let source = "(\"i\")(\"j\")";
229		let (first, second) = pair(&alloc, source);
230		assert!(!first.semantic_eq(&second, source));
231	}
232
233	#[test]
234	fn test_ident_spellings_are_equal() {
235		let alloc = Arena::new();
236		let source = "(Foo)(foo)";
237		let (first, second) = pair(&alloc, source);
238		assert!(first.semantic_eq(&second, source));
239
240		let source = "(b\\61r)(bar)";
241		let (first, second) = pair(&alloc, source);
242		assert!(first.semantic_eq(&second, source));
243	}
244
245	#[test]
246	fn test_string_spellings() {
247		let alloc = Arena::new();
248		let source = "(\"A\")(\"a\")";
249		let (first, second) = pair(&alloc, source);
250		assert!(!first.semantic_eq(&second, source));
251
252		let source = "(\"a\")('a')";
253		let (first, second) = pair(&alloc, source);
254		assert!(first.semantic_eq(&second, source));
255
256		let source = "(\"\\61\")(\"a\")";
257		let (first, second) = pair(&alloc, source);
258		assert!(first.semantic_eq(&second, source));
259	}
260
261	#[test]
262	fn test_hashes_are_case_sensitive() {
263		let alloc = Arena::new();
264		let source = "(#Foo)(#foo)";
265		let (first, second) = pair(&alloc, source);
266		assert!(!first.semantic_eq(&second, source));
267	}
268
269	#[test]
270	fn test_dimension_spellings_are_equal() {
271		let alloc = Arena::new();
272		let source = "(1.0Px)(1px)";
273		let (first, second) = pair(&alloc, source);
274		assert!(first.semantic_eq(&second, source));
275
276		let source = "(1px)(2px)";
277		let (first, second) = pair(&alloc, source);
278		assert!(!first.semantic_eq(&second, source));
279	}
280
281	// The tests above use an empty atom set, so every ident and unit compares through the source
282	// text. An atom set which holds them compares by atom instead.
283	#[derive(Debug, Default, derive_atom_set::AtomSet, Copy, Clone, PartialEq)]
284	pub enum TestAtomSet {
285		#[default]
286		_None,
287		Foo,
288		Bar,
289		Px,
290	}
291
292	impl TestAtomSet {
293		const ATOMS: TestAtomSet = TestAtomSet::_None;
294	}
295
296	#[test]
297	fn test_ident_atoms() {
298		assert_semantic_eq!(TestAtomSet::ATOMS, T![Ident], "Foo", "foo");
299		assert_semantic_ne!(TestAtomSet::ATOMS, T![Ident], "foo", "bar");
300		// A dashed ident is not the plain ident of the same atom.
301		assert_semantic_ne!(TestAtomSet::ATOMS, T![Ident], "--foo", "foo");
302	}
303
304	#[test]
305	fn test_dimension_unit_atoms() {
306		assert_semantic_eq!(TestAtomSet::ATOMS, T![Dimension], "1.0Px", "1px");
307		assert_semantic_ne!(TestAtomSet::ATOMS, T![Dimension], "1px", "2px");
308		// The atom of a dashed unit skips the leading `--`, so the units must also be equally long.
309		assert_semantic_ne!(TestAtomSet::ATOMS, T![Dimension], "1--px", "1px");
310	}
311}