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>,
}
impl FsImporter {
pub fn new(load_paths: Vec<PathBuf>) -> Self {
FsImporter { load_paths }
}
}
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 base in bases {
match resolve_in_base(&base, url, ctx.from_import) {
Resolution::Found(p) => {
let key = std::fs::canonicalize(&p)
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_else(|_| p.to_string_lossy().into_owned());
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 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)
}