sass_embedded/legacy/
importer.rs

1use std::{
2  collections::VecDeque,
3  env,
4  fmt::Debug,
5  fs,
6  path::{Path, PathBuf},
7  sync::Arc,
8  time::{SystemTime, UNIX_EPOCH},
9};
10
11use parking_lot::Mutex;
12use regex::Regex;
13use urlencoding::encode;
14
15use crate::{
16  Exception, Importer, ImporterOptions, ImporterResult, Result, Syntax, Url,
17};
18
19use super::{LegacyImporterResult, LegacyImporterThis, LegacyPluginThis};
20
21pub(crate) const END_OF_LOAD_PROTOCOL: &str = "sass-embedded-legacy-load-done:";
22pub(crate) const LEGACY_IMPORTER_PROTOCOL: &str = "legacy-importer:";
23
24/// More information: [Sass documentation](https://sass-lang.com/documentation/js-api/modules#LegacyImporter)
25pub trait LegacyImporter: Debug + Sync + Send {
26  /// implements of [LegacyImporter].
27  fn call(
28    &self,
29    this: &LegacyImporterThis,
30    url: &str,
31    prev: &str,
32  ) -> Result<Option<LegacyImporterResult>>;
33}
34
35/// A type alias for [Box<dyn LegacyImporter>].
36pub type BoxLegacyImporter = Box<dyn LegacyImporter>;
37
38impl<I: 'static + LegacyImporter> From<I> for BoxLegacyImporter {
39  fn from(importer: I) -> Self {
40    Box::new(importer)
41  }
42}
43
44#[derive(Debug)]
45pub(crate) struct LegacyImporterWrapper {
46  prev_stack: Mutex<Vec<PreviousUrl>>,
47  last_contents: Mutex<Option<String>>,
48  expecting_relative_load: Mutex<bool>,
49  callbacks: Vec<BoxLegacyImporter>,
50  this: LegacyPluginThis,
51  load_paths: Vec<PathBuf>,
52}
53
54impl LegacyImporterWrapper {
55  pub fn new(
56    this: LegacyPluginThis,
57    callbacks: Vec<BoxLegacyImporter>,
58    load_paths: Vec<PathBuf>,
59    initial_prev: &str,
60  ) -> Arc<Self> {
61    let path = initial_prev != "stdin";
62    Arc::new(Self {
63      prev_stack: Mutex::new(vec![PreviousUrl {
64        url: if path {
65          initial_prev.to_string()
66        } else {
67          "stdin".to_string()
68        },
69        path,
70      }]),
71      last_contents: Mutex::new(None),
72      expecting_relative_load: Mutex::new(true),
73      callbacks,
74      this,
75      load_paths,
76    })
77  }
78
79  fn invoke_callbacks(
80    &self,
81    url: &str,
82    prev: &str,
83    options: &ImporterOptions,
84  ) -> Result<Option<LegacyImporterResult>> {
85    assert!(!self.callbacks.is_empty());
86
87    let this = LegacyImporterThis {
88      options: self.this.options.clone(),
89      from_import: options.from_import,
90    };
91    for callback in &self.callbacks {
92      match callback.call(&this, url, prev) {
93        Ok(Some(result)) => return Ok(Some(result)),
94        Ok(None) => continue,
95        Err(e) => return Err(e),
96      }
97    }
98    Ok(None)
99  }
100}
101
102impl Importer for Arc<LegacyImporterWrapper> {
103  fn canonicalize(
104    &self,
105    url: &str,
106    options: &ImporterOptions,
107  ) -> Result<Option<url::Url>> {
108    if url.starts_with(END_OF_LOAD_PROTOCOL) {
109      return Ok(Some(Url::parse(url).unwrap()));
110    }
111
112    let mut prev_stack = self.prev_stack.lock();
113
114    let mut expecting_relative_load = self.expecting_relative_load.lock();
115    if *expecting_relative_load {
116      if url.starts_with("file:") {
117        let path = url_to_file_path_cross_platform(&Url::parse(url).unwrap());
118        let resolved = resolve_path(path, options.from_import)?;
119        if let Some(p) = resolved {
120          prev_stack.push(PreviousUrl {
121            url: p.to_string_lossy().to_string(),
122            path: true,
123          });
124          return Ok(Some(Url::from_file_path(p).unwrap()));
125        }
126      }
127      *expecting_relative_load = false;
128      return Ok(None);
129    } else {
130      *expecting_relative_load = true;
131    }
132
133    let prev = prev_stack.last().unwrap();
134    let result = match self.invoke_callbacks(url, &prev.url, options) {
135      Err(e) => Err(e),
136      Ok(None) => Ok(None),
137      Ok(Some(result)) => match result {
138        LegacyImporterResult::Contents { contents, file } => {
139          *self.last_contents.lock() = Some(contents);
140          Ok(Some(if let Some(file) = file {
141            Url::parse(&format!(
142              "{}{}",
143              LEGACY_IMPORTER_PROTOCOL,
144              encode(&file.to_string_lossy())
145            ))
146            .unwrap()
147          } else if Regex::new("^[A-Za-z+.-]+:").unwrap().is_match(url) {
148            Url::parse(url).unwrap()
149          } else {
150            Url::parse(&format!("{}{}", LEGACY_IMPORTER_PROTOCOL, encode(url)))
151              .unwrap()
152          }))
153        }
154        LegacyImporterResult::File(file) => {
155          if file.is_absolute() {
156            let resolved = resolve_path(file, options.from_import)?;
157            Ok(resolved.map(|p| Url::from_file_path(p).unwrap()))
158          } else {
159            let mut prefixes = VecDeque::from(self.load_paths.clone());
160            prefixes.push_back(PathBuf::from("."));
161            if prev.path {
162              prefixes.push_front(
163                Path::new(&prev.url).parent().unwrap().to_path_buf(),
164              );
165            }
166            let mut resolved = None;
167            for prefix in prefixes {
168              if let Some(p) = resolve_path(
169                Path::new(&prefix).join(file.clone()),
170                options.from_import,
171              )? {
172                let p = if p.is_absolute() {
173                  p
174                } else {
175                  env::current_dir().unwrap().join(p)
176                };
177                resolved = Some(Url::from_file_path(p).unwrap());
178                break;
179              }
180            }
181            Ok(resolved)
182          }
183        }
184      },
185    }?;
186    if let Some(result) = &result {
187      let path = result.scheme() == "file";
188      prev_stack.push(PreviousUrl {
189        url: if path {
190          url_to_file_path_cross_platform(result)
191            .to_string_lossy()
192            .to_string()
193        } else {
194          url.to_string()
195        },
196        path,
197      });
198    } else {
199      for load_path in &self.load_paths {
200        let resolved =
201          resolve_path(Path::new(&load_path).join(url), options.from_import)?;
202        if let Some(p) = resolved {
203          return Ok(Some(Url::from_file_path(p).unwrap()));
204        }
205      }
206    }
207    Ok(result)
208  }
209
210  fn load(&self, canonical_url: &Url) -> Result<Option<ImporterResult>> {
211    let protocol = format!("{}:", canonical_url.scheme());
212    if protocol == END_OF_LOAD_PROTOCOL {
213      self.prev_stack.lock().pop();
214      return Ok(Some(ImporterResult {
215        contents: String::new(),
216        source_map_url: Some(Url::parse(END_OF_LOAD_PROTOCOL).unwrap()),
217        syntax: Syntax::Scss,
218      }));
219    }
220    let timestamp = SystemTime::now()
221      .duration_since(UNIX_EPOCH)
222      .unwrap()
223      .as_micros();
224    if protocol == "file:" {
225      let syntax = if canonical_url.path().ends_with(".sass") {
226        Syntax::Indented
227      } else if canonical_url.path().ends_with(".css") {
228        Syntax::Css
229      } else {
230        Syntax::Scss
231      };
232      let mut last_contents = self.last_contents.lock();
233      let contents = last_contents.clone().unwrap_or_else(|| {
234        fs::read_to_string(url_to_file_path_cross_platform(canonical_url))
235          .unwrap()
236      });
237      *last_contents = None;
238      let contents = match syntax {
239        Syntax::Scss => {
240          format!("{contents}\n;@import \"{END_OF_LOAD_PROTOCOL}{timestamp}\"")
241        }
242        Syntax::Indented => {
243          format!("{contents}\n@import \"{END_OF_LOAD_PROTOCOL}{timestamp}\"")
244        }
245        Syntax::Css => {
246          self.prev_stack.lock().pop();
247          contents
248        }
249      };
250      return Ok(Some(ImporterResult {
251        contents,
252        syntax,
253        source_map_url: Some(canonical_url.clone()),
254      }));
255    }
256    let mut last_contents = self.last_contents.lock();
257    assert!(last_contents.is_some());
258    let contents = format!(
259      "{}\n;@import \"{END_OF_LOAD_PROTOCOL}{timestamp}\"",
260      last_contents.clone().unwrap()
261    );
262    *last_contents = None;
263    Ok(Some(ImporterResult {
264      contents,
265      syntax: Syntax::Scss,
266      source_map_url: Some(canonical_url.clone()),
267    }))
268  }
269}
270
271#[derive(Debug)]
272struct PreviousUrl {
273  url: String,
274  path: bool,
275}
276
277pub(crate) fn url_to_file_path_cross_platform(file_url: &Url) -> PathBuf {
278  let p = file_url
279    .to_file_path()
280    .unwrap()
281    .to_string_lossy()
282    .to_string();
283  if Regex::new("^/[A-Za-z]:/").unwrap().is_match(&p) {
284    PathBuf::from(&p[1..])
285  } else {
286    PathBuf::from(p)
287  }
288}
289
290fn resolve_path(path: PathBuf, from_import: bool) -> Result<Option<PathBuf>> {
291  let extension = path.extension();
292  if let Some(extension) = extension {
293    if extension == "sass" || extension == "scss" || extension == "css" {
294      if from_import {
295        if let Ok(Some(p)) = exactly_one(try_path(Path::new(&format!(
296          "{}.import.{}",
297          without_extension(&path).to_string_lossy(),
298          extension.to_string_lossy()
299        )))) {
300          return Ok(Some(p));
301        }
302      }
303      return exactly_one(try_path(&path));
304    }
305  }
306  if from_import {
307    if let Ok(Some(p)) = exactly_one(try_path_with_extensions(Path::new(
308      &format!("{}.import", path.file_stem().unwrap().to_string_lossy()),
309    ))) {
310      return Ok(Some(p));
311    }
312  }
313  if let Ok(Some(p)) = exactly_one(try_path_with_extensions(&path)) {
314    return Ok(Some(p));
315  }
316  try_path_as_directory(&path.join("index"), from_import)
317}
318
319fn exactly_one(paths: Vec<PathBuf>) -> Result<Option<PathBuf>> {
320  if paths.is_empty() {
321    Ok(None)
322  } else if paths.len() == 1 {
323    Ok(Some(paths[0].clone()))
324  } else {
325    Err(
326      Exception::new(format!(
327        "It's not clear which file to import. Found:\n{}",
328        paths
329          .iter()
330          .map(|p| format!("  {}", p.to_string_lossy()))
331          .collect::<Vec<String>>()
332          .join("\n")
333      ))
334      .into(),
335    )
336  }
337}
338
339fn dir_exists(path: &Path) -> bool {
340  path.exists() && path.is_dir()
341}
342
343fn file_exists(path: &Path) -> bool {
344  path.exists() && path.is_file()
345}
346
347fn try_path_as_directory(
348  path: &Path,
349  from_import: bool,
350) -> Result<Option<PathBuf>> {
351  if !dir_exists(path) {
352    return Ok(None);
353  }
354  if from_import {
355    if let Ok(Some(p)) =
356      exactly_one(try_path_with_extensions(&path.join("index.import")))
357    {
358      return Ok(Some(p));
359    }
360  }
361  exactly_one(try_path_with_extensions(&path.join("index")))
362}
363
364fn try_path_with_extensions(path: &Path) -> Vec<PathBuf> {
365  let result = [
366    try_path(Path::new(&format!("{}.sass", path.to_string_lossy()))),
367    try_path(Path::new(&format!("{}.scss", path.to_string_lossy()))),
368  ]
369  .concat();
370  if result.is_empty() {
371    try_path(Path::new(&format!("{}.css", path.to_string_lossy())))
372  } else {
373    result
374  }
375}
376
377fn try_path(path: &Path) -> Vec<PathBuf> {
378  let partial = path
379    .parent()
380    .unwrap()
381    .join(format!("_{}", path.file_name().unwrap().to_string_lossy()));
382  let mut result = Vec::new();
383  if file_exists(&partial) {
384    result.push(partial);
385  }
386  if file_exists(path) {
387    result.push(path.to_path_buf());
388  }
389  result
390}
391
392fn without_extension(path: &Path) -> PathBuf {
393  let mut result = path.to_path_buf();
394  result.set_extension("");
395  result
396}