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 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
114fn 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 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
196fn 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
238fn 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 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
304fn 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 result.push('_');
414 }
415}
416
417fn 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 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 if protect_animation_names {
458 result.push_str(&format!("\\{:06x}", value as u32));
459 continue;
460 }
461 match value {
462 ' ' => result.push('_'),
463 '_' | '$' | '(' => {
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 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 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 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 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 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 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 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 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
1295pub 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
1318pub 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 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}