Skip to main content

grimoire_css_lib/
transmutator.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs::{self},
4    ops::Range,
5    path::{Path, PathBuf},
6};
7
8use crate::{
9    GrimoireCssError, Spell,
10    core::{CssGenerator, config::ScrollDefinition},
11};
12use cssparser::{
13    AtRuleParser, BasicParseErrorKind, CowRcStr, DeclarationParser, ParseError, Parser,
14    ParserInput, QualifiedRuleParser, RuleBodyItemParser, RuleBodyParser, StyleSheetParser, Token,
15};
16use glob::glob;
17use indexmap::{IndexMap, IndexSet};
18use lightningcss::{
19    media_query::MediaList,
20    properties::{Property, PropertyId},
21    rules::{CssRule, CssRuleList},
22    selector::{Component, Selector, SelectorList},
23    stylesheet::{ParserOptions as LightningParserOptions, StyleSheet as LightningStyleSheet},
24    traits::{ParseWithOptions, ToCss},
25};
26use serde::{Deserialize, Serialize};
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
29pub struct TransmuteOptions {
30    #[serde(default)]
31    pub with_oneliner: bool,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Transmutation {
36    pub scrolls: Vec<TransmutedScroll>,
37}
38
39impl Transmutation {
40    pub(crate) fn validate_component_scroll_conflicts(
41        &self,
42        existing_names: &HashSet<String>,
43    ) -> Result<(), GrimoireCssError> {
44        let definitions = Some(
45            existing_names
46                .iter()
47                .map(String::as_str)
48                .chain(self.scrolls.iter().map(|scroll| scroll.name.as_str()))
49                .map(|name| (name.to_string(), ScrollDefinition::default()))
50                .collect::<HashMap<_, _>>(),
51        );
52        let shared = HashSet::new();
53        for scroll in &self.scrolls {
54            for token in &scroll.spells {
55                // Empty definitions detect invocations without expanding Scroll bodies.
56                if let Ok(Some(spell)) =
57                    Spell::new(token, &shared, &definitions, (0, token.len()), None)
58                    && definitions
59                        .as_ref()
60                        .unwrap()
61                        .contains_key(spell.component())
62                {
63                    return Err(conversion_error(format!(
64                        "CSS component/Scroll conflict: '{}' in migrated Scroll '{}' would invoke a Scroll instead of a CSS property; rename the conflicting Scroll before migration",
65                        spell.component(),
66                        scroll.name
67                    )));
68                }
69            }
70        }
71        Ok(())
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct TransmutedScroll {
77    pub name: String,
78    pub spells: Vec<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub oneliner: Option<String>,
81}
82
83type TransmutedMap = IndexMap<String, IndexSet<String>>;
84
85fn read_and_clean_files(paths: &[PathBuf]) -> Result<String, GrimoireCssError> {
86    let total_size: usize = paths
87        .iter()
88        .filter_map(|path| fs::metadata(path).ok())
89        .map(|metadata| metadata.len() as usize)
90        .sum();
91
92    let mut all_contents = String::with_capacity(total_size);
93
94    for path in paths {
95        let content = fs::read_to_string(path).map_err(|e| {
96            GrimoireCssError::Io(std::io::Error::new(
97                e.kind(),
98                format!("Failed to read '{}': {}", path.display(), e),
99            ))
100        })?;
101
102        validate_file_urls(&content, path)?;
103        all_contents.push_str(&content);
104        all_contents.push('\n');
105    }
106
107    if all_contents.capacity() > all_contents.len() * 2 {
108        all_contents.shrink_to_fit();
109    }
110
111    Ok(all_contents)
112}
113
114// Scrolls carry no source URL, so relative URLs would lose their base.
115fn validate_file_urls(css: &str, path: &Path) -> Result<(), GrimoireCssError> {
116    let mut input = ParserInput::new(css);
117    check_file_url_tokens(&mut Parser::new(&mut input), false).map_err(|error| {
118        let message = match error.kind {
119            cssparser::ParseErrorKind::Custom(error) => error.to_string(),
120            _ => format!("Cannot inspect CSS URLs: {error:?}"),
121        };
122        conversion_error(format!("{}: {message}", path.display()))
123    })
124}
125
126fn check_file_url_tokens<'i, 't>(
127    input: &mut Parser<'i, 't>,
128    strings_are_urls: bool,
129) -> Result<(), ParseError<'i, GrimoireCssError>> {
130    loop {
131        let token = match input.next_including_whitespace_and_comments() {
132            Ok(token) => token.clone(),
133            Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => return Ok(()),
134            Err(error) => return Err(error.into()),
135        };
136        match token {
137            Token::UnquotedUrl(url) => check_file_url(input, &url)?,
138            Token::QuotedString(url) if strings_are_urls => check_file_url(input, &url)?,
139            Token::Function(name) => {
140                if strings_are_urls && name.eq_ignore_ascii_case("var") {
141                    return Err(input.new_custom_error(conversion_error(
142                        "Dynamic CSS URL cannot retain its source base; use an explicit absolute or root-relative URL before file migration",
143                    )));
144                }
145                let is_url = name.eq_ignore_ascii_case("url") || name.eq_ignore_ascii_case("src");
146                let is_image = ["image", "image-set", "-webkit-image-set"]
147                    .iter()
148                    .any(|candidate| name.eq_ignore_ascii_case(candidate));
149                input.parse_nested_block(|nested| {
150                    if is_url {
151                        let url = nested.expect_string_cloned().map_err(|_| {
152                            nested.new_custom_error(conversion_error(
153                                "Dynamic CSS URL cannot retain its source base; use an explicit absolute or root-relative URL before file migration",
154                            ))
155                        })?;
156                        check_file_url(nested, &url)?;
157                    }
158                    check_file_url_tokens(nested, is_image)
159                })?;
160            }
161            Token::ParenthesisBlock | Token::SquareBracketBlock | Token::CurlyBracketBlock => {
162                input.parse_nested_block(|nested| check_file_url_tokens(nested, false))?;
163            }
164            _ => {}
165        }
166    }
167}
168
169fn check_file_url<'i>(
170    input: &Parser<'i, '_>,
171    url: &str,
172) -> Result<(), ParseError<'i, GrimoireCssError>> {
173    let value = url.trim_matches(|ch: char| ch.is_ascii_whitespace());
174    let has_scheme = value.split_once(':').is_some_and(|(scheme, _)| {
175        scheme
176            .as_bytes()
177            .first()
178            .is_some_and(u8::is_ascii_alphabetic)
179            && scheme
180                .bytes()
181                .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'+' | b'-' | b'.'))
182    });
183    // Fragment-only URLs have CSS's local-url behavior, independent of this base.
184    if value.starts_with('/') || value.starts_with('#') || has_scheme {
185        return Ok(());
186    }
187    Err(input.new_custom_error(conversion_error(format!(
188        "Relative CSS URL '{url}' depends on the source stylesheet directory, which Scrolls do not retain; use an absolute or root-relative URL before file migration"
189    ))))
190}
191
192fn is_css_whitespace(ch: char) -> bool {
193    matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{000c}')
194}
195
196/// Trailing escaped spaces belong to an identifier, not a whitespace token.
197fn trim_css_fragment_end(fragment: &str) -> Result<&str, GrimoireCssError> {
198    let mut source = ParserInput::new(fragment);
199    let mut input = Parser::new(&mut source);
200    let mut end = 0;
201    loop {
202        let token = match input.next_including_whitespace_and_comments() {
203            Ok(token) => token,
204            Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => break,
205            Err(error) => return Err(conversion_error(format!("Invalid CSS fragment: {error:?}"))),
206        };
207        if matches!(token, Token::WhiteSpace(_)) {
208            continue;
209        }
210        if matches!(
211            token,
212            Token::Function(_)
213                | Token::ParenthesisBlock
214                | Token::SquareBracketBlock
215                | Token::CurlyBracketBlock
216        ) {
217            input
218                .parse_nested_block(|nested| {
219                    consume_rule_tokens(nested);
220                    Ok::<_, ParseError<'_, ()>>(())
221                })
222                .map_err(|error| conversion_error(format!("Invalid CSS fragment: {error:?}")))?;
223        }
224        end = input.position().byte_index();
225    }
226    Ok(&fragment[..end])
227}
228
229fn merge_maps(map1: &mut TransmutedMap, map2: TransmutedMap) {
230    for (key, values) in map2 {
231        let spells = map1.entry(key).or_default();
232        for value in values {
233            insert_last(spells, value);
234        }
235    }
236}
237
238/// An identical later declaration wins at its later cascade position.
239fn insert_last(values: &mut IndexSet<String>, value: String) {
240    values.shift_remove(&value);
241    values.insert(value);
242}
243
244#[derive(Clone, PartialEq, Eq)]
245enum CascadeEffect {
246    Custom(String),
247    Longhand(String),
248    // Shorthands, logical properties and aliases may affect the same longhand.
249    Uncertain,
250}
251
252impl CascadeEffect {
253    fn from_property(property: &Property<'_>) -> Self {
254        let id = property.property_id();
255        let name = id.name();
256        if name.starts_with("--") {
257            return Self::Custom(name.to_string());
258        }
259        if matches!(id, PropertyId::Custom(_) | PropertyId::All)
260            || id.is_shorthand()
261            || (!id.prefix().is_empty()
262                && id.prefix() != lightningcss::vendor_prefix::VendorPrefix::None)
263            || name
264                .split('-')
265                .any(|part| matches!(part, "block" | "inline" | "start" | "end"))
266        {
267            return Self::Uncertain;
268        }
269        Self::Longhand(
270            if name == "word-wrap" {
271                "overflow-wrap"
272            } else {
273                name
274            }
275            .to_string(),
276        )
277    }
278
279    fn may_overlap(&self, other: &Self) -> bool {
280        match (self, other) {
281            (Self::Custom(a), Self::Custom(b)) => a == b,
282            (Self::Custom(_), _) | (_, Self::Custom(_)) => false,
283            (Self::Longhand(a), Self::Longhand(b)) => a == b,
284            _ => true,
285        }
286    }
287}
288
289#[derive(Clone, PartialEq, Eq)]
290struct CascadeDeclaration {
291    effect: CascadeEffect,
292    css: String,
293    important: bool,
294}
295
296struct CascadeRule {
297    class: String,
298    selector: String,
299    specificity: u32,
300    line: u32,
301    declarations: Vec<CascadeDeclaration>,
302}
303
304// Scroll discovery can reorder rules; reject order-dependent cascade results.
305fn validate_scroll_cascade(
306    css: &str,
307    transmutation: &Transmutation,
308) -> Result<(), GrimoireCssError> {
309    if transmutation.scrolls.len() < 2 {
310        return Ok(());
311    }
312    let sheet = LightningStyleSheet::parse(css, LightningParserOptions::default())
313        .map_err(|error| conversion_error(format!("Cannot inspect CSS cascade: {error}")))?;
314    let names = transmutation
315        .scrolls
316        .iter()
317        .map(|scroll| scroll.name.as_str())
318        .collect();
319    let mut rules = Vec::new();
320    collect_cascade_rules(&sheet.rules, &names, &mut rules)?;
321    for (index, a) in rules.iter().enumerate() {
322        for b in &rules[index + 1..] {
323            if a.class == b.class
324                || a.specificity != b.specificity
325                || a.declarations == b.declarations
326            {
327                continue;
328            }
329            for left in &a.declarations {
330                for right in &b.declarations {
331                    if left.important == right.important
332                        && left.css != right.css
333                        && left.effect.may_overlap(&right.effect)
334                    {
335                        return Err(conversion_error(format!(
336                            "CSS cascade conflict: '{}' (line {}, '{}') and '{}' (line {}, '{}') may depend on source order, which ordinary Scrolls cannot guarantee. No partial migration was produced. Keep the original CSS file connected through shared.styles, or refactor these rules before converting them to Scrolls",
337                            a.selector, a.line, left.css, b.selector, b.line, right.css
338                        )));
339                    }
340                }
341            }
342        }
343    }
344    Ok(())
345}
346
347fn collect_cascade_rules(
348    rules: &CssRuleList<'_>,
349    names: &HashSet<&str>,
350    output: &mut Vec<CascadeRule>,
351) -> Result<(), GrimoireCssError> {
352    for rule in &rules.0 {
353        match rule {
354            CssRule::Media(media) => collect_cascade_rules(&media.rules, names, output)?,
355            CssRule::Style(style) => {
356                let mut declarations = Vec::new();
357                for (properties, important) in [
358                    (&style.declarations.declarations, false),
359                    (&style.declarations.important_declarations, true),
360                ] {
361                    for property in properties {
362                        declarations.push(CascadeDeclaration {
363                            effect: CascadeEffect::from_property(property),
364                            css: property
365                                .to_css_string(important, Default::default())
366                                .map_err(|error| {
367                                    conversion_error(format!(
368                                        "Cannot inspect CSS declaration: {error}"
369                                    ))
370                                })?,
371                            important,
372                        });
373                    }
374                }
375                for selector in &style.selectors.0 {
376                    let text = selector
377                        .to_css_string(Default::default())
378                        .map_err(|error| {
379                            conversion_error(format!("Cannot inspect CSS selector: {error}"))
380                        })?;
381                    let name = {
382                        let mut source = ParserInput::new(&text);
383                        let mut parser = Parser::new(&mut source);
384                        if parser.expect_delim('.').is_err() {
385                            continue;
386                        }
387                        let name = parser.expect_ident_cloned().map_err(|error| {
388                            conversion_error(format!("Cannot inspect Scroll name: {error:?}"))
389                        })?;
390                        name.to_string()
391                    };
392                    if names.contains(name.as_str()) {
393                        output.push(CascadeRule {
394                            class: name.to_string(),
395                            selector: text,
396                            specificity: selector.specificity(),
397                            line: style.loc.line + 1,
398                            declarations: declarations.clone(),
399                        });
400                    }
401                }
402            }
403            _ => {}
404        }
405    }
406    Ok(())
407}
408
409fn push_css_escape(result: &mut String, value: char, followed_by_space: bool) {
410    result.push_str(&format!("\\{:06x}", value as u32));
411    if followed_by_space {
412        // Spell decoding turns '_' into an escape terminator, preserving the next space.
413        result.push('_');
414    }
415}
416
417/// Protect CSS literals from Grimoire substitutions and implicit animation imports.
418fn encode_css_fragment(
419    fragment: &str,
420    protect_dollars: bool,
421    protect_animation_names: bool,
422) -> Result<String, GrimoireCssError> {
423    let mut dollars = HashSet::new();
424    let mut strings = HashMap::new();
425    let mut names = HashMap::new();
426    let mut parser_input = ParserInput::new(fragment);
427    collect_fragment_literals(
428        &mut Parser::new(&mut parser_input),
429        &mut dollars,
430        &mut strings,
431        &mut names,
432        protect_animation_names,
433    )
434    .map_err(|error| conversion_error(format!("Invalid CSS fragment: {error:?}")))?;
435    let mut result = String::with_capacity(fragment.len());
436    let mut chars = fragment.char_indices().peekable();
437    while let Some((offset, ch)) = chars.next() {
438        if let Some((end, name, suffix)) = names.get(&offset) {
439            for value in name.chars() {
440                result.push_str(&format!("\\{:06x}", value as u32));
441            }
442            result.push_str(suffix);
443            if suffix.is_empty() {
444                // Even a six-digit escape would consume the following whitespace.
445                result.push_str("/**/");
446            }
447            while chars.peek().is_some_and(|(position, _)| position < end) {
448                chars.next();
449            }
450            continue;
451        }
452        if let Some((end, value)) = strings.get(&offset) {
453            result.push(ch);
454            let mut values = value.chars().peekable();
455            while let Some(value) = values.next() {
456                // Escape every word of a quoted name to prevent keyframe injection.
457                if protect_animation_names {
458                    result.push_str(&format!("\\{:06x}", value as u32));
459                    continue;
460                }
461                match value {
462                    ' ' => result.push('_'),
463                    // Escape '(' so string data cannot trigger Grimoire functions.
464                    '_' | '$' | '(' => {
465                        push_css_escape(&mut result, value, values.peek() == Some(&' '))
466                    }
467                    value if value.is_control() || value.is_whitespace() => {
468                        push_css_escape(&mut result, value, values.peek() == Some(&' '));
469                    }
470                    value if value == ch || value == '\\' => {
471                        result.push('\\');
472                        result.push(value);
473                    }
474                    value => result.push(value),
475                }
476            }
477            result.push(ch);
478            while chars.peek().is_some_and(|(position, _)| position < end) {
479                chars.next();
480            }
481            continue;
482        }
483        match ch {
484            '_' => push_css_escape(
485                &mut result,
486                ch,
487                chars
488                    .peek()
489                    .is_some_and(|(_, next)| is_css_whitespace(*next)),
490            ),
491            '$' if protect_dollars && dollars.contains(&offset) => {
492                // Preserve the delimiter while blocking '=$' arguments and '$name' variables.
493                result.push_str("/**/$/**/");
494            }
495            '$' if protect_dollars => push_css_escape(
496                &mut result,
497                ch,
498                chars
499                    .peek()
500                    .is_some_and(|(_, next)| is_css_whitespace(*next)),
501            ),
502            '\\' => match chars.next() {
503                Some((_, '_')) => push_css_escape(
504                    &mut result,
505                    '_',
506                    chars
507                        .peek()
508                        .is_some_and(|(_, next)| is_css_whitespace(*next)),
509                ),
510                Some((_, '$')) if protect_dollars => push_css_escape(
511                    &mut result,
512                    '$',
513                    chars
514                        .peek()
515                        .is_some_and(|(_, next)| is_css_whitespace(*next)),
516                ),
517                Some((_, next))
518                    if next.is_whitespace() && !matches!(next, '\n' | '\r' | '\u{000c}') =>
519                {
520                    push_css_escape(
521                        &mut result,
522                        next,
523                        chars
524                            .peek()
525                            .is_some_and(|(_, next)| is_css_whitespace(*next)),
526                    );
527                }
528                Some((_, next)) => {
529                    result.push('\\');
530                    result.push(if is_css_whitespace(next) { '_' } else { next });
531                }
532                None => result.push('\\'),
533            },
534            ch if is_css_whitespace(ch) => result.push('_'),
535            ch if ch.is_whitespace() => push_css_escape(
536                &mut result,
537                ch,
538                chars
539                    .peek()
540                    .is_some_and(|(_, next)| is_css_whitespace(*next)),
541            ),
542            ch => result.push(ch),
543        }
544    }
545    Ok(result)
546}
547
548fn collect_fragment_literals<'i, 't>(
549    input: &mut Parser<'i, 't>,
550    offsets: &mut HashSet<usize>,
551    strings: &mut HashMap<usize, (usize, String)>,
552    names: &mut HashMap<usize, (usize, String, &'static str)>,
553    protect_animation_names: bool,
554) -> Result<(), ParseError<'i, GrimoireCssError>> {
555    loop {
556        let start = input.position().byte_index();
557        let token = match input.next_including_whitespace_and_comments() {
558            Ok(token) => token,
559            Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => return Ok(()),
560            Err(error) => return Err(error.into()),
561        };
562        let string_value = match token {
563            Token::QuotedString(value) => Some(value.to_string()),
564            _ => None,
565        };
566        let protected_name = match token {
567            // The size-function regex also matches names such as vendor-mfs(...).
568            Token::Function(name)
569                if name.ends_with("mfs") || name.ends_with("mrs") || name.starts_with("g-") =>
570            {
571                Some((name.to_string(), "("))
572            }
573            // Project animation names are unknown here, so protect all identifiers.
574            Token::Ident(name) if protect_animation_names => Some((name.to_string(), "")),
575            _ => None,
576        };
577        if matches!(token, Token::Delim('$')) {
578            offsets.insert(start);
579        }
580        let nested = matches!(
581            token,
582            Token::Function(_)
583                | Token::ParenthesisBlock
584                | Token::SquareBracketBlock
585                | Token::CurlyBracketBlock
586        );
587        if let Some((name, suffix)) = protected_name {
588            names.insert(start, (input.position().byte_index(), name, suffix));
589        }
590        if nested {
591            input.parse_nested_block(|nested| {
592                collect_fragment_literals(nested, offsets, strings, names, protect_animation_names)
593            })?;
594        }
595        if let Some(value) = string_value {
596            strings.insert(start, (input.position().byte_index(), value));
597        }
598    }
599}
600
601fn validate_scroll_name(name: &str, generator: &CssGenerator<'_>) -> Result<(), GrimoireCssError> {
602    // Match source discovery, which splits class attributes on Unicode whitespace.
603    if !name.split_whitespace().eq([name]) {
604        return Err(conversion_error(format!(
605            "Unsupported Scroll name {name:?}: the Grimoire source scanner splits this name at whitespace; rename the source class before migration"
606        )));
607    }
608    let invalid = || {
609        conversion_error(format!(
610            "Unsupported Scroll name '{name}': the engine cannot invoke it as a plain class; rename the source class before migration"
611        ))
612    };
613    let scrolls = Some(HashMap::from([(
614        name.to_string(),
615        ScrollDefinition::default(),
616    )]));
617    let spell = Spell::new(name, &HashSet::new(), &scrolls, (0, name.len()), None)
618        .map_err(|_| invalid())?
619        .ok_or_else(invalid)?;
620    if spell.component() != name
621        || !spell.area().is_empty()
622        || !spell.focus().is_empty()
623        || !spell.effects().is_empty()
624        || spell.with_template
625    {
626        return Err(invalid());
627    }
628    let (selector, _) = generator
629        .generate_css_class_name(name, "", "", false)
630        .map_err(|_| invalid())?;
631    let mut input = ParserInput::new(&selector);
632    let mut parser = Parser::new(&mut input);
633    let selectors =
634        SelectorList::parse_with_options(&mut parser, &LightningParserOptions::default())
635            .map_err(|_| invalid())?;
636    parser.expect_exhausted().map_err(|_| invalid())?;
637    if selectors.0.len() != 1 {
638        return Err(invalid());
639    }
640    let mut components = selectors.0[0].iter_raw_match_order();
641    if !matches!(components.next(), Some(Component::Class(class)) if class.0.as_ref() == name)
642        || components.next().is_some()
643    {
644        return Err(invalid());
645    }
646    Ok(())
647}
648
649#[derive(Clone, Copy, PartialEq, Eq)]
650enum BlockKind {
651    Parenthesis,
652    Square,
653    Curly,
654}
655
656fn validate_css_syntax(css: &str) -> Result<(), GrimoireCssError> {
657    let mut blocks = Vec::new();
658    let mut chars = css.chars().peekable();
659    let mut quote = None;
660    let mut comment = false;
661
662    while let Some(ch) = chars.next() {
663        if comment {
664            if ch == '*' && chars.peek() == Some(&'/') {
665                chars.next();
666                comment = false;
667            }
668            continue;
669        }
670        if let Some(delimiter) = quote {
671            match ch {
672                '\\' => {
673                    if chars.next() == Some('\r') && chars.peek() == Some(&'\n') {
674                        chars.next();
675                    }
676                }
677                value if value == delimiter => quote = None,
678                '\n' | '\r' => {
679                    return Err(GrimoireCssError::InvalidInput(
680                        "Malformed CSS contains an unterminated string".into(),
681                    ));
682                }
683                _ => {}
684            }
685            continue;
686        }
687
688        match ch {
689            '/' if chars.peek() == Some(&'*') => {
690                chars.next();
691                comment = true;
692            }
693            '\'' | '"' => quote = Some(ch),
694            '\\' => {
695                chars.next();
696            }
697            '(' => blocks.push(BlockKind::Parenthesis),
698            '[' => blocks.push(BlockKind::Square),
699            '{' => blocks.push(BlockKind::Curly),
700            ')' => close_block(&mut blocks, BlockKind::Parenthesis)?,
701            ']' => close_block(&mut blocks, BlockKind::Square)?,
702            '}' => close_block(&mut blocks, BlockKind::Curly)?,
703            _ => {}
704        }
705    }
706
707    if comment {
708        return Err(GrimoireCssError::InvalidInput(
709            "Malformed CSS contains an unterminated comment".into(),
710        ));
711    } else if quote.is_some() {
712        return Err(GrimoireCssError::InvalidInput(
713            "Malformed CSS contains an unterminated string".into(),
714        ));
715    } else if !blocks.is_empty() {
716        return Err(GrimoireCssError::InvalidInput(
717            "Malformed CSS contains an unclosed block".into(),
718        ));
719    }
720
721    // Validate at-rule grammar that the token-level checks below cannot enforce.
722    LightningStyleSheet::parse(css, LightningParserOptions::default())
723        .map_err(|error| GrimoireCssError::InvalidInput(format!("Malformed CSS: {error}")))?;
724
725    let mut input = ParserInput::new(css);
726    let mut parser = Parser::new(&mut input);
727    validate_stylesheet_rules(&mut parser).map_err(|error| {
728        GrimoireCssError::InvalidInput(format!(
729            "Malformed CSS at line {}, column {}: {:?}",
730            error.location.line, error.location.column, error.kind
731        ))
732    })
733}
734
735struct SyntaxRuleParser;
736
737impl<'i> QualifiedRuleParser<'i> for SyntaxRuleParser {
738    type Prelude = ();
739    type QualifiedRule = ();
740    type Error = ();
741
742    fn parse_prelude<'t>(
743        &mut self,
744        input: &mut Parser<'i, 't>,
745    ) -> Result<Self::Prelude, ParseError<'i, Self::Error>> {
746        validate_selector_list(input)
747    }
748
749    fn parse_block<'t>(
750        &mut self,
751        _prelude: Self::Prelude,
752        _start: &cssparser::ParserState,
753        input: &mut Parser<'i, 't>,
754    ) -> Result<Self::QualifiedRule, ParseError<'i, Self::Error>> {
755        validate_declaration_list(input)
756    }
757}
758
759struct SyntaxDeclarationParser;
760
761impl<'i> DeclarationParser<'i> for SyntaxDeclarationParser {
762    type Declaration = ();
763    type Error = ();
764
765    fn parse_value<'t>(
766        &mut self,
767        name: CowRcStr<'i>,
768        input: &mut Parser<'i, 't>,
769    ) -> Result<Self::Declaration, ParseError<'i, Self::Error>> {
770        if validate_css_tokens(input)? || (name.starts_with("--") && name.len() > 2) {
771            Ok(())
772        } else {
773            Err(input.new_custom_error(()))
774        }
775    }
776}
777
778impl<'i> AtRuleParser<'i> for SyntaxDeclarationParser {
779    type Prelude = ();
780    type AtRule = ();
781    type Error = ();
782}
783
784impl<'i> QualifiedRuleParser<'i> for SyntaxDeclarationParser {
785    type Prelude = ();
786    type QualifiedRule = ();
787    type Error = ();
788}
789
790impl<'i> RuleBodyItemParser<'i, (), ()> for SyntaxDeclarationParser {
791    fn parse_declarations(&self) -> bool {
792        true
793    }
794
795    fn parse_qualified(&self) -> bool {
796        false
797    }
798}
799
800struct SyntaxKeyframeParser;
801
802impl<'i> QualifiedRuleParser<'i> for SyntaxKeyframeParser {
803    type Prelude = ();
804    type QualifiedRule = ();
805    type Error = ();
806
807    fn parse_prelude<'t>(
808        &mut self,
809        input: &mut Parser<'i, 't>,
810    ) -> Result<Self::Prelude, ParseError<'i, Self::Error>> {
811        if validate_css_tokens(input)? {
812            Ok(())
813        } else {
814            Err(input.new_error(BasicParseErrorKind::QualifiedRuleInvalid))
815        }
816    }
817
818    fn parse_block<'t>(
819        &mut self,
820        _prelude: Self::Prelude,
821        _start: &cssparser::ParserState,
822        input: &mut Parser<'i, 't>,
823    ) -> Result<Self::QualifiedRule, ParseError<'i, Self::Error>> {
824        validate_declaration_list(input)
825    }
826}
827
828impl<'i> AtRuleParser<'i> for SyntaxKeyframeParser {
829    type Prelude = ();
830    type AtRule = ();
831    type Error = ();
832}
833
834fn validate_declaration_list<'i, 't>(input: &mut Parser<'i, 't>) -> Result<(), ParseError<'i, ()>> {
835    let mut syntax = SyntaxDeclarationParser;
836    for declaration in RuleBodyParser::new(input, &mut syntax) {
837        declaration.map_err(|(error, _)| error)?;
838    }
839    Ok(())
840}
841
842impl<'i> AtRuleParser<'i> for SyntaxRuleParser {
843    type Prelude = CowRcStr<'i>;
844    type AtRule = ();
845    type Error = ();
846
847    fn parse_prelude<'t>(
848        &mut self,
849        name: CowRcStr<'i>,
850        input: &mut Parser<'i, 't>,
851    ) -> Result<Self::Prelude, ParseError<'i, Self::Error>> {
852        if name.eq_ignore_ascii_case("media") {
853            validate_media_query_list(input)?;
854        } else {
855            validate_css_tokens(input)?;
856        }
857        Ok(name)
858    }
859
860    fn rule_without_block(
861        &mut self,
862        name: Self::Prelude,
863        _start: &cssparser::ParserState,
864    ) -> Result<Self::AtRule, ()> {
865        if name.eq_ignore_ascii_case("media") {
866            Err(())
867        } else {
868            Ok(())
869        }
870    }
871
872    fn parse_block<'t>(
873        &mut self,
874        name: Self::Prelude,
875        _start: &cssparser::ParserState,
876        input: &mut Parser<'i, 't>,
877    ) -> Result<Self::AtRule, ParseError<'i, Self::Error>> {
878        if name.eq_ignore_ascii_case("media") {
879            validate_stylesheet_rules(input)
880        } else if name.eq_ignore_ascii_case("keyframes")
881            || name.eq_ignore_ascii_case("-webkit-keyframes")
882        {
883            validate_keyframe_rules(input)
884        } else {
885            validate_css_tokens(input).map(|_| ())
886        }
887    }
888}
889
890fn validate_stylesheet_rules<'i, 't>(
891    parser: &mut Parser<'i, 't>,
892) -> Result<(), ParseError<'i, ()>> {
893    let mut syntax = SyntaxRuleParser;
894    for rule in StyleSheetParser::new(parser, &mut syntax) {
895        rule.map_err(|(error, _)| error)?;
896    }
897    Ok(())
898}
899
900fn validate_keyframe_rules<'i, 't>(parser: &mut Parser<'i, 't>) -> Result<(), ParseError<'i, ()>> {
901    let mut syntax = SyntaxKeyframeParser;
902    for rule in StyleSheetParser::new(parser, &mut syntax) {
903        rule.map_err(|(error, _)| error)?;
904    }
905    Ok(())
906}
907
908fn validate_selector_list<'i, 't>(input: &mut Parser<'i, 't>) -> Result<(), ParseError<'i, ()>> {
909    let options = LightningParserOptions::default();
910    let selectors = match SelectorList::parse_with_options(input, &options) {
911        Ok(selectors) => selectors,
912        Err(_) => return Err(input.new_error(BasicParseErrorKind::QualifiedRuleInvalid)),
913    };
914    if selectors.0.iter().any(selector_contains_nesting) {
915        return Err(input.new_error(BasicParseErrorKind::QualifiedRuleInvalid));
916    }
917    input.expect_exhausted().map_err(Into::into)
918}
919
920fn selector_contains_nesting(selector: &Selector<'_>) -> bool {
921    selector
922        .iter_raw_match_order()
923        .any(component_contains_nesting)
924}
925
926fn component_contains_nesting(component: &Component<'_>) -> bool {
927    match component {
928        Component::Nesting => true,
929        Component::Negation(selectors)
930        | Component::Where(selectors)
931        | Component::Is(selectors)
932        | Component::Has(selectors)
933        | Component::Any(_, selectors) => selectors.iter().any(selector_contains_nesting),
934        Component::Slotted(selector) => selector_contains_nesting(selector),
935        Component::Host(Some(selector)) => selector_contains_nesting(selector),
936        Component::NthOf(data) => data.selectors().iter().any(selector_contains_nesting),
937        _ => false,
938    }
939}
940
941fn validate_media_query_list<'i, 't>(input: &mut Parser<'i, 't>) -> Result<(), ParseError<'i, ()>> {
942    let options = LightningParserOptions::default();
943    let media = MediaList::parse(input, &options)
944        .map_err(|_| input.new_error(BasicParseErrorKind::AtRuleBodyInvalid))?;
945    if media.media_queries.is_empty() {
946        return Err(input.new_error(BasicParseErrorKind::AtRuleBodyInvalid));
947    }
948    input.expect_exhausted().map_err(Into::into)
949}
950
951fn validate_css_tokens<'i, 't>(parser: &mut Parser<'i, 't>) -> Result<bool, ParseError<'i, ()>> {
952    let mut meaningful = false;
953    loop {
954        let nested = match parser.next_including_whitespace_and_comments() {
955            Ok(token) if token.is_parse_error() => return Err(parser.new_custom_error(())),
956            Ok(token) => {
957                meaningful |= !matches!(token, Token::WhiteSpace(_) | Token::Comment(_));
958                matches!(
959                    token,
960                    Token::Function(_)
961                        | Token::ParenthesisBlock
962                        | Token::SquareBracketBlock
963                        | Token::CurlyBracketBlock
964                )
965            }
966            Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => {
967                return Ok(meaningful);
968            }
969            Err(error) => return Err(error.into()),
970        };
971        if nested {
972            parser.parse_nested_block(|input| validate_css_tokens(input).map(|_| ()))?;
973        }
974    }
975}
976
977fn strip_source_comments(css: &str) -> Result<String, GrimoireCssError> {
978    let mut input = ParserInput::new(css);
979    let mut parser = Parser::new(&mut input);
980    let mut ranges = Vec::new();
981    collect_comment_ranges(&mut parser, &mut ranges).map_err(|error| {
982        GrimoireCssError::InvalidInput(format!(
983            "Malformed CSS at line {}, column {}: {:?}",
984            error.location.line, error.location.column, error.kind
985        ))
986    })?;
987
988    if ranges.is_empty() {
989        return Ok(css.to_string());
990    }
991
992    let removed_bytes = ranges.iter().map(|(range, _)| range.len()).sum::<usize>();
993    let mut cleaned = String::with_capacity(css.len().saturating_sub(removed_bytes));
994    let mut cursor = 0;
995    for (range, separator) in ranges {
996        cleaned.push_str(&css[cursor..range.start]);
997        if separator {
998            cleaned.push_str("/**/");
999        }
1000        cursor = range.end;
1001    }
1002    cleaned.push_str(&css[cursor..]);
1003    Ok(cleaned)
1004}
1005
1006fn collect_comment_ranges<'i, 't>(
1007    parser: &mut Parser<'i, 't>,
1008    ranges: &mut Vec<(Range<usize>, bool)>,
1009) -> Result<(), ParseError<'i, ()>> {
1010    let mut previous = cssparser::TokenSerializationType::nothing();
1011    let mut pending_comment = None;
1012    loop {
1013        let start = parser.position().byte_index();
1014        let token = match parser.next_including_whitespace_and_comments() {
1015            Ok(token) => token.clone(),
1016            Err(error) if matches!(error.kind, BasicParseErrorKind::EndOfInput) => return Ok(()),
1017            Err(error) => return Err(error.into()),
1018        };
1019        if matches!(token, Token::Comment(_)) {
1020            pending_comment = Some(ranges.len());
1021            ranges.push((start..parser.position().byte_index(), false));
1022            continue;
1023        }
1024        if let Some(index) = pending_comment.take() {
1025            // Whitespace would change compound selectors into descendant selectors.
1026            ranges[index].1 = previous.needs_separator_when_before(token.serialization_type());
1027        }
1028        let nested = matches!(
1029            token,
1030            Token::Function(_)
1031                | Token::ParenthesisBlock
1032                | Token::SquareBracketBlock
1033                | Token::CurlyBracketBlock
1034        );
1035        previous = token.serialization_type();
1036        if nested {
1037            parser.parse_nested_block(|input| collect_comment_ranges(input, ranges))?;
1038            previous = Token::CloseParenthesis.serialization_type();
1039        }
1040    }
1041}
1042
1043fn close_block(blocks: &mut Vec<BlockKind>, expected: BlockKind) -> Result<(), GrimoireCssError> {
1044    if blocks.pop() == Some(expected) {
1045        Ok(())
1046    } else {
1047        Err(GrimoireCssError::InvalidInput(
1048            "Malformed CSS contains an unmatched closing delimiter".into(),
1049        ))
1050    }
1051}
1052
1053struct TransmuteRuleParser {
1054    area: Option<String>,
1055}
1056
1057fn conversion_error(message: impl Into<String>) -> GrimoireCssError {
1058    GrimoireCssError::InvalidInput(message.into())
1059}
1060
1061fn consume_rule_tokens(input: &mut Parser<'_, '_>) {
1062    while input.next_including_whitespace_and_comments().is_ok() {}
1063}
1064
1065impl<'i> QualifiedRuleParser<'i> for TransmuteRuleParser {
1066    type Prelude = Vec<(String, String)>;
1067    type QualifiedRule = TransmutedMap;
1068    type Error = GrimoireCssError;
1069
1070    fn parse_prelude<'t>(
1071        &mut self,
1072        input: &mut Parser<'i, 't>,
1073    ) -> Result<Self::Prelude, ParseError<'i, Self::Error>> {
1074        input.parse_comma_separated(|selector| {
1075            selector.expect_delim('.').map_err(|_| {
1076                selector.new_custom_error(conversion_error(
1077                    "Unsupported selector: migration requires a leading class selector",
1078                ))
1079            })?;
1080            let name = selector.expect_ident_cloned()?.to_string();
1081            let start = selector.position();
1082            consume_rule_tokens(selector);
1083            let suffix = trim_css_fragment_end(selector.slice_from(start))
1084                .map_err(|error| selector.new_custom_error(error))?
1085                .to_string();
1086            Ok((name, suffix))
1087        })
1088    }
1089
1090    fn parse_block<'t>(
1091        &mut self,
1092        selectors: Self::Prelude,
1093        _start: &cssparser::ParserState,
1094        input: &mut Parser<'i, 't>,
1095    ) -> Result<Self::QualifiedRule, ParseError<'i, Self::Error>> {
1096        let mut declarations = IndexSet::new();
1097        for item in RuleBodyParser::new(input, &mut TransmuteDeclarationParser) {
1098            insert_last(&mut declarations, item.map_err(|(error, _)| error)?);
1099        }
1100        let mut result = TransmutedMap::new();
1101        for (name, suffix) in selectors {
1102            if Spell::new(&name, &HashSet::new(), &None, (0, name.len()), None)
1103                .map_err(|error| input.new_custom_error(error))?
1104                .is_some()
1105            {
1106                continue;
1107            }
1108            let mut prefix = String::new();
1109            if let Some(area) = &self.area {
1110                // Prevent literal media types from matching named Grimoire breakpoints.
1111                if matches!(area.as_str(), "sm" | "md" | "lg" | "xl" | "2xl") {
1112                    prefix.push_str("/**/");
1113                }
1114                prefix.push_str(
1115                    &encode_css_fragment(area, false, false)
1116                        .map_err(|error| input.new_custom_error(error))?,
1117                );
1118                prefix.push_str("__");
1119            }
1120            if !suffix.is_empty() {
1121                prefix.push('{');
1122                prefix.push_str(
1123                    &encode_css_fragment(&suffix, false, false)
1124                        .map_err(|error| input.new_custom_error(error))?,
1125                );
1126                prefix.push('}');
1127            }
1128            let spells = result.entry(name).or_default();
1129            for declaration in &declarations {
1130                insert_last(spells, format!("{prefix}{declaration}"));
1131            }
1132        }
1133        Ok(result)
1134    }
1135}
1136
1137struct TransmuteDeclarationParser;
1138
1139impl<'i> DeclarationParser<'i> for TransmuteDeclarationParser {
1140    type Declaration = String;
1141    type Error = GrimoireCssError;
1142
1143    fn parse_value<'t>(
1144        &mut self,
1145        name: CowRcStr<'i>,
1146        input: &mut Parser<'i, 't>,
1147    ) -> Result<Self::Declaration, ParseError<'i, Self::Error>> {
1148        let start = input.position();
1149        consume_rule_tokens(input);
1150        let value = trim_css_fragment_end(input.slice_from(start))
1151            .map_err(|error| input.new_custom_error(error))?
1152            .trim_start_matches(is_css_whitespace);
1153        let name = if name.starts_with("--") {
1154            name.to_string()
1155        } else {
1156            name.to_ascii_lowercase()
1157        };
1158        if name == "g-anim"
1159            || crate::core::component::get_css_property(&name)
1160                .is_some_and(|property| property != name)
1161        {
1162            return Err(input.new_custom_error(conversion_error(format!(
1163                "CSS property '{name}' would invoke a Grimoire component with different semantics; use a full CSS property name before migration"
1164            ))));
1165        }
1166        if name.starts_with("--") {
1167            let token = format!("{name}=initial");
1168            let preserves_name = Spell::new(&token, &HashSet::new(), &None, (0, token.len()), None)
1169                .ok()
1170                .flatten()
1171                .is_some_and(|spell| {
1172                    spell.component() == name
1173                        && spell.area().is_empty()
1174                        && spell.focus().is_empty()
1175                        && spell.effects().is_empty()
1176                        && !spell.with_template
1177                });
1178            if !preserves_name {
1179                return Err(input.new_custom_error(conversion_error(format!(
1180                    "Unsupported custom property name {name:?}: the Grimoire Spell parser cannot preserve this name; keep the original CSS in shared.styles"
1181                ))));
1182            }
1183        }
1184        let protect_animation_names = matches!(name.as_str(), "animation" | "animation-name");
1185        Ok(format!(
1186            "{name}={}",
1187            encode_css_fragment(value, true, protect_animation_names)
1188                .map_err(|error| input.new_custom_error(error))?
1189        ))
1190    }
1191}
1192
1193impl<'i> AtRuleParser<'i> for TransmuteDeclarationParser {
1194    type Prelude = ();
1195    type AtRule = String;
1196    type Error = GrimoireCssError;
1197}
1198impl<'i> QualifiedRuleParser<'i> for TransmuteDeclarationParser {
1199    type Prelude = ();
1200    type QualifiedRule = String;
1201    type Error = GrimoireCssError;
1202}
1203impl<'i> RuleBodyItemParser<'i, String, GrimoireCssError> for TransmuteDeclarationParser {
1204    fn parse_declarations(&self) -> bool {
1205        true
1206    }
1207    fn parse_qualified(&self) -> bool {
1208        false
1209    }
1210}
1211
1212impl<'i> AtRuleParser<'i> for TransmuteRuleParser {
1213    type Prelude = String;
1214    type AtRule = TransmutedMap;
1215    type Error = GrimoireCssError;
1216
1217    fn parse_prelude<'t>(
1218        &mut self,
1219        name: CowRcStr<'i>,
1220        input: &mut Parser<'i, 't>,
1221    ) -> Result<Self::Prelude, ParseError<'i, Self::Error>> {
1222        if !name.eq_ignore_ascii_case("media") {
1223            return Err(input.new_custom_error(conversion_error(format!(
1224                "Unsupported CSS at-rule '@{name}'; no partial migration was produced"
1225            ))));
1226        }
1227        let start = input.position();
1228        consume_rule_tokens(input);
1229        let area = trim_css_fragment_end(input.slice_from(start))
1230            .map_err(|error| input.new_custom_error(error))?
1231            .trim_start_matches(is_css_whitespace);
1232        match &self.area {
1233            None => Ok(area.to_string()),
1234            Some(parent) => {
1235                intersect_media_queries(parent, area).map_err(|error| input.new_custom_error(error))
1236            }
1237        }
1238    }
1239
1240    fn parse_block<'t>(
1241        &mut self,
1242        area: Self::Prelude,
1243        _start: &cssparser::ParserState,
1244        input: &mut Parser<'i, 't>,
1245    ) -> Result<Self::AtRule, ParseError<'i, Self::Error>> {
1246        collect_transmuted_rules(input, Some(area))
1247    }
1248}
1249
1250fn intersect_media_queries(parent: &str, child: &str) -> Result<String, GrimoireCssError> {
1251    let options = LightningParserOptions::default();
1252    let mut parent_input = ParserInput::new(parent);
1253    let mut child_input = ParserInput::new(child);
1254    let parent = MediaList::parse(&mut Parser::new(&mut parent_input), &options)
1255        .map_err(|error| conversion_error(format!("Invalid parent media query: {error:?}")))?;
1256    let child = MediaList::parse(&mut Parser::new(&mut child_input), &options)
1257        .map_err(|error| conversion_error(format!("Invalid nested media query: {error:?}")))?;
1258    // Media lists are disjunctions: intersect every pair of queries.
1259    let mut queries = Vec::new();
1260    for outer in &parent.media_queries {
1261        for inner in &child.media_queries {
1262            let mut combined = outer.clone();
1263            combined.and(inner).map_err(|_| conversion_error(
1264                "Unsupported intersection of nested media queries; no partial migration was produced"
1265            ))?;
1266            queries.push(combined);
1267        }
1268    }
1269    MediaList {
1270        media_queries: queries,
1271    }
1272    .to_css_string(Default::default())
1273    .map_err(|error| conversion_error(format!("Cannot serialize media query: {error}")))
1274}
1275
1276fn collect_transmuted_rules<'i, 't>(
1277    input: &mut Parser<'i, 't>,
1278    area: Option<String>,
1279) -> Result<TransmutedMap, ParseError<'i, GrimoireCssError>> {
1280    let mut result = TransmutedMap::new();
1281    for rule in StyleSheetParser::new(input, &mut TransmuteRuleParser { area }) {
1282        merge_maps(&mut result, rule.map_err(|(error, _)| error)?);
1283    }
1284    Ok(result)
1285}
1286
1287fn process_css_into_raw_spells(css_input: &str) -> Result<TransmutedMap, GrimoireCssError> {
1288    let mut input = ParserInput::new(css_input);
1289    collect_transmuted_rules(&mut Parser::new(&mut input), None).map_err(|error| match error.kind {
1290        cssparser::ParseErrorKind::Custom(error) => error,
1291        _ => conversion_error(format!("Cannot migrate CSS: {error:?}")),
1292    })
1293}
1294
1295/// Transmutes CSS files matched below an explicit project root.
1296pub fn transmute_paths(
1297    root: &Path,
1298    patterns: &[String],
1299    options: TransmuteOptions,
1300) -> Result<Transmutation, GrimoireCssError> {
1301    if patterns.is_empty() {
1302        return Err(GrimoireCssError::InvalidInput(
1303            "No CSS file patterns provided.".into(),
1304        ));
1305    }
1306
1307    let expanded_paths = expand_file_paths(root, patterns)?;
1308    if expanded_paths.is_empty() {
1309        return Err(GrimoireCssError::InvalidPath(
1310            "No files found matching the provided patterns.".into(),
1311        ));
1312    }
1313
1314    let all_css_string = read_and_clean_files(&expanded_paths)?;
1315    transmute_css(&all_css_string, options)
1316}
1317
1318/// Transmutes inline CSS without filesystem access.
1319pub fn transmute_css(
1320    css_content: &str,
1321    options: TransmuteOptions,
1322) -> Result<Transmutation, GrimoireCssError> {
1323    validate_css_syntax(css_content)?;
1324    let css_without_comments = strip_source_comments(css_content)?;
1325    let processed_css = process_css_into_raw_spells(&css_without_comments)?;
1326
1327    if processed_css.is_empty() {
1328        return Err(GrimoireCssError::InvalidInput(
1329            "There is nothing to transmute.".into(),
1330        ));
1331    }
1332
1333    let mut transmuted = Transmutation {
1334        scrolls: Vec::with_capacity(processed_css.len()),
1335    };
1336    let animations = HashMap::new();
1337    let generator = CssGenerator::new(&None, &animations)?;
1338
1339    for (name, spells) in processed_css {
1340        if !name.is_empty() && !spells.is_empty() {
1341            validate_scroll_name(&name, &generator)?;
1342            let spells = spells.into_iter().collect::<Vec<_>>();
1343            let oneliner = if options.with_oneliner {
1344                Some(spells.join(" "))
1345            } else {
1346                None
1347            };
1348
1349            transmuted.scrolls.push(TransmutedScroll {
1350                name,
1351                spells,
1352                oneliner,
1353            });
1354        }
1355    }
1356
1357    if transmuted.scrolls.is_empty() {
1358        return Err(GrimoireCssError::InvalidInput(
1359            "There is nothing to transmute.".into(),
1360        ));
1361    }
1362
1363    transmuted.validate_component_scroll_conflicts(&HashSet::new())?;
1364    validate_scroll_cascade(css_content, &transmuted)?;
1365    Ok(transmuted)
1366}
1367
1368fn expand_file_paths(cwd: &Path, patterns: &[String]) -> Result<Vec<PathBuf>, GrimoireCssError> {
1369    let mut paths = Vec::with_capacity(patterns.len() * 4);
1370    let canonical_root = fs::canonicalize(cwd).map_err(GrimoireCssError::Io)?;
1371
1372    for pattern in patterns {
1373        let input = Path::new(pattern);
1374        if pattern.is_empty()
1375            || input.is_absolute()
1376            || input.components().any(|component| {
1377                matches!(
1378                    component,
1379                    std::path::Component::ParentDir
1380                        | std::path::Component::RootDir
1381                        | std::path::Component::Prefix(_)
1382                )
1383            })
1384        {
1385            return Err(GrimoireCssError::InvalidPath(format!(
1386                "CSS paths must stay below the explicit root: {pattern}"
1387            )));
1388        }
1389        let root_text = canonical_root.to_string_lossy();
1390        let prefix_len = match canonical_root.components().next() {
1391            Some(std::path::Component::Prefix(prefix)) => {
1392                prefix.as_os_str().to_string_lossy().len()
1393            }
1394            _ => 0,
1395        };
1396        // Windows device prefixes are path syntax, not glob metacharacters.
1397        let mut absolute_pattern = root_text[..prefix_len].to_string();
1398        absolute_pattern.push_str(&glob::Pattern::escape(&root_text[prefix_len..]));
1399        if !absolute_pattern.ends_with(std::path::MAIN_SEPARATOR) {
1400            absolute_pattern.push(std::path::MAIN_SEPARATOR);
1401        }
1402        absolute_pattern.push_str(pattern);
1403
1404        for entry_result in glob(&absolute_pattern)
1405            .map_err(|e| GrimoireCssError::GlobPatternError(e.msg.to_string()))?
1406        {
1407            match entry_result {
1408                Ok(path) if path.is_file() => {
1409                    let canonical = fs::canonicalize(&path).map_err(GrimoireCssError::Io)?;
1410                    if !canonical.starts_with(&canonical_root) {
1411                        return Err(GrimoireCssError::InvalidPath(format!(
1412                            "CSS path escapes the explicit root: {}",
1413                            path.display()
1414                        )));
1415                    }
1416                    paths.push(canonical);
1417                }
1418                Ok(_) => {}
1419                Err(e) => return Err(GrimoireCssError::InvalidPath(e.to_string())),
1420            }
1421        }
1422    }
1423
1424    paths.sort();
1425    paths.dedup();
1426
1427    if paths.len() < paths.capacity() / 2 {
1428        paths.shrink_to_fit();
1429    }
1430
1431    Ok(paths)
1432}