#[cfg(test)]
mod scanner {
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread::sleep;
use std::time::Duration;
use std::{fs, path};
use tailwindcss_oxide::*;
use tempfile::tempdir;
fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> std::io::Result<()> {
#[cfg(not(windows))]
let result = std::os::unix::fs::symlink(original, link);
#[cfg(windows)]
let result = std::os::windows::fs::symlink_dir(original, link);
result
}
fn symlink_file<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> std::io::Result<()> {
#[cfg(not(windows))]
let result = std::os::unix::fs::symlink(original, link);
#[cfg(windows)]
let result = std::os::windows::fs::symlink_file(original, link);
result
}
fn public_source_entry_from_pattern(dir: PathBuf, pattern: &str) -> PublicSourceEntry {
let mut parts = pattern.split_whitespace();
let _ = parts.next().unwrap_or_default();
let not_or_pattern = parts.next().unwrap_or_default();
if not_or_pattern == "not" {
let pattern = parts.next().unwrap_or_default();
return PublicSourceEntry {
base: dir.to_string_lossy().into(),
pattern: pattern[1..pattern.len() - 1].to_string(),
negated: true,
};
}
PublicSourceEntry {
base: dir.to_string_lossy().into(),
pattern: not_or_pattern[1..not_or_pattern.len() - 1].to_string(),
negated: false,
}
}
struct ScanResult {
files: Vec<String>,
globs: Vec<String>,
normalized_sources: Vec<String>,
candidates: Vec<String>,
tree: String,
}
fn fs_tree(root: &Path, scanned: &[String]) -> String {
fn relative_to(target: &Path, parent: &Path) -> String {
let target = target.components().collect::<Vec<_>>();
let parent = parent.components().collect::<Vec<_>>();
let common = target
.iter()
.zip(&parent)
.take_while(|(a, b)| **a == **b)
.count();
let mut parts = vec![".."; parent.len() - common];
parts.extend(
target[common..]
.iter()
.map(|component| component.as_os_str().to_str().unwrap()),
);
if parts.is_empty() {
".".into()
} else {
parts.join("/")
}
}
fn walk(
dir: &Path,
root: &Path,
prefix: &str,
scanned: &[String],
visited: &mut Vec<PathBuf>,
out: &mut String,
) {
let mut entries = fs::read_dir(dir)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name() != ".git")
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name());
for (i, entry) in entries.iter().enumerate() {
let last = i == entries.len() - 1;
let connector = if last { "└── " } else { "├── " };
let child_prefix = if last { " " } else { "│ " };
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let rel = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
let file_type = entry.file_type().unwrap();
let dir_prefix = format!("{rel}/");
let is_scanned = scanned
.iter()
.any(|file| file == &rel || file.starts_with(&dir_prefix));
let indicator = if is_scanned { "✓" } else { "✗" };
let mut display_name = name.clone();
if path.join(".git").exists() {
display_name = format!("{display_name} (git)");
}
if file_type.is_symlink() {
if let Ok(target) = fs::read_link(&path) {
let target = match (target.strip_prefix(root), dir.strip_prefix(root)) {
(Ok(target), Ok(parent)) => relative_to(target, parent),
_ => target.to_string_lossy().replace('\\', "/"),
};
display_name = format!("{name} → {target}");
if path.metadata().is_err() {
display_name = format!("{display_name} (broken)");
}
}
}
out.push_str(&format!("{prefix}{connector}{indicator} {display_name}\n"));
if name == ".gitignore" {
if let Ok(contents) = fs::read_to_string(&path) {
for line in contents.lines() {
let line = format!("{prefix}{child_prefix} {line}");
out.push_str(line.trim_end());
out.push('\n');
}
}
}
if path.metadata().map(|meta| meta.is_dir()).unwrap_or(false) {
let Ok(canonical) = dunce::canonicalize(&path) else {
continue;
};
if visited.contains(&canonical) {
continue;
}
visited.push(canonical);
walk(
&path,
root,
&format!("{prefix}{child_prefix}"),
scanned,
visited,
out,
);
visited.pop();
}
}
}
let mut visited = vec![dunce::canonicalize(root).unwrap()];
let mut out = if root.join(".git").exists() {
String::from(". (git)\n")
} else {
String::from(".\n")
};
walk(root, root, "", scanned, &mut visited, &mut out);
out
}
fn create_files_in(dir: &path::Path, paths: &[(&str, &str)]) {
for (path, contents) in paths {
let path = dir.join(path.replace('/', path::MAIN_SEPARATOR.to_string().as_str()));
let parent = path.parent().unwrap();
if !parent.exists() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap()
}
}
fn scanned_files(scanner: &mut Scanner, base: &Path) -> Vec<String> {
let base_dir =
format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/");
let mut files = scanner
.get_files()
.iter()
.map(|file| file.replace('\\', "/").replace(&base_dir, ""))
.collect::<Vec<_>>();
files.sort();
files
}
fn scanned_globs(scanner: &mut Scanner, base: &Path) -> Vec<String> {
let base_dir =
format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/");
let mut globs = scanner
.get_globs()
.iter()
.map(|glob| {
if glob.pattern.starts_with('/') {
format!("{}{}", glob.base, glob.pattern)
} else {
format!("{}/{}", glob.base, glob.pattern)
}
})
.map(|file| file.replace('\\', "/").replace(&base_dir, ""))
.collect::<Vec<_>>();
globs.sort();
globs
}
fn normalize_files(files: Vec<String>, base: &Path) -> Vec<String> {
let base_dir =
format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/");
let mut files = files
.iter()
.map(|file| file.replace('\\', "/").replace(&base_dir, ""))
.collect::<Vec<_>>();
files.sort();
files
}
fn scan_with_globs(
paths_with_content: &[(&str, &str)],
source_directives: Vec<&str>,
) -> ScanResult {
let dir = tempdir().unwrap().into_path();
let _ = Command::new("git").arg("init").current_dir(&dir).output();
self::create_files_in(&dir, paths_with_content);
let base = format!("{}", dir.display()).replace('\\', "/");
let sources: Vec<PublicSourceEntry> = source_directives
.iter()
.map(|str| public_source_entry_from_pattern(base.clone().into(), str))
.collect();
let mut scanner = Scanner::new(sources);
let candidates = scanner.scan();
let base_dir =
format!("{}{}", dunce::canonicalize(&base).unwrap().display(), "/").replace('\\', "/");
let files = scanned_files(&mut scanner, Path::new(&base));
let globs = scanned_globs(&mut scanner, Path::new(&base));
let mut normalized_sources = scanner
.get_normalized_sources()
.iter()
.map(|glob| {
if glob.pattern.starts_with('/') {
format!("{}{}", glob.base, glob.pattern)
} else {
format!("{}/{}", glob.base, glob.pattern)
}
})
.map(|file| file.replace('\\', "/").replace(&base_dir, ""))
.collect::<Vec<_>>();
normalized_sources.sort();
let tree = fs_tree(&dir, &files);
ScanResult {
files,
globs,
normalized_sources,
candidates,
tree,
}
}
fn scan(paths_with_content: &[(&str, &str)]) -> ScanResult {
scan_with_globs(paths_with_content, vec!["@source '**/*'"])
}
#[test]
fn it_should_work_with_a_set_of_root_files() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
("index.html", ""),
("a.html", ""),
("b.html", ""),
("c.html", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✓ a.html
├── ✓ b.html
├── ✓ c.html
└── ✓ index.html
");
assert_eq!(files, vec!["a.html", "b.html", "c.html", "index.html"]);
assert_eq!(globs, vec!["*"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_work_with_a_set_of_root_files_and_ignore_ignored_files() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
(".gitignore", "b.html"),
("index.html", ""),
("a.html", ""),
("b.html", ""),
("c.html", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ b.html
├── ✓ a.html
├── ✗ b.html
├── ✓ c.html
└── ✓ index.html
");
assert_eq!(files, vec!["a.html", "c.html", "index.html"]);
assert_eq!(globs, vec!["*"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_list_all_files_in_the_public_folder_explicitly() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
("index.html", ""),
("public/a.html", ""),
("public/b.html", ""),
("public/c.html", ""),
("public/nested/c.html", ""),
("public/deeply/nested/c.html", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✓ index.html
└── ✓ public
├── ✓ a.html
├── ✓ b.html
├── ✓ c.html
├── ✓ deeply
│ └── ✓ nested
│ └── ✓ c.html
└── ✓ nested
└── ✓ c.html
");
assert_eq!(
files,
vec![
"index.html",
"public/a.html",
"public/b.html",
"public/c.html",
"public/deeply/nested/c.html",
"public/nested/c.html",
]
);
assert_eq!(globs, vec!["*"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_list_nested_folders_explicitly_in_the_public_folder() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
("index.html", ""),
("public/a.html", ""),
("public/b.html", ""),
("public/c.html", ""),
("public/nested/a.html", ""),
("public/nested/b.html", ""),
("public/nested/c.html", ""),
("public/nested/again/a.html", ""),
("public/very/deeply/nested/a.html", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✓ index.html
└── ✓ public
├── ✓ a.html
├── ✓ b.html
├── ✓ c.html
├── ✓ nested
│ ├── ✓ a.html
│ ├── ✓ again
│ │ └── ✓ a.html
│ ├── ✓ b.html
│ └── ✓ c.html
└── ✓ very
└── ✓ deeply
└── ✓ nested
└── ✓ a.html
");
assert_eq!(
files,
vec![
"index.html",
"public/a.html",
"public/b.html",
"public/c.html",
"public/nested/a.html",
"public/nested/again/a.html",
"public/nested/b.html",
"public/nested/c.html",
"public/very/deeply/nested/a.html",
]
);
assert_eq!(globs, vec!["*",]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_list_all_files_in_the_public_folder_explicitly_except_ignored_files() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
(".gitignore", "public/b.html\na.html"),
("index.html", ""),
("public/a.html", ""),
("public/b.html", ""),
("public/c.html", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ public/b.html
│ a.html
├── ✓ index.html
└── ✓ public
├── ✗ a.html
├── ✗ b.html
└── ✓ c.html
");
assert_eq!(files, vec!["index.html", "public/c.html",]);
assert_eq!(globs, vec!["*"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_use_a_glob_for_top_level_folders() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
("index.html", ""),
("src/a.html", ""),
("src/b.html", ""),
("src/c.html", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✓ index.html
└── ✓ src
├── ✓ a.html
├── ✓ b.html
└── ✓ c.html
");
assert_eq!(
files,
vec!["index.html", "src/a.html", "src/b.html", "src/c.html"]
);
assert_eq!(globs, vec![
"*",
"src/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_ignore_binary_files() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
("index.html", ""),
("a.mp4", ""),
("b.png", ""),
("c.lock", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✗ a.mp4
├── ✗ b.png
├── ✗ c.lock
└── ✓ index.html
");
assert_eq!(files, vec!["index.html"]);
assert_eq!(globs, vec!["*"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_not_ignore_folders_that_end_with_a_binary_extension() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
("some.pages/index.html", "content-['some.pages/index.html']"),
(".gitignore", "other.pages"),
(
"other.pages/index.html",
"content-['other.pages/index.html']",
),
]);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ other.pages
├── ✗ other.pages
│ └── ✗ index.html
└── ✓ some.pages
└── ✓ index.html
");
assert_eq!(files, vec!["some.pages/index.html"]);
assert_eq!(globs, vec!["*", "some.pages/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_ignore_known_extensions() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
("index.html", ""),
("a.css", ""),
("b.sass", ""),
("c.less", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✓ a.css
├── ✗ b.sass
├── ✗ c.less
└── ✓ index.html
");
assert_eq!(files, vec!["a.css", "index.html"]);
assert_eq!(globs, vec!["*"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_find_new_extensions() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[("src/index.my-extension", "")]);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
└── ✓ index.my-extension
");
assert_eq!(files, vec!["src/index.my-extension"]);
assert_eq!(globs, vec!["*", "src/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,my-extension,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_ignore_known_files() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
("index.html", ""),
("package-lock.json", ""),
("yarn.lock", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✓ index.html
├── ✗ package-lock.json
└── ✗ yarn.lock
");
assert_eq!(files, vec!["index.html"]);
assert_eq!(globs, vec!["*"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_ignore_and_expand_nested_ignored_folders() {
let ScanResult {
files,
globs,
normalized_sources,
tree,
..
} = scan(&[
("foo.html", ""),
("bar.html", ""),
("baz.html", ""),
("nested-a/foo.html", ""),
("nested-a/bar.html", ""),
("nested-a/baz.html", ""),
("nested-b/deeply-nested/foo.html", ""),
("nested-b/deeply-nested/bar.html", ""),
("nested-b/deeply-nested/baz.html", ""),
("nested-c/foo.html", ""),
("nested-c/bar.html", ""),
("nested-c/baz.html", ""),
("nested-c/.gitignore", "ignored-folder/"),
("nested-c/ignored-folder/foo.html", ""),
("nested-c/ignored-folder/bar.html", ""),
("nested-c/ignored-folder/baz.html", ""),
("nested-c/sibling-folder/foo.html", ""),
("nested-c/sibling-folder/bar.html", ""),
("nested-c/sibling-folder/baz.html", ""),
("nested-d/foo.html", ""),
("nested-d/bar.html", ""),
("nested-d/baz.html", ""),
("nested-d/.gitignore", "deep/"),
("nested-d/very/deeply/nested/deep/foo.html", ""),
("nested-d/very/deeply/nested/deep/bar.html", ""),
("nested-d/very/deeply/nested/deep/baz.html", ""),
("nested-d/very/deeply/nested/foo.html", ""),
("nested-d/very/deeply/nested/bar.html", ""),
("nested-d/very/deeply/nested/baz.html", ""),
("nested-d/very/deeply/nested/directory/foo.html", ""),
("nested-d/very/deeply/nested/directory/bar.html", ""),
("nested-d/very/deeply/nested/directory/baz.html", ""),
("nested-d/very/deeply/nested/directory/again/foo.html", ""),
]);
assert_snapshot!(tree, @"
. (git)
├── ✓ bar.html
├── ✓ baz.html
├── ✓ foo.html
├── ✓ nested-a
│ ├── ✓ bar.html
│ ├── ✓ baz.html
│ └── ✓ foo.html
├── ✓ nested-b
│ └── ✓ deeply-nested
│ ├── ✓ bar.html
│ ├── ✓ baz.html
│ └── ✓ foo.html
├── ✓ nested-c
│ ├── ✗ .gitignore
│ │ ignored-folder/
│ ├── ✓ bar.html
│ ├── ✓ baz.html
│ ├── ✓ foo.html
│ ├── ✗ ignored-folder
│ │ ├── ✗ bar.html
│ │ ├── ✗ baz.html
│ │ └── ✗ foo.html
│ └── ✓ sibling-folder
│ ├── ✓ bar.html
│ ├── ✓ baz.html
│ └── ✓ foo.html
└── ✓ nested-d
├── ✗ .gitignore
│ deep/
├── ✓ bar.html
├── ✓ baz.html
├── ✓ foo.html
└── ✓ very
└── ✓ deeply
└── ✓ nested
├── ✓ bar.html
├── ✓ baz.html
├── ✗ deep
│ ├── ✗ bar.html
│ ├── ✗ baz.html
│ └── ✗ foo.html
├── ✓ directory
│ ├── ✓ again
│ │ └── ✓ foo.html
│ ├── ✓ bar.html
│ ├── ✓ baz.html
│ └── ✓ foo.html
└── ✓ foo.html
");
assert_eq!(
files,
vec![
"bar.html",
"baz.html",
"foo.html",
"nested-a/bar.html",
"nested-a/baz.html",
"nested-a/foo.html",
"nested-b/deeply-nested/bar.html",
"nested-b/deeply-nested/baz.html",
"nested-b/deeply-nested/foo.html",
"nested-c/bar.html",
"nested-c/baz.html",
"nested-c/foo.html",
"nested-c/sibling-folder/bar.html",
"nested-c/sibling-folder/baz.html",
"nested-c/sibling-folder/foo.html",
"nested-d/bar.html",
"nested-d/baz.html",
"nested-d/foo.html",
"nested-d/very/deeply/nested/bar.html",
"nested-d/very/deeply/nested/baz.html",
"nested-d/very/deeply/nested/directory/again/foo.html",
"nested-d/very/deeply/nested/directory/bar.html",
"nested-d/very/deeply/nested/directory/baz.html",
"nested-d/very/deeply/nested/directory/foo.html",
"nested-d/very/deeply/nested/foo.html",
]
);
assert_eq!(globs, vec![
"*",
"nested-a/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"nested-b/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"nested-c/*/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"nested-c/sibling-folder/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"nested-d/*/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"nested-d/very/*/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"nested-d/very/deeply/*/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"nested-d/very/deeply/nested/*/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"nested-d/very/deeply/nested/directory/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_scan_for_utilities() {
let mut ignores = String::new();
ignores.push_str("# md:font-bold\n");
ignores.push_str("foo.html\n");
let ScanResult {
candidates,
normalized_sources,
tree,
..
} = scan(&[
(".gitignore", &ignores),
("index.html", "font-bold md:flex"),
("foo.jpg", "xl:font-bold"),
("foo.html", "lg:font-bold"),
(
"index.angular.html",
"<div [class.underline]=\"bool\"></div>",
),
("index.svelte", "<div class:px-4='condition'></div>"),
("index2.svelte", "<div\n\tclass:px-5='condition'></div>"),
("index3.svelte", "<div\n class:px-6='condition'></div>"),
("index4.svelte", "<div\nclass:px-7='condition'></div>"),
]);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ # md:font-bold
│ foo.html
├── ✗ foo.html
├── ✗ foo.jpg
├── ✓ index.angular.html
├── ✓ index.html
├── ✓ index.svelte
├── ✓ index2.svelte
├── ✓ index3.svelte
└── ✓ index4.svelte
");
assert_eq!(
candidates,
vec![
"bool",
"class",
"condition",
"font-bold",
"md:flex",
"px-4",
"px-5",
"px-6",
"px-7",
"underline"
]
);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn it_should_be_possible_to_scan_in_the_parent_directory() {
let ScanResult {
candidates,
normalized_sources,
tree,
..
} = scan_with_globs(
&[("foo/bar/baz/foo.html", "content-['foo.html']")],
vec!["@source '**/*'", "@source './foo/bar/baz/..'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ foo
└── ✓ bar
└── ✓ baz
└── ✓ foo.html
");
assert_eq!(candidates, vec!["content-['foo.html']"]);
assert_eq!(normalized_sources, vec!["**/*", "foo/bar/**/*"]);
}
#[test]
fn it_should_preserve_source_order_when_referencing_a_sibling_project() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
("project-a/src/index.css", ""),
("project-b/keep/keep.html", "content-['GOOD-1']"),
("project-b/ignored/ignored.html", "content-['BAD-1']"),
("project-b/ignored/except.html", "content-['GOOD-2']"),
],
);
let base = dir.join("project-a/src");
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(base.clone(), "@source '../../project-b'"),
public_source_entry_from_pattern(base.clone(), "@source not '../../project-b/ignored'"),
public_source_entry_from_pattern(
base.clone(),
"@source '../../project-b/ignored/except.html'",
),
]);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ project-a
│ └── ✗ src
│ └── ✗ index.css
└── ✓ project-b
├── ✓ ignored
│ ├── ✓ except.html
│ └── ✗ ignored.html
└── ✓ keep
└── ✓ keep.html
");
assert_eq!(candidates, vec!["content-['GOOD-1']", "content-['GOOD-2']"]);
}
#[test]
fn it_should_scan_files_without_extensions() {
let ScanResult {
candidates,
normalized_sources,
tree,
..
} = scan_with_globs(
&[("my-file", "content-['my-file']")],
vec!["@source '**/*'", "@source './my-file'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ my-file
");
assert_eq!(candidates, vec!["content-['my-file']"]);
assert_eq!(normalized_sources, vec!["**/*", "my-file"]);
}
#[test]
fn it_should_scan_folders_with_extensions() {
let ScanResult {
candidates,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
(
"my-folder.templates/foo.html",
"content-['my-folder.templates/foo.html']",
),
(
"my-folder.bin/foo.html",
"content-['my-folder.bin/foo.html']",
),
],
vec![
"@source '**/*'",
"@source './my-folder.templates'",
"@source './my-folder.bin'",
],
);
assert_snapshot!(tree, @"
. (git)
├── ✓ my-folder.bin
│ └── ✓ foo.html
└── ✓ my-folder.templates
└── ✓ foo.html
");
assert_eq!(
candidates,
vec![
"content-['my-folder.bin/foo.html']",
"content-['my-folder.templates/foo.html']",
]
);
assert_eq!(
normalized_sources,
vec!["**/*", "my-folder.bin/**/*", "my-folder.templates/**/*"]
);
}
#[test]
fn it_should_scan_content_paths() {
let ScanResult {
candidates,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
("foo.styl", "content-['foo.styl']"),
],
vec!["@source '**/*'", "@source '*.styl'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ foo.styl
");
assert_eq!(candidates, vec!["content-['foo.styl']"]);
assert_eq!(normalized_sources, vec!["**/*", "*.styl"]);
}
#[test]
fn it_should_drop_invalid_utf8_candidates() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("index.html"), b"flex bg-[\x80] block").unwrap();
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.path().to_path_buf(),
"@source '*.html'",
)]);
let candidates = scanner
.scan()
.into_iter()
.map(String::into_bytes)
.collect::<Vec<_>>();
assert_eq!(candidates, vec![b"block".to_vec(), b"flex".to_vec()]);
}
#[test]
fn it_should_not_store_invalid_utf8_candidates_during_incremental_scans() {
let dir = tempdir().unwrap();
let file = dir.path().join("index.html");
fs::write(&file, b"flex bg-[\x80]").unwrap();
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.path().to_path_buf(),
"@source '*.html'",
)]);
let candidates = scanner
.scan_content(vec![ChangedContent::File(file, "html".into())])
.into_iter()
.map(String::into_bytes)
.collect::<Vec<_>>();
assert_eq!(candidates, vec![b"flex".to_vec()]);
let candidates =
scanner.scan_content(vec![ChangedContent::Content("block".into(), "html".into())]);
assert_eq!(candidates, vec!["block"]);
let candidates = scanner
.scan()
.into_iter()
.map(String::into_bytes)
.collect::<Vec<_>>();
assert_eq!(candidates, vec![b"block".to_vec(), b"flex".to_vec()]);
}
#[test]
fn it_should_drop_invalid_utf8_candidates_with_positions() {
let dir = tempdir().unwrap();
let file = dir.path().join("index.html");
fs::write(
&file,
b"flex bg-[\x80] group-[]:block group-[]:bg-[\x80] grid",
)
.unwrap();
let mut scanner = Scanner::new(vec![]);
let candidates = scanner
.get_candidates_with_positions(ChangedContent::File(file, "html".into()))
.into_iter()
.map(|(candidate, position)| (candidate.into_bytes(), position))
.collect::<Vec<_>>();
assert_eq!(
candidates,
vec![
(b"flex".to_vec(), 0),
(b"group-[]:block".to_vec(), 12),
(b"grid".to_vec(), 43),
]
);
}
#[test]
fn it_should_preserve_valid_utf8_candidates() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("index.html"),
"before:content-['💩'] bg-[é] font-[中文]".as_bytes(),
)
.unwrap();
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.path().to_path_buf(),
"@source '*.html'",
)]);
assert_eq!(
scanner.scan(),
vec!["before:content-['💩']", "bg-[é]", "font-[中文]"]
);
}
#[test]
fn it_should_preserve_paths_for_sources_ending_in_a_deep_glob() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
(
"blog/2024/foo/bar/baz/index.html",
"content-['blog/2024/foo/bar/baz/index.html']",
),
(
"blog/2024/foo/bar/qux/index.html",
"content-['blog/2024/foo/bar/qux/index.html']",
),
],
vec!["@source './blog/*/foo/bar/baz/**/*'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ blog
└── ✓ 2024
└── ✓ foo
└── ✓ bar
├── ✓ baz
│ └── ✓ index.html
└── ✗ qux
└── ✗ index.html
");
assert_eq!(
candidates,
vec!["content-['blog/2024/foo/bar/baz/index.html']"]
);
assert_eq!(files, vec!["blog/2024/foo/bar/baz/index.html"]);
assert_eq!(globs, vec!["blog/*/foo/bar/baz/**/*"]);
assert_eq!(normalized_sources, vec!["blog/*/foo/bar/baz/**/*"]);
}
#[test]
fn it_should_scan_next_dynamic_folders() {
let ScanResult {
candidates,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
("app/[slug]/page.styl", "content-['[slug]']"),
("app/[...slug]/page.styl", "content-['[...slug]']"),
("app/[[...slug]]/page.styl", "content-['[[...slug]]']"),
("app/(theme)/page.styl", "content-['(theme)']"),
],
vec!["@source '**/*'", "@source './**/*.{styl}'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ app
├── ✓ (theme)
│ └── ✓ page.styl
├── ✓ [...slug]
│ └── ✓ page.styl
├── ✓ [[...slug]]
│ └── ✓ page.styl
└── ✓ [slug]
└── ✓ page.styl
");
assert_eq!(
candidates,
vec![
"content-['(theme)']",
"content-['[...slug]']",
"content-['[[...slug]]']",
"content-['[slug]']",
],
);
assert_eq!(normalized_sources, vec!["**/*", "**/*.styl"]);
}
#[test]
fn it_should_scan_absolute_paths() {
let dir = tempdir().unwrap().into_path();
let _ = Command::new("git").arg("init").current_dir(&dir).output();
create_files_in(
&dir,
&[
("project-a/index.html", "content-['project-a/index.html']"),
("project-b/index.html", "content-['project-b/index.html']"),
],
);
let full_path = format!("{}", dir.display()).replace('\\', "/");
let sources = vec![PublicSourceEntry {
base: full_path.clone(),
pattern: full_path.clone(),
negated: false,
}];
let mut scanner = Scanner::new(sources);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
. (git)
├── ✓ project-a
│ └── ✓ index.html
└── ✓ project-b
└── ✓ index.html
");
assert_eq!(
candidates,
vec![
"content-['project-a/index.html']",
"content-['project-b/index.html']"
]
);
}
#[test]
fn it_should_scan_content_paths_even_when_they_are_git_ignored() {
let ScanResult {
candidates,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
(".gitignore", "foo.styl"),
("foo.styl", "content-['foo.styl']"),
],
vec!["@source '**/*'", "@source 'foo.styl'"],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ foo.styl
└── ✓ foo.styl
");
assert_eq!(candidates, vec!["content-['foo.styl']"]);
assert_eq!(normalized_sources, vec!["**/*", "foo.styl"]);
}
#[test]
fn it_should_pick_up_new_files() {
let dir = tempdir().unwrap().into_path();
let _ = Command::new("git").arg("init").current_dir(&dir).output();
create_files_in(
&dir,
&[
("project-a/index.html", "content-['project-a/index.html']"),
("project-b/index.html", "content-['project-b/index.html']"),
],
);
let sources = vec![
public_source_entry_from_pattern(dir.join("project-a"), "@source '**/*'"),
public_source_entry_from_pattern(dir.join("project-b"), "@source '**/*'"),
];
let mut scanner = Scanner::new(sources);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
. (git)
├── ✓ project-a
│ └── ✓ index.html
└── ✓ project-b
└── ✓ index.html
");
assert_eq!(
candidates,
vec![
"content-['project-a/index.html']",
"content-['project-b/index.html']"
]
);
sleep(Duration::from_millis(100));
create_files_in(
&dir,
&[
("project-a/new.html", "content-['project-a/new.html']"),
("project-b/new.html", "content-['project-b/new.html']"),
],
);
let candidates = scanner.scan();
assert_eq!(
candidates,
vec![
"content-['project-a/index.html']",
"content-['project-a/new.html']",
"content-['project-b/index.html']",
"content-['project-b/new.html']"
]
);
sleep(Duration::from_millis(100));
create_files_in(
&dir,
&[
(
"project-a/sub1/sub2/index.html",
"content-['project-a/sub1/sub2/index.html']",
),
(
"project-b/sub1/sub2/index.html",
"content-['project-b/sub1/sub2/index.html']",
),
],
);
let candidates = scanner.scan();
assert_eq!(
candidates,
vec![
"content-['project-a/index.html']",
"content-['project-a/new.html']",
"content-['project-a/sub1/sub2/index.html']",
"content-['project-b/index.html']",
"content-['project-b/new.html']",
"content-['project-b/sub1/sub2/index.html']"
]
);
sleep(Duration::from_millis(100));
create_files_in(
&dir,
&[
(
"project-a/sub1/sub2/new.html",
"content-['project-a/sub1/sub2/new.html']",
),
(
"project-b/sub1/sub2/new.html",
"content-['project-b/sub1/sub2/new.html']",
),
],
);
let candidates = scanner.scan();
assert_eq!(
candidates,
vec![
"content-['project-a/index.html']",
"content-['project-a/new.html']",
"content-['project-a/sub1/sub2/index.html']",
"content-['project-a/sub1/sub2/new.html']",
"content-['project-b/index.html']",
"content-['project-b/new.html']",
"content-['project-b/sub1/sub2/index.html']",
"content-['project-b/sub1/sub2/new.html']"
]
);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
. (git)
├── ✓ project-a
│ ├── ✓ index.html
│ ├── ✓ new.html
│ └── ✓ sub1
│ └── ✓ sub2
│ ├── ✓ index.html
│ └── ✓ new.html
└── ✓ project-b
├── ✓ index.html
├── ✓ new.html
└── ✓ sub1
└── ✓ sub2
├── ✓ index.html
└── ✓ new.html
");
}
#[test]
fn it_should_remove_deleted_files_from_scanned_files() {
let dir = tempdir().unwrap().into_path();
let _ = Command::new("git").arg("init").current_dir(&dir).output();
create_files_in(
&dir,
&[
("src/index.html", "content-['src/index.html']"),
("src/keep.html", "content-['src/keep.html']"),
("src/remove.html", "content-['src/remove.html']"),
],
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source '**/*'",
)]);
scanner.scan();
assert_eq!(
scanned_files(&mut scanner, &dir),
vec!["src/index.html", "src/keep.html", "src/remove.html"]
);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
. (git)
└── ✓ src
├── ✓ index.html
├── ✓ keep.html
└── ✓ remove.html
");
fs::remove_file(dir.join("src/remove.html")).unwrap();
scanner.scan();
assert_eq!(
scanned_files(&mut scanner, &dir),
vec!["src/index.html", "src/keep.html"]
);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
. (git)
└── ✓ src
├── ✓ index.html
└── ✓ keep.html
");
}
#[test]
fn it_should_remove_deleted_directories_from_scanned_files() {
let dir = tempdir().unwrap().into_path();
let _ = Command::new("git").arg("init").current_dir(&dir).output();
create_files_in(
&dir,
&[
("src/index.html", "content-['src/index.html']"),
("src/keep/index.html", "content-['src/keep/index.html']"),
("src/remove/index.html", "content-['src/remove/index.html']"),
(
"src/remove/nested/index.html",
"content-['src/remove/nested/index.html']",
),
],
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source '**/*'",
)]);
scanner.scan();
assert_eq!(
scanned_files(&mut scanner, &dir),
vec![
"src/index.html",
"src/keep/index.html",
"src/remove/index.html",
"src/remove/nested/index.html",
]
);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
. (git)
└── ✓ src
├── ✓ index.html
├── ✓ keep
│ └── ✓ index.html
└── ✓ remove
├── ✓ index.html
└── ✓ nested
└── ✓ index.html
");
fs::remove_dir_all(dir.join("src/remove")).unwrap();
scanner.scan();
assert_eq!(
scanned_files(&mut scanner, &dir),
vec!["src/index.html", "src/keep/index.html"]
);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
. (git)
└── ✓ src
├── ✓ index.html
└── ✓ keep
└── ✓ index.html
");
}
#[test]
fn it_should_remove_deleted_directories_from_scanned_globs() {
let dir = tempdir().unwrap().into_path();
let _ = Command::new("git").arg("init").current_dir(&dir).output();
create_files_in(
&dir,
&[
("index.html", "content-['index.html']"),
("src/index.html", "content-['src/index.html']"),
],
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source '**/*'",
)]);
scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
. (git)
├── ✓ index.html
└── ✓ src
└── ✓ index.html
");
let globs = scanned_globs(&mut scanner, &dir);
assert!(globs.iter().any(|glob| glob.starts_with("src/**/*")));
fs::remove_dir_all(dir.join("src")).unwrap();
scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
. (git)
└── ✓ index.html
");
let globs = scanned_globs(&mut scanner, &dir);
assert!(!globs.iter().any(|glob| glob.starts_with("src/**/*")));
}
#[test]
fn it_should_track_files_scanned_by_the_last_scan() {
let dir = tempdir().unwrap().into_path();
let _ = Command::new("git").arg("init").current_dir(&dir).output();
create_files_in(
&dir,
&[
("src/index.html", "content-['src/index.html']"),
("src/keep.html", "content-['src/keep.html']"),
],
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source '**/*'",
)]);
assert_eq!(
scanner.scan(),
vec!["content-['src/index.html']", "content-['src/keep.html']"]
);
assert_eq!(
scanner.scan(),
vec!["content-['src/index.html']", "content-['src/keep.html']"]
);
assert_eq!(scanner.get_scanned_files(), Vec::<String>::new());
sleep(Duration::from_millis(10));
fs::write(dir.join("src/index.html"), "content-['src/changed.html']").unwrap();
assert_eq!(
scanner.scan(),
vec![
"content-['src/changed.html']",
"content-['src/index.html']",
"content-['src/keep.html']",
]
);
assert_eq!(
normalize_files(scanner.get_scanned_files(), &dir),
vec!["src/index.html"]
);
}
#[test]
fn it_should_ignore_negated_custom_sources() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
("src/index.ts", "content-['src/index.ts']"),
("src/colors/red.jsx", "content-['src/colors/red.jsx']"),
("src/colors/blue.tsx", "content-['src/colors/blue.tsx']"),
("src/colors/green.tsx", "content-['src/colors/green.tsx']"),
("src/utils/string.ts", "content-['src/utils/string.ts']"),
("src/utils/date.ts", "content-['src/utils/date.ts']"),
("src/utils/file.ts", "content-['src/utils/file.ts']"),
(
"src/admin/foo/template.html",
"content-['src/admin/template.html']",
),
(
"src/templates/index.html",
"content-['src/templates/index.html']",
),
],
vec![
"@source '**/*'",
"@source not 'src/index.ts'",
"@source not '**/*.{jsx,tsx}'",
"@source not 'src/utils'",
"@source not 'dist'",
],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
├── ✓ admin
│ └── ✓ foo
│ └── ✓ template.html
├── ✗ colors
│ ├── ✗ blue.tsx
│ ├── ✗ green.tsx
│ └── ✗ red.jsx
├── ✗ index.ts
├── ✓ templates
│ └── ✓ index.html
└── ✗ utils
├── ✗ date.ts
├── ✗ file.ts
└── ✗ string.ts
");
assert_eq!(
candidates,
vec![
"content-['src/admin/template.html']",
"content-['src/templates/index.html']",
]
);
assert_eq!(
files,
vec![
"src/admin/foo/template.html",
"src/templates/index.html",
]
);
assert_eq!(
globs,
vec![
"*",
"src/*/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"src/admin/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"src/colors/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"src/templates/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
]
);
assert_eq!(normalized_sources, vec!["**/*",]);
}
#[test]
fn it_should_include_defined_extensions_that_are_ignored_by_default() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
("src/index.exe", "content-['src/index.exe']"),
("src/index.bin", "content-['src/index.bin']"),
("out/out.exe", "content-['out/out.exe']"),
],
vec!["@source '**/*'", "@source 'src/**/*.{exe,bin}'"],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ out
│ └── ✗ out.exe
└── ✓ src
├── ✓ index.bin
└── ✓ index.exe
");
assert_eq!(
candidates,
vec!["content-['src/index.bin']", "content-['src/index.exe']",]
);
assert_eq!(files, vec!["src/index.bin", "src/index.exe",]);
assert_eq!(
globs,
vec![
"*",
"out/**/*.{aspx,astro,bin,cjs,cts,eex,erb,exe,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"src/{**/*.bin,**/*.exe,**/*.{aspx,astro,bin,cjs,cts,eex,erb,exe,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}}",
]
);
assert_eq!(
normalized_sources,
vec!["**/*", "src/**/*.bin", "src/**/*.exe"]
);
}
#[test]
fn it_should_work_with_manual_glob_only() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
("index.html", "content-['index.html']"),
("src/index.html", "content-['src/index.html']"),
("src/ignore.html", "content-['src/ignore.html']"),
("src/admin/index.html", "content-['src/admin/index.html']"),
("src/admin/ignore.html", "content-['src/admin/ignore.html']"),
(
"src/dashboard/index.html",
"content-['src/dashboard/index.html']",
),
(
"src/dashboard/ignore.html",
"content-['src/dashboard/ignore.html']",
),
("src/lib.ts", "content-['src/lib.ts']"),
],
vec![
"@source './src/**/*.html'",
"@source not './src/index.html'",
"@source not './src/**/ignore.html'",
],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ index.html
└── ✓ src
├── ✓ admin
│ ├── ✗ ignore.html
│ └── ✓ index.html
├── ✓ dashboard
│ ├── ✗ ignore.html
│ └── ✓ index.html
├── ✗ ignore.html
├── ✗ index.html
└── ✗ lib.ts
");
assert_eq!(
candidates,
vec![
"content-['src/admin/index.html']",
"content-['src/dashboard/index.html']",
]
);
assert_eq!(
files,
vec!["src/admin/index.html", "src/dashboard/index.html",]
);
assert_eq!(globs, vec!["src/**/*.html"]);
assert_eq!(normalized_sources, vec!["src/**/*.html"]);
}
#[test]
fn it_should_restrict_explicit_file_sources_to_the_matching_file() {
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
&[
("src/foo.html", "content-['src/foo.html']"),
("src/bar.html", "content-['src/bar.html']"),
],
vec!["@source './src/foo.html'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
├── ✗ bar.html
└── ✓ foo.html
");
assert_eq!(candidates, vec!["content-['src/foo.html']"]);
assert_eq!(files, vec!["src/foo.html"]);
}
#[test]
fn it_should_combine_multiple_restricted_sources_for_the_same_base() {
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
&[
("src/foo.html", "content-['src/foo.html']"),
("src/bar.html", "content-['src/bar.html']"),
("src/baz.html", "content-['src/baz.html']"),
],
vec!["@source './src/foo.html'", "@source './src/bar.html'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
├── ✓ bar.html
├── ✗ baz.html
└── ✓ foo.html
");
assert_eq!(
candidates,
vec!["content-['src/bar.html']", "content-['src/foo.html']"]
);
assert_eq!(files, vec!["src/bar.html", "src/foo.html"]);
}
#[test]
fn it_should_combine_nested_and_root_restricted_sources() {
let paths_with_content = &[
("nested/component.html", "content-['nested/component.html']"),
("nested/ignore-me.html", "content-['nested/ignore-me.html']"),
("ignore-me/component.html", "content-['ignore-me']"),
("component-sources.classes.txt", "content-['classes.txt']"),
("ignore-me.txt", "content-['ignore-me.txt']"),
];
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
paths_with_content,
vec![
"@source './nested/component.html'",
"@source './component-sources.classes.txt'",
],
);
assert_snapshot!(tree, @"
. (git)
├── ✓ component-sources.classes.txt
├── ✗ ignore-me
│ └── ✗ component.html
├── ✗ ignore-me.txt
└── ✓ nested
├── ✓ component.html
└── ✗ ignore-me.html
");
assert_eq!(
candidates,
vec![
"content-['classes.txt']",
"content-['nested/component.html']"
]
);
assert_eq!(
files,
vec!["component-sources.classes.txt", "nested/component.html"]
);
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
paths_with_content,
vec![
"@source './component-sources.classes.txt'",
"@source './nested/component.html'",
],
);
assert_snapshot!(tree, @"
. (git)
├── ✓ component-sources.classes.txt
├── ✗ ignore-me
│ └── ✗ component.html
├── ✗ ignore-me.txt
└── ✓ nested
├── ✓ component.html
└── ✗ ignore-me.html
");
assert_eq!(
candidates,
vec![
"content-['classes.txt']",
"content-['nested/component.html']"
]
);
assert_eq!(
files,
vec!["component-sources.classes.txt", "nested/component.html"]
);
}
#[test]
fn it_should_allow_later_ignores_to_override_restricted_sources() {
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
&[("src/foo.html", "content-['src/foo.html']")],
vec!["@source './src/foo.html'", "@source not './src/foo.html'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✗ src
└── ✗ foo.html
");
assert!(candidates.is_empty());
assert!(files.is_empty());
}
#[test]
fn it_should_handle_sources_with_parent_patterns() {
{
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
&[
("src/foo.html", "content-['src/foo.html']"),
("src/bar/index.html", "content-['src/bar/index.html']"),
("src/bar/ignore.html", "content-['src/bar/ignore.html']"),
],
vec!["@source './src/ba*/*.html'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
├── ✓ bar
│ ├── ✓ ignore.html
│ └── ✓ index.html
└── ✗ foo.html
");
assert_eq!(
candidates,
vec![
"content-['src/bar/ignore.html']",
"content-['src/bar/index.html']"
]
);
assert_eq!(files, vec!["src/bar/ignore.html", "src/bar/index.html"]);
}
{
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
&[
("src/foo.html", "content-['src/foo.html']"),
("src/bar/index.html", "content-['src/bar/index.html']"),
("src/bar/ignore.html", "content-['src/bar/ignore.html']"),
],
vec![
"@source './src/ba*/*.html'",
"@source not './src/bar/ignore.html'",
],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
├── ✓ bar
│ ├── ✗ ignore.html
│ └── ✓ index.html
└── ✗ foo.html
");
assert_eq!(candidates, vec!["content-['src/bar/index.html']"]);
assert_eq!(files, vec!["src/bar/index.html"]);
}
{
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
&[
("src/foo.html", "content-['src/foo.html']"),
("src/bar/index.html", "content-['src/bar/index.html']"),
("src/bar/ignore.html", "content-['src/bar/ignore.html']"),
],
vec!["@source '**/*'", "@source not './src/ba*/*.html'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
├── ✗ bar
│ ├── ✗ ignore.html
│ └── ✗ index.html
└── ✓ foo.html
");
assert_eq!(candidates, vec!["content-['src/foo.html']"]);
assert_eq!(files, vec!["src/foo.html"]);
}
{
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
&[
("src/foo.html", "content-['src/foo.html']"),
("src/bar/index.html", "content-['src/bar/index.html']"),
("src/bar/ignore.html", "content-['src/bar/ignore.html']"),
],
vec![
"@source '**/*'",
"@source not './src/ba*/*.html'",
"@source './src/bar/index.html'",
],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
├── ✓ bar
│ ├── ✗ ignore.html
│ └── ✓ index.html
└── ✓ foo.html
");
assert_eq!(
candidates,
vec!["content-['src/bar/index.html']", "content-['src/foo.html']"]
);
assert_eq!(files, vec!["src/bar/index.html", "src/foo.html"]);
}
}
#[test]
fn nested_explicit_source_should_not_suppress_siblings_of_broad_source() {
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
&[
("src/components/button.html", "content-['button']"),
("src/components/card.html", "content-['card']"),
],
vec!["@source '**/*'", "@source './src/components/button.html'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
└── ✓ components
├── ✓ button.html
└── ✓ card.html
");
assert_eq!(candidates, vec!["content-['button']", "content-['card']"]);
assert_eq!(
files,
vec!["src/components/button.html", "src/components/card.html"]
);
}
#[test]
fn nested_explicit_source_should_not_suppress_siblings_of_auto_source() {
let ScanResult {
candidates,
files,
tree,
..
} = scan_with_globs(
&[
("src/components/button.html", "content-['button']"),
("src/components/card.html", "content-['card']"),
],
vec!["@source 'src'", "@source './src/components/button.html'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
└── ✓ components
├── ✓ button.html
└── ✓ card.html
");
assert_eq!(candidates, vec!["content-['button']", "content-['card']"]);
assert_eq!(
files,
vec!["src/components/button.html", "src/components/card.html"]
);
}
#[test]
fn root_file_source_should_not_suppress_sibling_source_roots() {
let ScanResult {
candidates, tree, ..
} = scan_with_globs(
&[
("index.css", ""),
("src/index.html", "content-['src/index.html']"),
("pages/foo.html", "content-['pages/foo.html']"),
("pages/nested/foo.html", "content-['pages/nested/foo.html']"),
],
vec![
"@source './src'",
"@source './pages/**/*.html'",
"@source './index.css'",
],
);
assert_snapshot!(tree, @"
. (git)
├── ✓ index.css
├── ✓ pages
│ ├── ✓ foo.html
│ └── ✓ nested
│ └── ✓ foo.html
└── ✓ src
└── ✓ index.html
");
assert_eq!(
candidates,
vec![
"content-['pages/foo.html']",
"content-['pages/nested/foo.html']",
"content-['src/index.html']",
]
);
}
#[test]
fn it_respects_gitignore_in_workspace_root() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
(".gitignore", "ignore-1.html\nweb/ignore-2.html"),
("src/index.html", "content-['src/index.html']"),
("web/index.html", "content-['web/index.html']"),
("web/ignore-1.html", "content-['web/ignore-1.html']"),
("web/ignore-2.html", "content-['web/ignore-2.html']"),
],
vec!["@source './src'", "@source './web'"],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ ignore-1.html
│ web/ignore-2.html
├── ✓ src
│ └── ✓ index.html
└── ✓ web
├── ✗ ignore-1.html
├── ✗ ignore-2.html
└── ✓ index.html
");
assert_eq!(
candidates,
vec!["content-['src/index.html']", "content-['web/index.html']",]
);
assert_eq!(files, vec!["src/index.html", "web/index.html",]);
assert_eq!(globs, vec!["src/*", "web/*",]);
assert_eq!(normalized_sources, vec!["src/**/*", "web/**/*",]);
}
#[test]
fn it_includes_skipped_by_default_extensions_with_a_specific_source() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
("src/logo.jpg", "content-['/src/logo.jpg']"),
("src/logo.png", "content-['/src/logo.png']"),
],
vec!["@source './src/logo.{jpg,png}'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
├── ✓ logo.jpg
└── ✓ logo.png
");
assert_eq!(
candidates,
vec!["content-['/src/logo.jpg']", "content-['/src/logo.png']"]
);
assert_eq!(files, vec!["src/logo.jpg", "src/logo.png"]);
assert!(globs.is_empty());
assert_eq!(normalized_sources, vec!["src/logo.jpg", "src/logo.png"]);
}
#[test]
fn it_respects_gitignore_in_workspace_root_for_manual_globs() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
(".gitignore", "ignore-1.html\n/web/ignore-2.html"),
("web/index.html", "content-['web/index.html']"),
("web/ignore-1.html", "content-['web/ignore-1.html']"),
("web/ignore-2.html", "content-['web/ignore-2.html']"),
],
vec!["@source './web'", "@source './web/ignore-1.html'"],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ ignore-1.html
│ /web/ignore-2.html
└── ✓ web
├── ✓ ignore-1.html
├── ✗ ignore-2.html
└── ✓ index.html
");
assert_eq!(
candidates,
vec![
"content-['web/ignore-1.html']",
"content-['web/index.html']",
]
);
assert_eq!(files, vec!["web/ignore-1.html", "web/index.html",]);
assert_eq!(globs, vec!["web/*"]);
assert_eq!(normalized_sources, vec!["web/**/*", "web/ignore-1.html"]);
}
#[test]
fn it_respects_gitignore_files_with_whitelist_patterns() {
let ScanResult {
files,
candidates,
tree,
..
} = scan(&[
(
".gitignore",
"/*\n!/app\n!/public\n!/package.json\n!/.gitignore\n",
),
("app/index.html", "content-['app/index.html']"),
("public/index.html", "content-['public/index.html']"),
("package.json", ""),
("build/generated.html", "content-['build/generated.html']"),
("logs/dev.log", "content-['logs/dev.log']"),
(
"node_modules/my-ui-lib/index.html",
"content-['node_modules/my-ui-lib/index.html']",
),
]);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ /*
│ !/app
│ !/public
│ !/package.json
│ !/.gitignore
├── ✓ app
│ └── ✓ index.html
├── ✗ build
│ └── ✗ generated.html
├── ✗ logs
│ └── ✗ dev.log
├── ✗ node_modules
│ └── ✗ my-ui-lib
│ └── ✗ index.html
├── ✓ package.json
└── ✓ public
└── ✓ index.html
");
assert_eq!(
files,
vec!["app/index.html", "package.json", "public/index.html"]
);
assert_eq!(
candidates,
vec![
"content-['app/index.html']",
"content-['public/index.html']"
]
);
}
#[test]
fn it_respects_gitignore_files_with_nested_whitelist_patterns() {
let ScanResult {
files,
candidates,
tree,
..
} = scan(&[
(
".gitignore",
"# exclude everything except directory foo/bar\n/*\n!/foo\n/foo/*\n!/foo/bar\n",
),
("foo/bar/index.html", "content-['foo/bar/index.html']"),
(
"foo/bar/nested/index.html",
"content-['foo/bar/nested/index.html']",
),
("index.html", "content-['index.html']"),
("foo/index.html", "content-['foo/index.html']"),
("foo/baz/index.html", "content-['foo/baz/index.html']"),
]);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ # exclude everything except directory foo/bar
│ /*
│ !/foo
│ /foo/*
│ !/foo/bar
├── ✓ foo
│ ├── ✓ bar
│ │ ├── ✓ index.html
│ │ └── ✓ nested
│ │ └── ✓ index.html
│ ├── ✗ baz
│ │ └── ✗ index.html
│ └── ✗ index.html
└── ✗ index.html
");
assert_eq!(
files,
vec!["foo/bar/index.html", "foo/bar/nested/index.html"]
);
assert_eq!(
candidates,
vec![
"content-['foo/bar/index.html']",
"content-['foo/bar/nested/index.html']"
]
);
}
#[test]
fn explicit_source_directories_respect_their_own_gitignore() {
let ScanResult {
files,
candidates,
tree,
..
} = scan_with_globs(
&[
("vendor/.gitignore", "ignored.html"),
("vendor/index.html", "content-['vendor/index.html']"),
("vendor/ignored.html", "content-['vendor/ignored.html']"),
],
vec!["@source 'vendor'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ vendor
├── ✗ .gitignore
│ ignored.html
├── ✗ ignored.html
└── ✓ index.html
");
assert_eq!(files, vec!["vendor/index.html"]);
assert_eq!(candidates, vec!["content-['vendor/index.html']"]);
}
#[test]
fn explicit_sources_can_include_files_inside_gitignored_parent_directories() {
let ScanResult {
candidates,
files,
globs,
tree,
..
} = scan_with_globs(
&[
(".gitignore", "src/efgh/"),
("src/efgh/index.html", "content-['src/efgh/index.html']"),
("src/efgh/ignore.js", "content-['src/efgh/ignore.js']"),
("src/abcd/index.html", "content-['src/abcd/index.html']"),
],
vec!["@source './src/ef*/*.html'"],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ src/efgh/
└── ✓ src
├── ✗ abcd
│ └── ✗ index.html
└── ✓ efgh
├── ✗ ignore.js
└── ✓ index.html
");
assert_eq!(candidates, vec!["content-['src/efgh/index.html']"]);
assert_eq!(files, vec!["src/efgh/index.html"]);
assert_eq!(globs, vec!["src/ef*/*.html"]);
}
#[test]
fn skips_ignore_files_outside_of_a_repo() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
("home/project/apps/web/index.html", "content-['index.html']"),
("home/.gitignore", "ignore-home.html"),
("home/project/.gitignore", "ignore-project.html"),
("home/project/apps/.gitignore", "ignore-apps.html"),
("home/project/apps/web/.gitignore", "ignore-web.html"),
(
"home/project/apps/web/ignore-home.html",
"content-['ignore-home.html']",
),
(
"home/project/apps/web/ignore-project.html",
"content-['ignore-project.html']",
),
(
"home/project/apps/web/ignore-apps.html",
"content-['ignore-apps.html']",
),
(
"home/project/apps/web/ignore-web.html",
"content-['ignore-web.html']",
),
(
"home/project/apps/admin/index.html",
"content-['home/project/apps/admin/index.html']",
),
(
"home/project/apps/dashboard/index.html",
"content-['home/project/apps/dashboard/index.html']",
),
],
);
let sources = vec![
public_source_entry_from_pattern(
dir.join("home/project/apps/web")
.to_string_lossy()
.to_string()
.into(),
"@source '**/*'",
),
public_source_entry_from_pattern(
dir.join("home/project/apps/web")
.to_string_lossy()
.to_string()
.into(),
"@source '../admin'",
),
public_source_entry_from_pattern(
dir.join("home/project/apps/web")
.to_string_lossy()
.to_string()
.into(),
"@source '../dashboard/*.html'",
),
];
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✓ home
├── ✗ .gitignore
│ ignore-home.html
└── ✓ project
├── ✗ .gitignore
│ ignore-project.html
└── ✓ apps
├── ✗ .gitignore
│ ignore-apps.html
├── ✓ admin
│ └── ✓ index.html
├── ✓ dashboard
│ └── ✓ index.html
└── ✓ web
├── ✗ .gitignore
│ ignore-web.html
├── ✗ ignore-apps.html
├── ✗ ignore-home.html
├── ✗ ignore-project.html
├── ✗ ignore-web.html
└── ✓ index.html
");
assert_eq!(
candidates,
vec![
"content-['home/project/apps/admin/index.html']",
"content-['home/project/apps/dashboard/index.html']",
"content-['index.html']"
]
);
_ = Command::new("git")
.arg("init")
.current_dir(dir.join("home"))
.output();
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✓ home (git)
├── ✗ .gitignore
│ ignore-home.html
└── ✓ project
├── ✗ .gitignore
│ ignore-project.html
└── ✓ apps
├── ✗ .gitignore
│ ignore-apps.html
├── ✓ admin
│ └── ✓ index.html
├── ✓ dashboard
│ └── ✓ index.html
└── ✓ web
├── ✗ .gitignore
│ ignore-web.html
├── ✗ ignore-apps.html
├── ✗ ignore-home.html
├── ✗ ignore-project.html
├── ✗ ignore-web.html
└── ✓ index.html
");
assert_eq!(
candidates,
vec![
"content-['home/project/apps/admin/index.html']",
"content-['home/project/apps/dashboard/index.html']",
"content-['index.html']"
]
);
fs::remove_dir_all(dir.join("home/.git")).unwrap();
_ = Command::new("git")
.arg("init")
.current_dir(dir.join("home/project"))
.output();
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✓ home
├── ✗ .gitignore
│ ignore-home.html
└── ✓ project (git)
├── ✗ .gitignore
│ ignore-project.html
└── ✓ apps
├── ✗ .gitignore
│ ignore-apps.html
├── ✓ admin
│ └── ✓ index.html
├── ✓ dashboard
│ └── ✓ index.html
└── ✓ web
├── ✗ .gitignore
│ ignore-web.html
├── ✗ ignore-apps.html
├── ✓ ignore-home.html
├── ✗ ignore-project.html
├── ✗ ignore-web.html
└── ✓ index.html
");
assert_eq!(
candidates,
vec![
"content-['home/project/apps/admin/index.html']",
"content-['home/project/apps/dashboard/index.html']",
"content-['ignore-home.html']",
"content-['index.html']"
]
);
fs::remove_dir_all(dir.join("home/project/.git")).unwrap();
_ = Command::new("git")
.arg("init")
.current_dir(dir.join("home/project/apps"))
.output();
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✓ home
├── ✗ .gitignore
│ ignore-home.html
└── ✓ project
├── ✗ .gitignore
│ ignore-project.html
└── ✓ apps (git)
├── ✗ .gitignore
│ ignore-apps.html
├── ✓ admin
│ └── ✓ index.html
├── ✓ dashboard
│ └── ✓ index.html
└── ✓ web
├── ✗ .gitignore
│ ignore-web.html
├── ✗ ignore-apps.html
├── ✓ ignore-home.html
├── ✓ ignore-project.html
├── ✗ ignore-web.html
└── ✓ index.html
");
assert_eq!(
candidates,
vec![
"content-['home/project/apps/admin/index.html']",
"content-['home/project/apps/dashboard/index.html']",
"content-['ignore-home.html']",
"content-['ignore-project.html']",
"content-['index.html']"
]
);
fs::remove_dir_all(dir.join("home/project/apps/.git")).unwrap();
_ = Command::new("git")
.arg("init")
.current_dir(dir.join("home/project/apps/web"))
.output();
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✓ home
├── ✗ .gitignore
│ ignore-home.html
└── ✓ project
├── ✗ .gitignore
│ ignore-project.html
└── ✓ apps
├── ✗ .gitignore
│ ignore-apps.html
├── ✓ admin
│ └── ✓ index.html
├── ✓ dashboard
│ └── ✓ index.html
└── ✓ web (git)
├── ✗ .gitignore
│ ignore-web.html
├── ✓ ignore-apps.html
├── ✓ ignore-home.html
├── ✓ ignore-project.html
├── ✗ ignore-web.html
└── ✓ index.html
");
assert_eq!(
candidates,
vec![
"content-['home/project/apps/admin/index.html']",
"content-['home/project/apps/dashboard/index.html']",
"content-['ignore-apps.html']",
"content-['ignore-home.html']",
"content-['ignore-project.html']",
"content-['index.html']",
]
);
}
#[test]
fn test_explicitly_ignore_explicitly_allowed_files() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
("src/keep-me.html", "content-['keep-me.html']"),
("src/ignore-me.html", "content-['ignore-me.html']"),
],
);
let sources = vec![
public_source_entry_from_pattern(dir.clone(), "@source '**/*.html'"),
public_source_entry_from_pattern(dir.clone(), "@source not 'src/ignore-me.html'"),
];
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✓ src
├── ✗ ignore-me.html
└── ✓ keep-me.html
");
assert_eq!(candidates, vec!["content-['keep-me.html']"]);
}
#[test]
fn test_works_with_filenames_containing_glob_characters() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
("src/app/[foo]/ignore-me.html", "content-['ignore-me.html']"),
("src/app/[foo]/keep-me.html", "content-['keep-me.html']"),
],
);
let sources = vec![
public_source_entry_from_pattern(dir.clone(), "@source '**/*'"),
public_source_entry_from_pattern(
dir.clone(),
"@source not 'src/app/[foo]/ignore*.html'",
),
];
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✓ src
└── ✓ app
└── ✓ [foo]
├── ✗ ignore-me.html
└── ✓ keep-me.html
");
assert_eq!(candidates, vec!["content-['keep-me.html']"]);
}
#[test]
fn test_ignore_files_can_be_included_with_custom_source_rule() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[("src/keep-me.html", "content-['src/keep-me.html']")],
);
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(dir.clone(), "@source '**/*.html'"),
public_source_entry_from_pattern(
dir.clone(),
"@source not 'src/ignored-by-source-not.html'",
),
]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['src/keep-me.html']"]);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✓ src
└── ✓ keep-me.html
");
create_files_in(
&dir,
&[
("src/new-file.html", "content-['src/new-file.html']"),
(
"src/ignore-by-extension.bin",
"content-['src/ignore-by-extension.bin']",
),
(".gitignore", "src/ignored-by-gitignore.html"),
(
"src/ignored-by-gitignore.html",
"content-['src/ignored-by-gitignore.html']",
),
(
"src/ignored-by-source-not.html",
"content-['src/ignored-by-source-not.html']",
),
],
);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ src/ignored-by-gitignore.html
└── ✓ src
├── ✗ ignore-by-extension.bin
├── ✓ ignored-by-gitignore.html
├── ✗ ignored-by-source-not.html
├── ✓ keep-me.html
└── ✓ new-file.html
");
assert_eq!(
candidates,
vec![
"content-['src/ignored-by-gitignore.html']",
"content-['src/keep-me.html']",
"content-['src/new-file.html']"
]
);
}
#[test]
fn test_allow_default_ignored_files() {
let dir = tempdir().unwrap().into_path();
create_files_in(&dir, &[("foo.styl", "content-['foo.styl']")]);
let sources = vec![public_source_entry_from_pattern(
dir.clone(),
"@source '**/*'",
)];
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert!(candidates.is_empty());
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✗ foo.styl
");
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(dir.clone(), "@source '**/*'"),
public_source_entry_from_pattern(dir.clone(), "@source '*.styl'"),
]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['foo.styl']"]);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
└── ✓ foo.styl
");
}
#[test]
fn test_allow_default_ignored_files_via_gitignore() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
("index.html", "content-['index.html']"),
(".gitignore", "index.html"),
],
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source '**/*'",
)]);
let candidates = scanner.scan();
assert!(candidates.is_empty());
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ index.html
└── ✗ index.html
");
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(dir.clone(), "@source '**/*'"),
public_source_entry_from_pattern(dir.clone(), "@source './*.html'"),
]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['index.html']"]);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ index.html
└── ✓ index.html
");
}
#[test]
fn test_allow_explicit_node_modules_paths() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
("src/index.html", "content-['src/index.html']"),
(".gitignore", "node_modules"),
(
"node_modules/my-ui-lib/index.html",
"content-['node_modules/my-ui-lib/index.html']",
),
],
);
let sources = vec![public_source_entry_from_pattern(
dir.clone(),
"@source './'",
)];
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['src/index.html']"]);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ node_modules
├── ✗ node_modules
│ └── ✗ my-ui-lib
│ └── ✗ index.html
└── ✓ src
└── ✓ index.html
");
let sources = vec![public_source_entry_from_pattern(
dir.clone(),
"@source '**/*.html'",
)];
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['src/index.html']"]);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ node_modules
├── ✗ node_modules
│ └── ✗ my-ui-lib
│ └── ✗ index.html
└── ✓ src
└── ✓ index.html
");
let sources = vec![
public_source_entry_from_pattern(dir.clone(), "@source '**/*.html'"),
public_source_entry_from_pattern(dir.clone(), "@source 'node_modules/my-ui-lib'"),
];
let mut scanner = Scanner::new(sources.clone());
let candidates = scanner.scan();
assert_eq!(
candidates,
vec![
"content-['node_modules/my-ui-lib/index.html']",
"content-['src/index.html']"
]
);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ node_modules
├── ✓ node_modules
│ └── ✓ my-ui-lib
│ └── ✓ index.html
└── ✓ src
└── ✓ index.html
");
}
#[test]
fn test_allow_explicit_sources_ignored_by_allow_list_gitignore() {
let ScanResult {
candidates, tree, ..
} = scan_with_globs(
&[
(".gitignore", "*\n!/app\n!/app/design\n!/app/design/**\n"),
(
"app/design/frontend/theme/templates/component.phtml",
"content-['app/design/frontend/theme/templates/component.phtml']",
),
(
"vendor/acme/theme/module/templates/component.phtml",
"content-['vendor/acme/theme/module/templates/component.phtml']",
),
],
vec!["@source 'vendor/acme/theme'"],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ *
│ !/app
│ !/app/design
│ !/app/design/**
├── ✗ app
│ └── ✗ design
│ └── ✗ frontend
│ └── ✗ theme
│ └── ✗ templates
│ └── ✗ component.phtml
└── ✓ vendor
└── ✓ acme
└── ✓ theme
└── ✓ module
└── ✓ templates
└── ✓ component.phtml
");
assert_eq!(
candidates,
vec!["content-['vendor/acme/theme/module/templates/component.phtml']"]
);
}
#[test]
fn test_ignore_node_modules_without_gitignore() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
(
"packages/web/index.html",
"content-['packages/web/index.html']",
),
(
"node_modules/index.html",
"content-['node_modules/index.html']",
),
(
"packages/web/node_modules/index.html",
"content-['packages/web/node_modules/index.html']",
),
],
vec!["@source '**/*'"],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ node_modules
│ └── ✗ index.html
└── ✓ packages
└── ✓ web
├── ✓ index.html
└── ✗ node_modules
└── ✗ index.html
");
assert_eq!(candidates, vec!["content-['packages/web/index.html']"]);
assert_eq!(files, vec!["packages/web/index.html",]);
assert_eq!(globs, vec!["*", "packages/*/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}", "packages/web/*/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn test_ignore_gitignore_in_node_modules_source() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
(".gitignore", "node_modules\ndist"),
(
"node_modules/my-ui-lib/dist/index.html",
"content-['node_modules/my-ui-lib/dist/index.html']",
),
(
"node_modules/my-ui-lib/node.exe",
"content-['node_modules/my-ui-lib/node.exe']",
),
],
vec!["@source 'node_modules/my-ui-lib'"],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ node_modules
│ dist
└── ✓ node_modules
└── ✓ my-ui-lib
├── ✓ dist
│ └── ✓ index.html
└── ✗ node.exe
");
assert_eq!(
candidates,
vec!["content-['node_modules/my-ui-lib/dist/index.html']"]
);
assert_eq!(files, vec!["node_modules/my-ui-lib/dist/index.html"]);
assert_eq!(globs, vec!["node_modules/my-ui-lib/*", "node_modules/my-ui-lib/dist/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]);
assert_eq!(normalized_sources, vec!["node_modules/my-ui-lib/**/*"]);
}
#[test]
fn test_manually_scanning_files_should_follow_all_rules() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
(".gitignore", "*.jsx\ngenerated/"),
(
"src/components/button.tsx",
"content-['src/components/button.tsx']",
),
(
"src/components/button.jsx",
"content-['src/components/button.jsx']",
),
],
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source '**/*'",
)]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['src/components/button.tsx']"]);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ *.jsx
│ generated/
└── ✓ src
└── ✓ components
├── ✗ button.jsx
└── ✓ button.tsx
");
create_files_in(
&dir,
&[
(
"src/components/good.tsx",
"content-['src/components/good.tsx']",
),
(
"src/components/bad.jsx",
"content-['src/components/bad.jsx']",
),
],
);
let candidates = scanner.scan_content(vec![
ChangedContent::File(dir.join("src/components/good.tsx"), "tsx".to_owned()),
ChangedContent::File(dir.join("src/components/bad.jsx"), "jsx".to_owned()),
]);
assert_eq!(candidates, vec!["content-['src/components/good.tsx']"]);
create_files_in(
&dir,
&[
(
"src/components/generated/bad.tsx",
"content-['src/components/generated/bad.tsx']",
),
(
"src/components/generated/bad.jsx",
"content-['src/components/generated/bad.jsx']",
),
],
);
let candidates = scanner.scan_content(vec![
ChangedContent::File(
dir.join("src/components/generated/bad.tsx"),
"tsx".to_owned(),
),
ChangedContent::File(
dir.join("src/components/generated/bad.jsx"),
"jsx".to_owned(),
),
]);
assert!(candidates.is_empty());
}
#[test]
fn test_works_with_utf8_special_character_paths() {
let ScanResult {
candidates,
files,
globs,
normalized_sources,
tree,
..
} = scan_with_globs(
&[
("src/💩.js", "content-['src/💩.js']"),
("src/🤦♂️.tsx", "content-['src/🤦♂️.tsx']"),
("src/🤦♂️/foo.tsx", "content-['src/🤦♂️/foo.tsx']"),
],
vec!["@source '**/*'", "@source not 'src/🤦♂️'"],
);
assert_snapshot!(tree, @"
. (git)
└── ✓ src
├── ✓ 💩.js
├── ✗ 🤦♂️
│ └── ✗ foo.tsx
└── ✓ 🤦♂️.tsx
");
assert_eq!(
candidates,
vec!["content-['src/💩.js']", "content-['src/🤦♂️.tsx']"]
);
assert_eq!(files, vec!["src/💩.js", "src/🤦♂️.tsx"]);
assert_eq!(globs, vec!["*", "src/*/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
#[test]
fn test_glob_with_symlinks() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
(".gitignore", "node_modules\ndist"),
(
"node_modules/.pnpm/@org+my-ui-library/dist/index.ts",
"content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']",
),
("node_modules/@org/.gitkeep", ""),
],
);
let _ = symlink(
dir.join("node_modules/.pnpm/@org+my-ui-library"),
dir.join("node_modules/@org/my-ui-library"),
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source 'node_modules'",
)]);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ node_modules
│ dist
└── ✓ node_modules
├── ✓ .pnpm
│ └── ✓ @org+my-ui-library
│ └── ✓ dist
│ └── ✓ index.ts
└── ✓ @org
├── ✓ .gitkeep
└── ✓ my-ui-library → ../.pnpm/@org+my-ui-library
└── ✓ dist
└── ✓ index.ts
");
assert_eq!(
candidates,
vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"]
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source 'node_modules/@org/my-ui-library'",
)]);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ node_modules
│ dist
└── ✓ node_modules
├── ✓ .pnpm
│ └── ✓ @org+my-ui-library
│ └── ✓ dist
│ └── ✓ index.ts
└── ✓ @org
├── ✗ .gitkeep
└── ✓ my-ui-library → ../.pnpm/@org+my-ui-library
└── ✓ dist
└── ✓ index.ts
");
assert_eq!(
candidates,
vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"]
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source 'node_modules/@org'",
)]);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ .gitignore
│ node_modules
│ dist
└── ✓ node_modules
├── ✓ .pnpm
│ └── ✓ @org+my-ui-library
│ └── ✓ dist
│ └── ✓ index.ts
└── ✓ @org
├── ✓ .gitkeep
└── ✓ my-ui-library → ../.pnpm/@org+my-ui-library
└── ✓ dist
└── ✓ index.ts
");
assert_eq!(
candidates,
vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"]
);
}
#[test]
fn test_globs_with_recursive_symlinks() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
("b/index.html", "content-['b/index.html']"),
("z/index.html", "content-['z/index.html']"),
],
);
let _ = symlink(dir.join("b"), dir.join("a"));
let _ = symlink(dir.join("c"), dir.join("b/c"));
let _ = symlink(&dir, dir.join("b/root"));
let _ = symlink(dir.join("a"), dir.join("c"));
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source '.'",
)]);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✓ a → b
│ ├── ✗ c → ../c
│ ├── ✓ index.html
│ └── ✗ root → ..
├── ✓ b
│ ├── ✗ c → ../c
│ ├── ✓ index.html
│ └── ✗ root → ..
├── ✓ c → a
│ ├── ✗ c → .
│ ├── ✓ index.html
│ └── ✗ root → ..
└── ✓ z
└── ✓ index.html
");
assert_eq!(
candidates,
vec!["content-['b/index.html']", "content-['z/index.html']"]
);
}
#[test]
fn test_partial_globs_with_symlinks() {
let dir = tempdir().unwrap().into_path();
create_files_in(&dir, &[("abcd/xyz.html", "content-['abcd/xyz.html']")]);
let _ = symlink(dir.join("abcd"), dir.join("efgh"));
let mut scanner = Scanner::new(vec![]);
let candidates = scanner.scan();
assert!(candidates.is_empty());
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source 'efgh/*.html'",
)]);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✓ abcd
│ └── ✓ xyz.html
└── ✓ efgh → abcd
└── ✓ xyz.html
");
assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source 'ef*/*.html'",
)]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]);
}
#[test]
fn test_symlinked_sources_within_the_scanned_tree_can_be_ignored() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
("src/keep.html", "content-['src/keep.html']"),
(
"actual-dir/ignore.html",
"content-['actual-dir/ignore.html']",
),
("actual-file.html", "content-['actual-file.html']"),
],
);
let _ = symlink(dir.join("actual-dir"), dir.join("linked-dir"));
let _ = symlink_file(dir.join("actual-file.html"), dir.join("linked-file.html"));
let base = dir.join("src");
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(base.clone(), "@source '../**/*'"),
public_source_entry_from_pattern(base.clone(), "@source not '../actual-dir'"),
public_source_entry_from_pattern(base.clone(), "@source not '../linked-dir'"),
public_source_entry_from_pattern(base.clone(), "@source not '../actual-file.html'"),
public_source_entry_from_pattern(base.clone(), "@source not '../linked-file.html'"),
]);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ actual-dir
│ └── ✗ ignore.html
├── ✗ actual-file.html
├── ✗ linked-dir → actual-dir
│ └── ✗ ignore.html
├── ✗ linked-file.html → actual-file.html
└── ✓ src
└── ✓ keep.html
");
assert_eq!(candidates, vec!["content-['src/keep.html']"]);
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(base.clone(), "@source '../**/*'"),
public_source_entry_from_pattern(base.clone(), "@source not '../actual-dir/**/*.html'"),
public_source_entry_from_pattern(base.clone(), "@source not '../linked-dir/**/*.html'"),
public_source_entry_from_pattern(base.clone(), "@source not '../actual-file.html'"),
public_source_entry_from_pattern(base.clone(), "@source not '../linked-file.html'"),
]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['src/keep.html']"]);
}
#[test]
fn test_symlinked_sources_outside_the_scanned_tree_can_be_ignored() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
(
"actual-dir/ignore.html",
"content-['actual-dir/ignore.html']",
),
("project/keep.html", "content-['project/keep.html']"),
],
);
let _ = symlink(dir.join("actual-dir"), dir.join("project/linked-dir"));
let base = dir.join("project");
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
base.clone(),
"@source '**/*'",
)]);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✓ actual-dir
│ └── ✓ ignore.html
└── ✓ project
├── ✓ keep.html
└── ✓ linked-dir → ../actual-dir
└── ✓ ignore.html
");
assert_eq!(
candidates,
vec![
"content-['actual-dir/ignore.html']",
"content-['project/keep.html']",
]
);
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(base.clone(), "@source '**/*'"),
public_source_entry_from_pattern(base.clone(), "@source not 'linked-dir'"),
]);
let candidates = scanner.scan();
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✗ actual-dir
│ └── ✗ ignore.html
└── ✓ project
├── ✓ keep.html
└── ✗ linked-dir → ../actual-dir
└── ✗ ignore.html
");
assert_eq!(candidates, vec!["content-['project/keep.html']"]);
}
#[test]
fn test_files_behind_symlinks_are_tracked_with_their_canonical_paths() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
(
"packages/repro/source.html",
"content-['packages/repro/source.html']",
),
(
"packages/repro/nested/deep.html",
"content-['packages/repro/nested/deep.html']",
),
(
"packages/other/index.html",
"content-['packages/other/index.html']",
),
],
);
fs::create_dir_all(dir.join("node_modules")).unwrap();
let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro"));
let _ = symlink_file(
dir.join("packages/other/index.html"),
dir.join("linked.html"),
);
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(
dir.clone(),
"@source 'node_modules/repro/source.html'",
),
public_source_entry_from_pattern(
dir.clone(),
"@source 'node_modules/repro/nested/deep.html'",
),
public_source_entry_from_pattern(dir.clone(), "@source 'linked.html'"),
]);
let candidates = scanner.scan();
assert_eq!(
candidates,
vec![
"content-['packages/other/index.html']",
"content-['packages/repro/nested/deep.html']",
"content-['packages/repro/source.html']",
]
);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✓ linked.html → packages/other/index.html
├── ✓ node_modules
│ └── ✓ repro → ../packages/repro
│ ├── ✓ nested
│ │ └── ✓ deep.html
│ └── ✓ source.html
└── ✓ packages
├── ✓ other
│ └── ✓ index.html
└── ✓ repro
├── ✓ nested
│ └── ✓ deep.html
└── ✓ source.html
");
let files = scanned_files(&mut scanner, &dir);
assert_eq!(
files,
vec![
"linked.html",
"node_modules/repro/nested/deep.html",
"node_modules/repro/source.html",
"packages/other/index.html",
"packages/repro/nested/deep.html",
"packages/repro/source.html",
]
);
}
#[test]
fn test_changes_to_the_canonical_path_of_a_symlinked_file_are_detected() {
let dir = tempdir().unwrap().into_path();
create_files_in(&dir, &[("packages/repro/source.html", "content-['v1']")]);
fs::create_dir_all(dir.join("node_modules")).unwrap();
let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro"));
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source 'node_modules/repro/source.html'",
)]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['v1']"]);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✓ node_modules
│ └── ✓ repro → ../packages/repro
│ └── ✓ source.html
└── ✓ packages
└── ✓ repro
└── ✓ source.html
");
create_files_in(&dir, &[("packages/repro/source.html", "content-['v2']")]);
let candidates = scanner.scan_content(vec![ChangedContent::File(
dir.join("packages/repro/source.html"),
"html".into(),
)]);
assert_eq!(candidates, vec!["content-['v2']"]);
}
#[test]
fn test_new_files_behind_symlinks_are_detected_at_their_canonical_path() {
let dir = tempdir().unwrap().into_path();
create_files_in(&dir, &[("packages/repro/a.html", "content-['a']")]);
fs::create_dir_all(dir.join("node_modules")).unwrap();
let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro"));
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source 'node_modules/repro/*.html'",
)]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['a']"]);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✓ node_modules
│ └── ✓ repro → ../packages/repro
│ └── ✓ a.html
└── ✓ packages
└── ✓ repro
└── ✓ a.html
");
create_files_in(&dir, &[("packages/repro/b.html", "content-['b']")]);
let candidates = scanner.scan_content(vec![ChangedContent::File(
dir.join("packages/repro/b.html"),
"html".into(),
)]);
assert_eq!(candidates, vec!["content-['b']"]);
assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @"
.
├── ✓ node_modules
│ └── ✓ repro → ../packages/repro
│ ├── ✓ a.html
│ └── ✓ b.html
└── ✓ packages
└── ✓ repro
├── ✓ a.html
└── ✗ b.html
");
}
#[test]
fn test_resolving_globs_does_not_traverse_gitignored_directories() {
let ScanResult {
files, globs, tree, ..
} = scan_with_globs(
&[
(".gitignore", "/vendor\n"),
("vendor/pkg/canary/index.html", ""),
("src/index.html", ""),
],
vec!["@source '**/*'", "@source './vendor/pkg/canary'"],
);
assert_snapshot!(tree, @"
. (git)
├── ✗ .gitignore
│ /vendor
├── ✓ src
│ └── ✓ index.html
└── ✓ vendor
└── ✓ pkg
└── ✓ canary
└── ✓ index.html
");
assert_eq!(
files,
vec!["src/index.html", "vendor/pkg/canary/index.html"]
);
assert_eq!(globs, vec![
"*",
"src/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}",
"vendor/pkg/canary/*",
]);
}
#[test]
fn test_extract_used_css_variables_from_css() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
(
"src/index.css",
r#"
@theme {
--color-red: #ff0000; /* Not used, so don't extract */
--color-green: #00ff00; /* Not used, so don't extract */
}
.button {
color: var(--color-red); /* Used, so extract */
}
"#,
),
("src/used-at-start.css", "var(--color-used-at-start)"),
("src/defined-at-start.css", "--color-defined-at-start: red;"),
],
);
fs::write(
dir.join("src/invalid.css"),
b".button { color: var(--color-\x80); }",
)
.unwrap();
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source './'",
)]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["--color-red", "--color-used-at-start"]);
}
}