/
githubmirror
/
tailwindcss
Обзор
Документация
Войти
/
githubmirror
/
tailwindcss
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
crates/oxide/tests/scanner.rs
2 678 строк
92 KB
Robin Malfait
Don't scan ignored folders using `.gitignore` safelist setup (#20397)
07 авг 2026, 18:44
Не верифицирован
07 авг 2026, 18:44
46df7ee
Код
Авторство
О чём код?
#[cfg(test)] mod scanner { 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>, } fn create_files_in(dir: &path::Path, paths: &[(&str, &str)]) { // Create the necessary files for (path, contents) in paths { // Ensure we use the right path separator for the current platform 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() // Normalize paths to use unix style separators .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) } }) // Normalize paths to use unix style separators .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 { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Initialize this directory as a git repository let _ = Command::new("git").arg("init").current_dir(&dir).output(); // Create the necessary files self::create_files_in(&dir, paths_with_content); let base = format!("{}", dir.display()).replace('\\', "/"); // Resolve all content paths for the (temporary) current working directory 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('\\', "/"); // Get all scanned files as strings relative to the base directory let files = scanned_files(&mut scanner, Path::new(&base)); // Get all scanned globs as strings relative to the base directory let globs = scanned_globs(&mut scanner, Path::new(&base)); // Get all normalized sources as strings relative to the base directory 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) } }) // Normalize paths to use unix style separators .map(|file| file.replace('\\', "/").replace(&base_dir, "")) .collect::<Vec<_>>(); normalized_sources.sort(); ScanResult { files, globs, normalized_sources, candidates, } } 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, .. } = scan(&[ ("index.html", ""), ("a.html", ""), ("b.html", ""), ("c.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, .. } = scan(&[ (".gitignore", "b.html"), ("index.html", ""), ("a.html", ""), ("b.html", ""), ("c.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, .. } = scan(&[ ("index.html", ""), ("public/a.html", ""), ("public/b.html", ""), ("public/c.html", ""), ("public/nested/c.html", ""), ("public/deeply/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, .. } = 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_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, .. } = scan(&[ (".gitignore", "public/b.html\na.html"), ("index.html", ""), ("public/a.html", ""), ("public/b.html", ""), ("public/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, .. } = scan(&[ ("index.html", ""), ("src/a.html", ""), ("src/b.html", ""), ("src/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, .. } = scan(&[ ("index.html", ""), ("a.mp4", ""), ("b.png", ""), ("c.lock", ""), ]); assert_eq!(files, vec!["index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); } // https://github.com/tailwindlabs/tailwindcss/issues/17569 #[test] fn it_should_not_ignore_folders_that_end_with_a_binary_extension() { let ScanResult { files, globs, normalized_sources, .. } = scan(&[ // Looks like `.pages` binary extension, but it's a folder ("some.pages/index.html", "content-['some.pages/index.html']"), // Ignore a specific folder. This is to ensure that this still "wins" from the internal // solution of dealing with binary extensions for files only. (".gitignore", "other.pages"), ( "other.pages/index.html", "content-['other.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, .. } = scan(&[ ("index.html", ""), ("a.css", ""), ("b.sass", ""), ("c.less", ""), ]); 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, .. } = scan(&[("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, .. } = scan(&[ ("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, .. } = scan(&[ // Explicitly listed root files ("foo.html", ""), ("bar.html", ""), ("baz.html", ""), // Nested folder A, using glob ("nested-a/foo.html", ""), ("nested-a/bar.html", ""), ("nested-a/baz.html", ""), // Nested folder B, with deeply nested files, using glob ("nested-b/deeply-nested/foo.html", ""), ("nested-b/deeply-nested/bar.html", ""), ("nested-b/deeply-nested/baz.html", ""), // Nested folder C, with ignored sub-folder ("nested-c/foo.html", ""), ("nested-c/bar.html", ""), ("nested-c/baz.html", ""), // Ignored folder ("nested-c/.gitignore", "ignored-folder/"), ("nested-c/ignored-folder/foo.html", ""), ("nested-c/ignored-folder/bar.html", ""), ("nested-c/ignored-folder/baz.html", ""), // Deeply nested, without issues ("nested-c/sibling-folder/foo.html", ""), ("nested-c/sibling-folder/bar.html", ""), ("nested-c/sibling-folder/baz.html", ""), // Nested folder D, with deeply nested ignored folder ("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_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, .. } = scan(&[ // The gitignore file is used to filter out files but not scanned for candidates (".gitignore", &ignores), // A file that should definitely be scanned ("index.html", "font-bold md:flex"), // A file that should definitely not be scanned ("foo.jpg", "xl:font-bold"), // A file that is ignored ("foo.html", "lg:font-bold"), // An Angular file using the class shorthand syntax ( "index.angular.html", "<div [class.underline]=\"bool\"></div>", ), // A svelte file with `class:foo="bar"` syntax ("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_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, .. } = scan_with_globs( &[("foo/bar/baz/foo.html", "content-['foo.html']")], vec!["@source '**/*'", "@source './foo/bar/baz/..'"], ); 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() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Create files 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_eq!(candidates, vec!["content-['GOOD-1']", "content-['GOOD-2']"]); } #[test] fn it_should_scan_files_without_extensions() { // These look like folders, but they are files let ScanResult { candidates, normalized_sources, .. } = scan_with_globs( &[("my-file", "content-['my-file']")], vec!["@source '**/*'", "@source './my-file'"], ); assert_eq!(candidates, vec!["content-['my-file']"]); assert_eq!(normalized_sources, vec!["**/*", "my-file"]); } #[test] fn it_should_scan_folders_with_extensions() { // These look like files, but they are folders let ScanResult { candidates, normalized_sources, .. } = 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_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, .. } = scan_with_globs( &[ // We know that `.styl` extensions are ignored, so they are not covered by auto content // detection. ("foo.styl", "content-['foo.styl']"), ], vec!["@source '**/*'", "@source '*.styl'"], ); assert_eq!(candidates, vec!["content-['foo.styl']"]); assert_eq!(normalized_sources, vec!["**/*", "*.styl"]); } #[test] fn it_should_preserve_paths_for_sources_ending_in_a_deep_glob() { let ScanResult { candidates, files, globs, normalized_sources, } = 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_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, .. } = scan_with_globs( &[ // We know that `.styl` extensions are ignored, so they are not covered by auto content // detection. ("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_eq!( candidates, vec![ "content-['(theme)']", "content-['[...slug]']", "content-['[[...slug]]']", "content-['[slug]']", ], ); assert_eq!(normalized_sources, vec!["**/*", "**/*.styl"]); } #[test] fn it_should_scan_absolute_paths() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Initialize this directory as a git repository let _ = Command::new("git").arg("init").current_dir(&dir).output(); // Create files create_files_in( &dir, &[ ("project-a/index.html", "content-['project-a/index.html']"), ("project-b/index.html", "content-['project-b/index.html']"), ], ); // Get POSIX-style absolute path 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(); // We've done the initial scan and found the files 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, .. } = scan_with_globs( &[ (".gitignore", "foo.styl"), // We know that `.styl` extensions are ignored, so they are not covered by auto content // detection. ("foo.styl", "content-['foo.styl']"), ], // But explicitly including them should still work vec!["@source '**/*'", "@source 'foo.styl'"], ); assert_eq!(candidates, vec!["content-['foo.styl']"]); assert_eq!(normalized_sources, vec!["**/*", "foo.styl"]); } #[test] fn it_should_pick_up_new_files() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Initialize this directory as a git repository let _ = Command::new("git").arg("init").current_dir(&dir).output(); // Create files 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(); // We've done the initial scan and found the files assert_eq!( candidates, vec![ "content-['project-a/index.html']", "content-['project-b/index.html']" ] ); // We have to sleep because it might run too fast (seriously) and the // mtimes of the directories end up being the same as the last time we // checked them sleep(Duration::from_millis(100)); // Create files 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']" ] ); // We have to sleep because it might run too fast (seriously) and the // mtimes of the directories end up being the same as the last time we // checked them sleep(Duration::from_millis(100)); // Create folders 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']" ] ); // We have to sleep because it might run too fast (seriously) and the // mtimes of the directories end up being the same as the last time we // checked them sleep(Duration::from_millis(100)); // Create folders 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']" ] ); } #[test] fn it_should_remove_deleted_files_from_scanned_files() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Initialize this directory as a git repository let _ = Command::new("git").arg("init").current_dir(&dir).output(); // Create files 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"] ); 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"] ); } #[test] fn it_should_remove_deleted_directories_from_scanned_files() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Initialize this directory as a git repository let _ = Command::new("git").arg("init").current_dir(&dir).output(); // Create files 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", ] ); 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"] ); } #[test] fn it_should_remove_deleted_directories_from_scanned_globs() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Initialize this directory as a git repository let _ = Command::new("git").arg("init").current_dir(&dir).output(); // Create files 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(); 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(); 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, } = 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_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", // These files are ignored and thus do not need to be watched: // "src/colors/blue.tsx", // "src/colors/green.tsx", // "src/colors/red.jsx", // "src/index.ts", // "src/utils/date.ts", // "src/utils/file.ts", // "src/utils/string.ts" ] ); 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, } = scan_with_globs( // Typically skipped &[ ("src/index.exe", "content-['src/index.exe']"), ("src/index.bin", "content-['src/index.bin']"), ("out/out.exe", "content-['out/out.exe']"), ], // But explicitly included vec!["@source '**/*'", "@source 'src/**/*.{exe,bin}'"], ); 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![ "*", // Contains `.exe` and `.bin` in the list "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, } = 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_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, .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), ("src/bar.html", "content-['src/bar.html']"), ], vec!["@source './src/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, .. } = 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_eq!( candidates, vec!["content-['src/bar.html']", "content-['src/foo.html']"] ); assert_eq!(files, vec!["src/bar.html", "src/foo.html"]); } // https://github.com/tailwindlabs/tailwindcss/issues/20333 #[test] fn it_should_combine_nested_and_root_restricted_sources() { // The restriction (`*`) added for the root-level file must not prevent walking into the // `nested` folder that another explicit source points into. At the same time, relaxing // that restriction to `/*` must not accidentally open up sibling folders (`ignore-me`) // or files that no explicit source points at. 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, .. } = scan_with_globs( paths_with_content, vec![ "@source './nested/component.html'", "@source './component-sources.classes.txt'", ], ); assert_eq!( candidates, vec![ "content-['classes.txt']", "content-['nested/component.html']" ] ); assert_eq!( files, vec!["component-sources.classes.txt", "nested/component.html"] ); // Same setup, but with the root-level source declared first let ScanResult { candidates, files, .. } = scan_with_globs( paths_with_content, vec![ "@source './component-sources.classes.txt'", "@source './nested/component.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, .. } = scan_with_globs( &[("src/foo.html", "content-['src/foo.html']")], vec!["@source './src/foo.html'", "@source not './src/foo.html'"], ); assert!(candidates.is_empty()); assert!(files.is_empty()); } #[test] fn it_should_handle_sources_with_parent_patterns() { { let ScanResult { candidates, files, .. } = 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_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, .. } = 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'", // Ignore the ignore.html ], ); assert_eq!(candidates, vec!["content-['src/bar/index.html']"]); assert_eq!(files, vec!["src/bar/index.html"]); } { let ScanResult { candidates, files, .. } = 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_eq!(candidates, vec!["content-['src/foo.html']"]); assert_eq!(files, vec!["src/foo.html"]); } { let ScanResult { candidates, files, .. } = 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_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() { // A broad `**/*` source should keep auto-detecting every file, even when a more // specific `@source` points at a single file inside a subdirectory. The restriction // added for the explicit file must not hide its siblings from the broad source. let ScanResult { candidates, files, .. } = scan_with_globs( &[ ("src/components/button.html", "content-['button']"), ("src/components/card.html", "content-['card']"), ], vec!["@source '**/*'", "@source './src/components/button.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() { // Same as above, but the broad source is an auto-detected directory (`@source "src"`) // and the explicit file lives in a nested subdirectory of it. let ScanResult { candidates, files, .. } = scan_with_globs( &[ ("src/components/button.html", "content-['button']"), ("src/components/card.html", "content-['card']"), ], vec!["@source 'src'", "@source './src/components/button.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, .. } = 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_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, } = 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_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, } = scan_with_globs( &[ ("src/logo.jpg", "content-['/src/logo.jpg']"), ("src/logo.png", "content-['/src/logo.png']"), ], vec!["@source './src/logo.{jpg,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, } = 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_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() { // https://github.com/tailwindlabs/tailwindcss/discussions/20382 // // A `.gitignore` that ignores everything (`/*`) and then whitelists specific // directories and files using negated patterns. let ScanResult { files, candidates, .. } = 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", ""), // These are all ignored by the `/*` rule because they are not whitelisted ("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_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() { // Example from the official `.gitignore` documentation: // // ```gitignore // # exclude everything except directory foo/bar // /* // !/foo // /foo/* // !/foo/bar // ``` let ScanResult { files, candidates, .. } = 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']", ), // These are all ignored because they are not inside `foo/bar` ("index.html", "content-['index.html']"), ("foo/index.html", "content-['foo/index.html']"), ("foo/baz/index.html", "content-['foo/baz/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() { // A directory referenced via `@source` behaves like an auto source detection // root. The `.gitignore` file _inside_ that directory still applies to its // contents. Only when the directory itself is ignored (by an ancestor // `.gitignore`, see the tests above) do we bypass the ignore rules. let ScanResult { files, candidates, .. } = 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_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, .. } = 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_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() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Create files create_files_in( &dir, &[ // This file should always be picked up ("home/project/apps/web/index.html", "content-['index.html']"), // Set up various ignore rules ("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"), // Some of these should be ignored depending on which dir is the repo root ( "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']", ), // Auto content detection outside of `web/` ( "home/project/apps/admin/index.html", "content-['home/project/apps/admin/index.html']", ), // Manual sources outside of `web/` ( "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 candidates = Scanner::new(sources.clone()).scan(); // All ignore files are applied because there's no git repo assert_eq!( candidates, vec![ "content-['home/project/apps/admin/index.html']", "content-['home/project/apps/dashboard/index.html']", "content-['index.html']" ] ); // Initialize `home` as a git repository and scan again // The results should be the same as before _ = Command::new("git") .arg("init") .current_dir(dir.join("home")) .output(); let candidates = Scanner::new(sources.clone()).scan(); assert_eq!( candidates, vec![ "content-['home/project/apps/admin/index.html']", "content-['home/project/apps/dashboard/index.html']", "content-['index.html']" ] ); // Drop the .git folder fs::remove_dir_all(dir.join("home/.git")).unwrap(); // Initialize `home/project` as a git repository and scan again _ = Command::new("git") .arg("init") .current_dir(dir.join("home/project")) .output(); let candidates = Scanner::new(sources.clone()).scan(); 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']" ] ); // Drop the .git folder fs::remove_dir_all(dir.join("home/project/.git")).unwrap(); // Initialize `home/project/apps` as a git repository and scan again _ = Command::new("git") .arg("init") .current_dir(dir.join("home/project/apps")) .output(); let candidates = Scanner::new(sources.clone()).scan(); 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']" ] ); // Drop the .git folder fs::remove_dir_all(dir.join("home/project/apps/.git")).unwrap(); // Initialize `home/project/apps` as a git repository and scan again _ = Command::new("git") .arg("init") .current_dir(dir.join("home/project/apps/web")) .output(); let candidates = Scanner::new(sources.clone()).scan(); 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() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Create files 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 candidates = Scanner::new(sources.clone()).scan(); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } #[test] fn test_works_with_filenames_containing_glob_characters() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Create files 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 candidates = Scanner::new(sources.clone()).scan(); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } #[test] fn test_ignore_files_can_be_included_with_custom_source_rule() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Create files 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']"]); // Create new files that should definitely be ignored create_files_in( &dir, &[ // Create new file that matches the `@source '…'` glob ("src/new-file.html", "content-['src/new-file.html']"), // Create new file that is ignored based on file extension ( "src/ignore-by-extension.bin", "content-['src/ignore-by-extension.bin']", ), // Create a file that is ignored based on the `.gitignore` file (".gitignore", "src/ignored-by-gitignore.html"), ( "src/ignored-by-gitignore.html", "content-['src/ignored-by-gitignore.html']", ), // Create a file that is ignored by the `@source not '…'` ( "src/ignored-by-source-not.html", "content-['src/ignored-by-source-not.html']", ), ], ); let candidates = scanner.scan(); assert_eq!( candidates, vec![ // Ignored by git ignore BUT included by `@source "**/*.html"` "content-['src/ignored-by-gitignore.html']", "content-['src/keep-me.html']", "content-['src/new-file.html']" ] ); } #[test] fn test_allow_default_ignored_files() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Create files 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()); // Explicitly allow `.styl` files 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']"]); } #[test] fn test_allow_default_ignored_files_via_gitignore() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Create files 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()); 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']"]); } #[test] fn test_allow_explicit_node_modules_paths() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Create files create_files_in( &dir, &[ // Current project ("src/index.html", "content-['src/index.html']"), // Ignore file (".gitignore", "node_modules"), // Library ignored by default ( "node_modules/my-ui-lib/index.html", "content-['node_modules/my-ui-lib/index.html']", ), ], ); // Default auto source detection 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']"]); // Explicitly listing all `*.html` files, should not include `node_modules` because it's // ignored 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']"]); // Explicitly listing all `*.html` files // Explicitly list the `node_modules/my-ui-lib` // 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']" ] ); } // https://github.com/tailwindlabs/tailwindcss/issues/19844 #[test] fn test_allow_explicit_sources_ignored_by_allow_list_gitignore() { let ScanResult { candidates, .. } = 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_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, } = 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_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, } = 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_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() { // Create a temporary working directory let dir = tempdir().unwrap().into_path(); // Create files create_files_in( &dir, &[ // Ignore all `.jsx` files, and all `generated` folders (".gitignore", "*.jsx\ngenerated/"), // .tsx files are allowed ( "src/components/button.tsx", "content-['src/components/button.tsx']", ), // .jsx files are not allowed ( "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']"]); // Create 2 new files, one "good" and one "bad" file, and manually scan them. This should // only return the "good" file because the "bad" one is ignored by a `.gitignore` file. 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 a generated file in a nested folder that is ignored by a `.gitignore` file higher // up the tree. 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, } = 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_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']", ), // Make sure the `@org` does exist ("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_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_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_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']"), ], ); // Create recursive symlinks let _ = symlink(dir.join("a"), dir.join("b")); let _ = symlink(dir.join("b/c"), dir.join("c")); let _ = symlink(dir.join("b/root"), &dir); let _ = symlink(dir.join("c"), dir.join("a")); let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( dir.clone(), "@source '.'", )]); let candidates = scanner.scan(); 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")); // No sources should find nothing let mut scanner = Scanner::new(vec![]); let candidates = scanner.scan(); assert!(candidates.is_empty()); // Full symlinked folder name, should find the file let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( dir.clone(), "@source 'efgh/*.html'", )]); let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]); // Partially referencing the symlinked folder with a glob, should find the file 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_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_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_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']", ), ], ); // Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package fs::create_dir_all(dir.join("node_modules")).unwrap(); let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro")); // A directly symlinked file 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'", ), // The symlink sits multiple levels up from the file 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']", ] ); // Both the symlinked paths and the canonical paths should be tracked, such that file // watchers watching the returned files also watch the real files on disk. 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']")]); // Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package 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']"]); // Update the real file on disk. This is the path file watchers will report changes for. 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']")]); // Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package 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']"]); // Create a new file in the real directory. File watchers watching the real directory // will report the new file with its canonical path. 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']"]); } #[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)"), // Here to verify that we don't crash when trying to find `var(` in front of the // variable. ("src/defined-at-start.css", "--color-defined-at-start: red;"), ], ); 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"]); } }