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