use anyhow::{Result, bail};
use regex::Regex;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::config::SassAnalyzerConfig;
use crate::errors;
use crate::file_index::FileIndex;
use crate::graph::Product;
use super::{DepAnalyzer, ScanResult};
const SASS_MATCH_EXTENSIONS: &[&str] = &["scss", "sass"];
const LOAD_EXTENSIONS: &[&str] = &["scss", "sass", "css"];
#[derive(Debug, PartialEq, Eq)]
enum Rule {
UseOrForward,
Import,
}
pub struct SassDepAnalyzer {
config: SassAnalyzerConfig,
}
impl SassDepAnalyzer {
pub const fn new(config: SassAnalyzerConfig) -> Self {
Self { config }
}
fn resolve(&self, url: &str, including_dir: &Path) -> Option<PathBuf> {
std::iter::once(including_dir.to_path_buf())
.chain(self.config.load_paths.iter().map(PathBuf::from))
.find_map(|dir| resolve_in(&dir.join(url)))
}
fn scan_source(&self, source: &Path) -> Result<ScanResult> {
let mut deps: Vec<PathBuf> = Vec::new();
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut scanned: HashSet<PathBuf> = HashSet::new();
self.scan_recursive(source, &mut deps, &mut seen, &mut scanned)?;
Ok(ScanResult::deps(deps, Vec::new()))
}
fn scan_recursive(
&self,
file: &Path,
deps: &mut Vec<PathBuf>,
seen: &mut HashSet<PathBuf>,
scanned: &mut HashSet<PathBuf>,
) -> Result<()> {
let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
if !scanned.insert(canonical) {
return Ok(());
}
let content = errors::ctx(
fs::read_to_string(file),
&format!("Failed to read {}", file.display()),
)?;
let indented = file.extension().is_some_and(|e| e == "sass");
let including_dir = crate::processor::parent_dir_or_empty(file);
for url in load_urls(&content, indented) {
let Some(resolved) = self.resolve(&url, including_dir) else {
if self.config.skip_not_found {
continue;
}
bail!(
"Sass import not found: \"{}\" in {} (searched {} and load_paths {:?}). \
Add its directory to load_paths, or set skip_not_found = true.",
url,
file.display(),
if including_dir.as_os_str().is_empty() {
Path::new(".").display()
} else {
including_dir.display()
},
self.config.load_paths,
);
};
if seen.insert(resolved.clone()) {
deps.push(resolved.clone());
}
let is_sass = resolved
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| SASS_MATCH_EXTENSIONS.contains(&e));
if is_sass {
self.scan_recursive(&resolved, deps, seen, scanned)?;
}
}
Ok(())
}
}
fn load_urls(content: &str, indented: bool) -> Vec<String> {
static SCSS_RULE_RE: OnceLock<Regex> = OnceLock::new();
static SASS_RULE_RE: OnceLock<Regex> = OnceLock::new();
let rule_re = if indented {
SASS_RULE_RE.get_or_init(|| {
Regex::new(r"@(use|forward|import)\b([^\n]*)").expect(errors::INVALID_REGEX)
})
} else {
SCSS_RULE_RE.get_or_init(|| {
Regex::new(r"@(use|forward|import)\b([^;{}]*)").expect(errors::INVALID_REGEX)
})
};
static QUOTED_RE: OnceLock<Regex> = OnceLock::new();
let quoted_re = QUOTED_RE
.get_or_init(|| Regex::new(r#""([^"]*)"|'([^']*)'"#).expect(errors::INVALID_REGEX));
let content = strip_comments(content);
let mut urls: Vec<String> = Vec::new();
for caps in rule_re.captures_iter(&content) {
let rule = if &caps[1] == "import" {
Rule::Import
} else {
Rule::UseOrForward
};
let clause = caps[2].trim();
let quoted: Vec<String> = quoted_re
.captures_iter(clause)
.filter_map(|c| c.get(1).or_else(|| c.get(2)))
.map(|m| m.as_str().to_string())
.collect();
let candidates: Vec<String> = match rule {
Rule::UseOrForward => quoted.into_iter().take(1).collect(),
Rule::Import if clause.starts_with("url(") => Vec::new(),
Rule::Import if quoted.is_empty() && indented => clause
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
Rule::Import => quoted,
};
for url in candidates {
let plain_css_import = rule == Rule::Import
&& Path::new(&url)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("css"));
if !is_external(&url) && !plain_css_import {
urls.push(url);
}
}
}
urls
}
fn is_external(url: &str) -> bool {
url.is_empty()
|| url.starts_with("sass:")
|| url.starts_with("pkg:")
|| url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("//")
|| url.starts_with("data:")
}
fn strip_comments(content: &str) -> String {
let mut out = String::with_capacity(content.len());
let mut chars = content.chars().peekable();
let mut quote: Option<char> = None;
while let Some(c) = chars.next() {
if let Some(q) = quote {
out.push(c);
if c == '\\' {
if let Some(escaped) = chars.next() {
out.push(escaped);
}
} else if c == q {
quote = None;
}
continue;
}
match (c, chars.peek()) {
('"' | '\'', _) => {
quote = Some(c);
out.push(c);
}
('/', Some('/')) => {
for skipped in chars.by_ref() {
if skipped == '\n' {
out.push('\n');
break;
}
}
}
('/', Some('*')) => {
chars.next();
let mut prev = '\0';
for skipped in chars.by_ref() {
if skipped == '\n' {
out.push('\n');
}
if prev == '*' && skipped == '/' {
break;
}
prev = skipped;
}
}
_ => out.push(c),
}
}
out
}
fn resolve_in(base: &Path) -> Option<PathBuf> {
let has_load_ext = base
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| LOAD_EXTENSIONS.contains(&e));
let mut candidates: Vec<PathBuf> = Vec::new();
if has_load_ext {
candidates.push(base.to_path_buf());
candidates.extend(partial_of(base));
} else {
for ext in LOAD_EXTENSIONS {
let with_ext = with_appended_extension(base, ext);
candidates.extend(partial_of(&with_ext));
candidates.push(with_ext);
}
for ext in LOAD_EXTENSIONS {
candidates.push(base.join(format!("_index.{ext}")));
candidates.push(base.join(format!("index.{ext}")));
}
}
candidates.into_iter().find(|c| c.is_file())
}
fn partial_of(path: &Path) -> Option<PathBuf> {
let name = path.file_name()?.to_str()?;
if name.starts_with('_') {
return None;
}
Some(path.with_file_name(format!("_{name}")))
}
fn with_appended_extension(path: &Path, ext: &str) -> PathBuf {
let mut name: OsString = path.as_os_str().to_owned();
name.push(".");
name.push(ext);
PathBuf::from(name)
}
impl DepAnalyzer for SassDepAnalyzer {
fn description(&self) -> &'static str {
"Scan Sass/SCSS files for @use, @forward and @import dependencies"
}
fn enabled(&self) -> bool {
self.config.enabled
}
fn auto_detect(&self, file_index: &FileIndex) -> bool {
file_index.has_extension(".scss") || file_index.has_extension(".sass")
}
fn match_product(&self, p: &Product) -> Option<PathBuf> {
let source = p.inputs.first()?;
let ext = source.extension().and_then(|s| s.to_str()).unwrap_or("");
if SASS_MATCH_EXTENSIONS.contains(&ext) {
Some(source.clone())
} else {
None
}
}
fn scan(
&self,
_ctx: &crate::build_context::BuildContext,
source: &Path,
_file_index: &FileIndex,
) -> Result<ScanResult> {
self.scan_source(source)
}
fn always_rescan(&self) -> bool {
true
}
fn fingerprint_parts(&self, _ctx: &crate::build_context::BuildContext) -> Result<Vec<String>> {
super::config_fingerprint(&self.config)
}
}
inventory::submit! {
crate::registries::AnalyzerPlugin {
name: "sass",
description: "Scan Sass/SCSS files for @use, @forward and @import dependencies",
is_native: true,
create: |toml_value, _| {
let cfg: SassAnalyzerConfig = toml::from_str(&toml::to_string(toml_value)?)?;
Ok(Box::new(SassDepAnalyzer::new(cfg)))
},
defconfig_toml: || {
toml::to_string_pretty(&SassAnalyzerConfig::default()).ok()
},
known_fields: crate::registries::typed_known_fields::<SassAnalyzerConfig>,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_urls_scss_rules() {
let src = r#"
@use "sass:math";
@use "vars" as v;
@use 'theme' with ($accent: "red", $font: 'x');
@forward "mixins" show button;
@import "a", 'b';
@import "plain.css";
@import url(remote.css);
@import "https://example.com/x";
"#;
assert_eq!(
load_urls(src, false),
vec!["vars", "theme", "mixins", "a", "b"]
);
}
#[test]
fn load_urls_ignore_comments() {
let src = "// @use \"gone\";\n/* @import \"also-gone\"; */\n@use \"kept\"; // trailing\n";
assert_eq!(load_urls(src, false), vec!["kept"]);
}
#[test]
fn load_urls_keep_double_slash_inside_strings() {
let src = "$u: \"http://x\";\n@use \"kept\";\n";
assert_eq!(load_urls(src, false), vec!["kept"]);
}
#[test]
fn load_urls_indented_unquoted_import() {
let src = "@import reset, base\n@use \"vars\"\n";
assert_eq!(load_urls(src, true), vec!["reset", "base", "vars"]);
}
#[test]
fn resolve_in_follows_sass_rules() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
fs::write(d.join("_vars.scss"), "").unwrap();
fs::write(d.join("plain.scss"), "").unwrap();
fs::write(d.join("theme.dark.scss"), "").unwrap();
fs::create_dir(d.join("comp")).unwrap();
fs::write(d.join("comp/_index.scss"), "").unwrap();
fs::write(d.join("lib.css"), "").unwrap();
assert_eq!(resolve_in(&d.join("vars")), Some(d.join("_vars.scss")));
assert_eq!(resolve_in(&d.join("_vars")), Some(d.join("_vars.scss")));
assert_eq!(resolve_in(&d.join("vars.scss")), Some(d.join("_vars.scss")));
assert_eq!(resolve_in(&d.join("plain")), Some(d.join("plain.scss")));
assert_eq!(
resolve_in(&d.join("theme.dark")),
Some(d.join("theme.dark.scss"))
);
assert_eq!(
resolve_in(&d.join("comp")),
Some(d.join("comp/_index.scss"))
);
assert_eq!(resolve_in(&d.join("lib")), Some(d.join("lib.css")));
assert_eq!(resolve_in(&d.join("missing")), None);
}
}