use std::path::{Path, PathBuf};
use crate::Syntax;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CanonicalUrl(String);
impl CanonicalUrl {
pub fn new(url: impl Into<String>) -> Self {
CanonicalUrl(url.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug)]
pub struct ImporterResult {
pub contents: String,
pub syntax: Syntax,
pub source_map_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ImporterError {
pub message: String,
}
pub struct CanonicalizeContext<'a> {
pub from_import: bool,
pub containing_url: Option<&'a CanonicalUrl>,
}
pub trait Importer {
fn canonicalize(
&self,
url: &str,
ctx: &CanonicalizeContext<'_>,
) -> Result<Option<CanonicalUrl>, ImporterError>;
fn load(&self, canonical: &CanonicalUrl) -> Result<Option<ImporterResult>, ImporterError>;
}
pub struct FsImporter {
load_paths: Vec<PathBuf>,
dependencies: DependencySet,
}
impl FsImporter {
pub fn new(load_paths: Vec<PathBuf>) -> Self {
FsImporter {
load_paths,
dependencies: DependencySet::default(),
}
}
pub fn dependencies(&self) -> DependencySet {
self.dependencies.clone()
}
}
#[derive(Clone)]
pub struct DependencySet(std::sync::Arc<std::sync::Mutex<DependencyState>>);
impl std::fmt::Debug for DependencySet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.lock() {
Some((_paused, st)) => f
.debug_struct("DependencySet")
.field("set", &st.set)
.field("depth", &st.depth)
.finish(),
None => f.write_str("DependencySet(<poisoned>)"),
}
}
}
impl Default for DependencySet {
fn default() -> Self {
let _paused = crate::arena::pause();
DependencySet(std::sync::Arc::new(std::sync::Mutex::new(
DependencyState::default(),
)))
}
}
#[derive(Default, Debug)]
struct DependencyState {
set: std::collections::HashSet<String>,
depth: usize,
}
impl DependencySet {
fn lock(&self) -> Option<(crate::arena::Paused, std::sync::MutexGuard<'_, DependencyState>)> {
let paused = crate::arena::pause();
let guard = self.0.lock().ok()?;
Some((paused, guard))
}
pub fn is_dependency(&self, canonical: &str) -> bool {
self.lock()
.map(|(_paused, st)| st.set.contains(canonical))
.unwrap_or(false)
}
pub fn clear(&self) {
if let Some((_paused, mut st)) = self.lock() {
st.set.clear();
}
}
pub(crate) fn insert(&self, canonical: &str) {
if let Some((_paused, mut st)) = self.lock() {
st.set.insert(canonical.to_string());
}
}
pub(crate) fn enter_compile(&self) -> DependencyScope {
let saved = match self.lock() {
Some((_paused, mut st)) => {
st.depth += 1;
std::mem::take(&mut st.set)
}
None => std::collections::HashSet::new(),
};
DependencyScope {
set: self.clone(),
saved,
}
}
}
pub(crate) struct DependencyScope {
set: DependencySet,
saved: std::collections::HashSet<String>,
}
impl Drop for DependencyScope {
fn drop(&mut self) {
if let Some((_paused, mut st)) = self.set.lock() {
st.depth = st.depth.saturating_sub(1);
if st.depth > 0 {
st.set = std::mem::take(&mut self.saved);
}
}
}
}
impl Importer for FsImporter {
fn canonicalize(
&self,
url: &str,
ctx: &CanonicalizeContext<'_>,
) -> Result<Option<CanonicalUrl>, ImporterError> {
let base_dir: PathBuf = match ctx.containing_url {
Some(c) => match Path::new(c.as_str()).parent() {
Some(par) if !par.as_os_str().is_empty() => par.to_path_buf(),
_ => PathBuf::new(),
},
None => PathBuf::new(),
};
let bases = std::iter::once(base_dir).chain(self.load_paths.iter().cloned());
for (i, base) in bases.enumerate() {
match resolve_in_base(&base, url, ctx.from_import) {
Resolution::Found(p) => {
let key = absolute_normalized(&p);
let via_load_path = i > 0;
let from_dependency = ctx
.containing_url
.map(|c| self.dependencies.is_dependency(c.as_str()))
.unwrap_or(false);
if via_load_path || from_dependency {
self.dependencies.insert(&key);
}
return Ok(Some(CanonicalUrl::new(key)));
}
Resolution::Ambiguous => return Ok(None),
Resolution::NotFound => {}
}
}
Ok(None)
}
fn load(&self, canonical: &CanonicalUrl) -> Result<Option<ImporterResult>, ImporterError> {
let p = Path::new(canonical.as_str());
match std::fs::read_to_string(p) {
Ok(contents) => Ok(Some(ImporterResult {
contents,
syntax: syntax_for_path(p),
source_map_url: None,
})),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ImporterError {
message: format!("Cannot read {}: {e}", p.display()),
}),
}
}
}
fn syntax_for_path(p: &Path) -> Syntax {
match p.extension().and_then(|e| e.to_str()) {
Some(e) if e.eq_ignore_ascii_case("sass") => Syntax::Sass,
Some(e) if e.eq_ignore_ascii_case("css") => Syntax::Css,
_ => Syntax::Scss,
}
}
enum Resolution {
Found(PathBuf),
Ambiguous,
NotFound,
}
fn resolve_in_base(base: &Path, path: &str, allow_import_only: bool) -> Resolution {
let normalized = lexical_normalize(path);
let path = normalized.as_str();
let p = Path::new(path);
let dir = match p.parent() {
Some(par) if !par.as_os_str().is_empty() => base.join(par),
_ => base.to_path_buf(),
};
let file = p.file_name().and_then(|s| s.to_str()).unwrap_or(path);
if let Some(stem) = file.strip_suffix(".css") {
return match tier_exact(&dir, stem, &["css"], false) {
Tier::One(p) => Resolution::Found(p),
Tier::Many => Resolution::Ambiguous,
Tier::None => Resolution::NotFound,
};
}
let explicit_ext = [".scss", ".sass"].into_iter().find(|ext| file.ends_with(ext));
if let Some(ext) = explicit_ext {
let stem = &file[..file.len() - ext.len()];
let ext = &ext[1..];
if allow_import_only {
match tier_exact(&dir, stem, &[ext], true) {
Tier::One(p) => return Resolution::Found(p),
Tier::Many => return Resolution::Ambiguous,
Tier::None => {}
}
}
return match tier_exact(&dir, stem, &[ext], false) {
Tier::One(p) => Resolution::Found(p),
Tier::Many => Resolution::Ambiguous,
Tier::None => Resolution::NotFound,
};
}
let mut non_index: Vec<(&str, bool)> = Vec::with_capacity(2);
if allow_import_only {
non_index.push((file, true));
}
non_index.push((file, false));
for (stem, import_only) in &non_index {
match tier_with_extensions(&dir, stem, *import_only) {
Tier::One(p) => return Resolution::Found(p),
Tier::Many => return Resolution::Ambiguous,
Tier::None => {}
}
}
match tier_exact(&dir, file, &["css"], false) {
Tier::One(p) => return Resolution::Found(p),
Tier::Many => return Resolution::Ambiguous,
Tier::None => {}
}
let index_dir = dir.join(file);
let index_modes: &[bool] = if allow_import_only {
&[true, false]
} else {
&[false]
};
for import_only in index_modes {
match tier_with_extensions(&index_dir, "index", *import_only) {
Tier::One(p) => return Resolution::Found(p),
Tier::Many => return Resolution::Ambiguous,
Tier::None => {}
}
}
Resolution::NotFound
}
fn absolute_normalized(p: &Path) -> String {
use std::path::Component;
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(p))
.unwrap_or_else(|_| p.to_path_buf())
};
let mut out = PathBuf::new();
for comp in abs.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if matches!(out.components().next_back(), Some(Component::Normal(_))) {
out.pop();
}
}
c => out.push(c),
}
}
let out = out.to_string_lossy().into_owned();
#[cfg(windows)]
let out = out.to_lowercase();
out
}
fn lexical_normalize(path: &str) -> String {
let mut out: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
"" | "." => {}
".." => {
if matches!(out.last(), Some(&s) if s != "..") {
out.pop();
} else {
out.push("..");
}
}
s => out.push(s),
}
}
let mut s = out.join("/");
if path.starts_with('/') {
s.insert(0, '/');
}
if s.is_empty() {
s.push('.');
}
s
}
enum Tier {
None,
One(PathBuf),
Many,
}
impl Tier {
fn from(mut found: Vec<PathBuf>) -> Tier {
match found.len() {
0 => Tier::None,
1 => Tier::One(found.pop().unwrap_or_default()),
_ => Tier::Many,
}
}
}
fn tier_with_extensions(dir: &Path, stem: &str, import_only: bool) -> Tier {
tier_exact(dir, stem, &["scss", "sass"], import_only)
}
fn tier_exact(dir: &Path, stem: &str, exts: &[&str], import_only: bool) -> Tier {
let mut found = Vec::new();
let suffix = if import_only { ".import" } else { "" };
for ext in exts {
for name in [format!("_{stem}{suffix}.{ext}"), format!("{stem}{suffix}.{ext}")] {
let cand = dir.join(&name);
if cand.is_file() {
found.push(cand);
}
}
}
Tier::from(found)
}