use crate::GlobEntry;
use bexpand::Expression;
use fxhash::FxHashMap;
use ignore::gitignore::Gitignore;
use std::path::{Component, Path, PathBuf};
use tracing::{event, Level};
use super::auto_source_detection::IGNORED_CONTENT_DIRS;
#[derive(Debug, Clone)]
pub struct PublicSourceEntry {
pub base: String,
pub pattern: String,
pub negated: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SourceEntry {
Auto { base: PathBuf },
Pattern { base: PathBuf, pattern: String },
Ignored { base: PathBuf, pattern: String },
External { base: PathBuf },
}
#[derive(Debug, Clone, Default)]
pub struct Sources {
sources: Vec<SourceEntry>,
}
impl Sources {
pub fn new(sources: Vec<SourceEntry>) -> Self {
Self { sources }
}
pub fn iter(&self) -> impl Iterator<Item = &SourceEntry> {
self.sources.iter()
}
}
impl PublicSourceEntry {
pub fn optimize(&mut self) {
let Ok(mut base) = dunce::canonicalize(&self.base) else {
event!(Level::ERROR, "Failed to resolve base: {:?}", self.base);
return;
};
let mut new_pattern = PathBuf::new();
enum ComponentStage {
Base,
Pattern,
}
let mut stage = ComponentStage::Base;
let mut components = Path::new(&self.pattern).components().peekable();
while let Some(component) = components.next() {
match stage {
ComponentStage::Base => {
match component {
Component::CurDir => {}
Component::ParentDir => {
base.pop();
}
Component::Normal(part) if part.to_string_lossy().contains("*") => {
new_pattern.push(component);
stage = ComponentStage::Pattern;
}
Component::Normal(part) if components.peek().is_some() => {
base.push(part);
}
Component::Normal(part) => {
let full_path = base.join(part);
if full_path.is_dir() {
base.push(part);
} else {
new_pattern.push(part);
}
}
Component::Prefix(_) => {
base.clear();
base.push(component);
}
Component::RootDir => {
#[cfg(not(windows))]
base.clear();
base.push(component);
}
}
}
ComponentStage::Pattern => {
new_pattern.push(component);
}
}
}
self.base = base.to_string_lossy().to_string();
self.pattern = path_to_posix_string(&new_pattern);
if self.pattern == "" {
self.pattern = "/**/*".to_owned();
}
else if !self.pattern.starts_with("/") {
self.pattern = format!("/{}", self.pattern);
}
}
}
fn path_to_posix_string(path: &Path) -> String {
let mut parts = Vec::new();
let mut is_rooted = false;
for component in path.components() {
match component {
Component::Prefix(prefix) => {
parts.push(prefix.as_os_str().to_string_lossy().to_string());
}
Component::RootDir => {
is_rooted = true;
if parts.is_empty() {
parts.push(String::new());
}
}
Component::CurDir => {
parts.push(".".to_string());
}
Component::ParentDir => {
parts.push("..".to_string());
}
Component::Normal(part) => {
parts.push(part.to_string_lossy().to_string());
}
}
}
let result = parts.join("/");
if result.is_empty() && is_rooted {
"/".to_string()
} else {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::tempdir;
#[test]
fn path_to_posix_string_serializes_relative_paths() {
let path = PathBuf::from("src").join("**").join("*.html");
assert_eq!(path_to_posix_string(&path), "src/**/*.html");
}
#[test]
fn path_to_posix_string_serializes_rooted_paths() {
let path = PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
.join("src")
.join("**")
.join("*.html");
assert_eq!(path_to_posix_string(&path), "/src/**/*.html");
}
#[test]
fn path_to_posix_string_serializes_empty_paths() {
assert_eq!(path_to_posix_string(&PathBuf::new()), "");
}
#[test]
fn optimize_hoists_static_directories_and_keeps_files_in_the_pattern() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src").join("examples")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/examples/index.html".to_string(),
negated: false,
};
source.optimize();
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src").join("examples"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/index.html");
}
#[test]
fn optimize_hoists_folder_patterns() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src").join("examples")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/examples".to_string(),
negated: false,
};
source.optimize();
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src").join("examples"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/**/*");
}
#[test]
fn optimize_keeps_wildcards_in_the_pattern() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/**/*.html".to_string(),
negated: false,
};
source.optimize();
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/**/*.html");
}
fn auto_source_entry(base: &Path) -> SourceEntry {
public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
base: base.to_string_lossy().to_string(),
pattern: "**/*".to_string(),
negated: false,
}])
.into_iter()
.next()
.unwrap()
}
#[test]
fn auto_detected_folders_become_auto_sources() {
let dir = tempdir().unwrap();
let base = dir.path().join("src");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
}
#[test]
fn folders_ignored_by_default_become_external_sources() {
let dir = tempdir().unwrap();
let base = dir.path().join("node_modules").join("my-lib");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
fn folders_ignored_by_gitignore_become_external_sources() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap();
let base = dir.path().join("dist");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
fn folders_ignored_by_a_parent_gitignore_become_external_sources() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "generated/\n").unwrap();
let base = dir.path().join("packages").join("app").join("generated");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
fn folders_not_ignored_by_gitignore_stay_auto_sources() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap();
let base = dir.path().join("src");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
}
}
pub fn public_source_entries_to_private_source_entries(
sources: Vec<PublicSourceEntry>,
) -> Vec<SourceEntry> {
let expanded_globs = sources
.into_iter()
.flat_map(|source| {
let expression: Result<Expression, _> = source.pattern[..].try_into();
let Ok(expression) = expression else {
return vec![source];
};
expression
.into_iter()
.filter_map(Result::ok)
.map(move |pattern| PublicSourceEntry {
base: source.base.clone(),
pattern: pattern.into(),
negated: source.negated,
})
.collect::<Vec<_>>()
})
.map(|mut public_source| {
public_source.optimize();
public_source
})
.collect::<Vec<_>>();
let mut gitignores: FxHashMap<PathBuf, Option<Gitignore>> = FxHashMap::default();
let cwd = std::env::current_dir()
.map(|cwd| dunce::canonicalize(&cwd).unwrap_or(cwd))
.ok();
expanded_globs
.into_iter()
.map(|public_source| {
let mut source: SourceEntry = public_source.into();
if let SourceEntry::Auto { ref base } = source {
let inside_git_repo = base.ancestors().any(|dir| dir.join(".git").exists());
for dir in base.ancestors() {
let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| {
let path = dir.join(".gitignore");
path.is_file().then(|| Gitignore::new(&path).0)
});
if let Some(gitignore) = gitignore {
if gitignore
.matched_path_or_any_parents(&base, true)
.is_ignore()
{
source = SourceEntry::External { base: base.into() };
break;
}
}
if dir.join(".git").exists() {
break;
}
if !inside_git_repo && cwd.as_ref().is_some_and(|cwd| cwd.starts_with(dir)) {
break;
}
}
}
source
})
.collect::<Vec<SourceEntry>>()
}
impl From<PublicSourceEntry> for SourceEntry {
fn from(value: PublicSourceEntry) -> Self {
if value.negated {
return SourceEntry::Ignored {
base: value.base.into(),
pattern: value.pattern,
};
}
let auto =
value.pattern == "/**/*" || PathBuf::from(&value.base).join(&value.pattern).is_dir();
if !auto {
return SourceEntry::Pattern {
base: value.base.into(),
pattern: value.pattern,
};
}
let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| {
value.base.contains(&format!(
"{}{}{}",
std::path::MAIN_SEPARATOR,
dir,
std::path::MAIN_SEPARATOR
)) || value
.base
.ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir))
});
match inside_ignored_content_dir {
false => SourceEntry::Auto {
base: value.base.into(),
},
true => SourceEntry::External {
base: value.base.into(),
},
}
}
}
impl From<GlobEntry> for SourceEntry {
fn from(value: GlobEntry) -> Self {
SourceEntry::Pattern {
base: PathBuf::from(value.base),
pattern: value.pattern,
}
}
}
impl From<SourceEntry> for GlobEntry {
fn from(value: SourceEntry) -> Self {
match value {
SourceEntry::Auto { base } | SourceEntry::External { base } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: "**/*".into(),
},
SourceEntry::Pattern { base, pattern } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: pattern.clone(),
},
SourceEntry::Ignored { base, pattern } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: pattern.clone(),
},
}
}
}
impl From<&SourceEntry> for GlobEntry {
fn from(value: &SourceEntry) -> Self {
match value {
SourceEntry::Auto { base } | SourceEntry::External { base } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: "**/*".into(),
},
SourceEntry::Pattern { base, pattern } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: pattern.clone(),
},
SourceEntry::Ignored { base, pattern } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: pattern.clone(),
},
}
}
}