style/servo/
shadow_parts.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use crate::values::AtomIdent;
6use crate::Atom;
7
8type Mapping<'a> = (&'a str, &'a str);
9
10#[derive(Clone, Debug, MallocSizeOf)]
11pub struct ShadowParts {
12    // FIXME: Consider a smarter data structure for this.
13    // Gecko uses a hashtable in both directions:
14    // https://searchfox.org/mozilla-central/rev/5d4178378f84c7130ccb8ac1723d33e380d7f7d7/layout/style/ShadowParts.h
15    mappings: Vec<(Atom, Atom)>,
16}
17
18/// <https://drafts.csswg.org/css-shadow-parts/#parsing-mapping>
19///
20/// Returns `None` in the failure case.
21pub fn parse_part_mapping(input: &str) -> Option<Mapping<'_>> {
22    // Step 1. Let input be the string being parsed.
23    // Step 2. Let position be a pointer into input, initially pointing at the start of the string.
24    // NOTE: We don't need an explicit position, we just drop stuff from the input
25
26    // Step 3. Collect a sequence of code points that are space characters
27    let input = input.trim_start_matches(|c| c == ' ');
28
29    // Step 4. Collect a sequence of code points that are not space characters or U+003A COLON characters,
30    // and let first token be the result.
31    let space_or_colon_position = input
32        .char_indices()
33        .find(|(_, c)| matches!(c, ' ' | ':'))
34        .map(|(index, _)| index)
35        .unwrap_or(input.len());
36    let (first_token, input) = input.split_at(space_or_colon_position);
37
38    // Step 5. If first token is empty then return error.
39    if first_token.is_empty() {
40        return None;
41    }
42
43    // Step 6. Collect a sequence of code points that are space characters.
44    let input = input.trim_start_matches(|c| c == ' ');
45
46    // Step 7. If the end of the input has been reached, return the tuple (first token, first token)
47    if input.is_empty() {
48        return Some((first_token, first_token));
49    }
50
51    // Step 8. If character at position is not a U+003A COLON character, return error.
52    // Step 9. Consume the U+003A COLON character.
53    let Some(input) = input.strip_prefix(':') else {
54        return None;
55    };
56
57    // Step 10. Collect a sequence of code points that are space characters.
58    let input = input.trim_start_matches(|c| c == ' ');
59
60    // Step 11. Collect a sequence of code points that are not space characters or U+003A COLON characters.
61    // and let second token be the result.
62    let space_or_colon_position = input
63        .char_indices()
64        .find(|(_, c)| matches!(c, ' ' | ':'))
65        .map(|(index, _)| index)
66        .unwrap_or(input.len());
67    let (second_token, input) = input.split_at(space_or_colon_position);
68
69    // Step 12. If second token is empty then return error.
70    if second_token.is_empty() {
71        return None;
72    }
73
74    // Step 13. Collect a sequence of code points that are space characters.
75    let input = input.trim_start_matches(|c| c == ' ');
76
77    // Step 14. If position is not past the end of input then return error.
78    if !input.is_empty() {
79        return None;
80    }
81
82    // Step 14. Return the tuple (first token, second token).
83    Some((first_token, second_token))
84}
85
86/// <https://drafts.csswg.org/css-shadow-parts/#parsing-mapping-list>
87fn parse_mapping_list(input: &str) -> impl Iterator<Item = Mapping> {
88    // Step 1. Let input be the string being parsed.
89    // Step 2. Split the string input on commas. Let unparsed mappings be the resulting list of strings.
90    let unparsed_mappings = input.split(',');
91
92    // Step 3. Let mappings be an initially empty list of tuples of tokens.
93    // This list will be the result of this algorithm.
94    // NOTE: We return an iterator here - it is up to the caller to turn it into a list
95
96    // Step 4. For each string unparsed mapping in unparsed mappings, run the following substeps:
97    unparsed_mappings.filter_map(|unparsed_mapping| {
98        // Step 4.1 If unparsed mapping is empty or contains only space characters,
99        // continue to the next iteration of the loop.
100        if unparsed_mapping.chars().all(|c| c == ' ') {
101            return None;
102        }
103
104        // Step 4.2 Let mapping be the result of parsing unparsed mapping using the rules for parsing part mappings.
105        // Step 4.3 If mapping is an error then continue to the next iteration of the loop.
106        // This allows clients to skip over new syntax that is not understood.
107        // Step 4.4 Append mapping to mappings.
108        parse_part_mapping(unparsed_mapping)
109    })
110}
111
112impl ShadowParts {
113    pub fn parse(input: &str) -> Self {
114        Self {
115            mappings: parse_mapping_list(input)
116                .map(|(first, second)| (first.into(), second.into()))
117                .collect(),
118        }
119    }
120
121    /// Call the provided callback for each exported part with the given name.
122    pub fn for_each_exported_part<F>(&self, name: &Atom, mut callback: F)
123    where
124        F: FnMut(&AtomIdent),
125    {
126        for (from, to) in &self.mappings {
127            if from == name {
128                callback(AtomIdent::cast(to));
129            }
130        }
131    }
132
133    pub fn imported_part(&self, name: &Atom) -> Option<&Atom> {
134        self.mappings
135            .iter()
136            .find(|(_, to)| to == name)
137            .map(|(from, _)| from)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn parse_valid_mapping() {
147        assert_eq!(
148            parse_part_mapping("foo"),
149            Some(("foo", "foo")),
150            "Single token"
151        );
152        assert_eq!(
153            parse_part_mapping("  foo"),
154            Some(("foo", "foo")),
155            "Single token with leading whitespace"
156        );
157        assert_eq!(
158            parse_part_mapping("foo "),
159            Some(("foo", "foo")),
160            "Single token with trailing whitespace"
161        );
162        assert_eq!(
163            parse_part_mapping("foo:bar"),
164            Some(("foo", "bar")),
165            "Two tokens"
166        );
167        assert_eq!(
168            parse_part_mapping("foo:bar "),
169            Some(("foo", "bar")),
170            "Two tokens with trailing whitespace"
171        );
172        assert_eq!(
173            parse_part_mapping("🦀:🚀"),
174            Some(("🦀", "🚀")),
175            "Two tokens consisting of non-ascii characters"
176        );
177    }
178
179    #[test]
180    fn reject_invalid_mapping() {
181        assert!(parse_part_mapping("").is_none(), "Empty input");
182        assert!(parse_part_mapping("    ").is_none(), "Only whitespace");
183        assert!(parse_part_mapping("foo bar").is_none(), "Missing colon");
184        assert!(parse_part_mapping(":bar").is_none(), "Empty first token");
185        assert!(parse_part_mapping("foo:").is_none(), "Empty second token");
186        assert!(
187            parse_part_mapping("foo:bar baz").is_none(),
188            "Trailing input"
189        );
190    }
191
192    #[test]
193    fn parse_valid_mapping_list() {
194        let mut mappings = parse_mapping_list("foo: bar, totally-invalid-mapping,,");
195
196        // "foo: bar" is a valid mapping
197        assert_eq!(
198            mappings.next(),
199            Some(("foo", "bar")),
200            "First mapping should be in the list"
201        );
202        // "totally-invalid-mapping" is not a valid mapping and should be ignored
203        // "" is not valid (and consists of nothing but whitespace), so it should be ignored
204        assert!(mappings.next().is_none(), "No more mappings should exist");
205    }
206}