diff --git a/CHANGELOG.md b/CHANGELOG.md index be2c8117a14f..4071daaab0f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fix symlink issues when resolving `@source` directives ([#17391](https://github.com/tailwindlabs/tailwindcss/pull/17391)) + +## [4.0.17] - 2025-03-26 + +### Fixed + - Fix an issue causing the CLI to hang when processing Ruby files ([#17383](https://github.com/tailwindlabs/tailwindcss/pull/17383)) ## [4.0.16] - 2025-03-25 @@ -3582,7 +3588,8 @@ No release notes - Everything! -[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.0.16...HEAD +[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.0.17...HEAD +[4.0.17]: https://github.com/tailwindlabs/tailwindcss/compare/v4.0.16...v4.0.17 [4.0.16]: https://github.com/tailwindlabs/tailwindcss/compare/v4.0.15...v4.0.16 [4.0.15]: https://github.com/tailwindlabs/tailwindcss/compare/v4.0.14...v4.0.15 [4.0.14]: https://github.com/tailwindlabs/tailwindcss/compare/v4.0.13...v4.0.14 diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 445dfc98372b..416191c27ce0 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -523,6 +523,9 @@ fn create_walker(sources: Sources) -> Option { let mut builder = WalkBuilder::new(first_root?); + // We have to follow symlinks + builder.follow_links(true); + // Scan hidden files / directories builder.hidden(false); diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index 450fcb7c2873..ea6c115a4fc3 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -243,7 +243,9 @@ impl From for SourceEntry { std::path::MAIN_SEPARATOR, dir, std::path::MAIN_SEPARATOR - )) + )) || value + .base + .ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir,)) }); match (value.negated, auto, inside_ignored_content_dir) { diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 03daee0ebafb..c5ece5d75090 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod scanner { - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use std::process::Command; use std::thread::sleep; use std::time::Duration; @@ -9,6 +9,16 @@ mod scanner { use tailwindcss_oxide::*; use tempfile::tempdir; + fn symlink, Q: AsRef>(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 public_source_entry_from_pattern(dir: PathBuf, pattern: &str) -> PublicSourceEntry { let mut parts = pattern.split_whitespace(); let _ = parts.next().unwrap_or_default(); @@ -646,8 +656,8 @@ mod scanner { assert_eq!( candidates, vec![ - "content-['project-a/index.html']".to_owned(), - "content-['project-b/index.html']".to_owned(), + "content-['project-a/index.html']", + "content-['project-b/index.html']" ] ); } @@ -702,8 +712,8 @@ mod scanner { assert_eq!( candidates, vec![ - "content-['project-a/index.html']".to_owned(), - "content-['project-b/index.html']".to_owned(), + "content-['project-a/index.html']", + "content-['project-b/index.html']" ] ); @@ -726,10 +736,10 @@ mod scanner { assert_eq!( candidates, vec![ - "content-['project-a/index.html']".to_owned(), - "content-['project-a/new.html']".to_owned(), - "content-['project-b/index.html']".to_owned(), - "content-['project-b/new.html']".to_owned(), + "content-['project-a/index.html']", + "content-['project-a/new.html']", + "content-['project-b/index.html']", + "content-['project-b/new.html']" ] ); @@ -758,12 +768,12 @@ mod scanner { assert_eq!( candidates, vec![ - "content-['project-a/index.html']".to_owned(), - "content-['project-a/new.html']".to_owned(), - "content-['project-a/sub1/sub2/index.html']".to_owned(), - "content-['project-b/index.html']".to_owned(), - "content-['project-b/new.html']".to_owned(), - "content-['project-b/sub1/sub2/index.html']".to_owned(), + "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']" ] ); @@ -792,14 +802,14 @@ mod scanner { assert_eq!( candidates, vec![ - "content-['project-a/index.html']".to_owned(), - "content-['project-a/new.html']".to_owned(), - "content-['project-a/sub1/sub2/index.html']".to_owned(), - "content-['project-a/sub1/sub2/new.html']".to_owned(), - "content-['project-b/index.html']".to_owned(), - "content-['project-b/new.html']".to_owned(), - "content-['project-b/sub1/sub2/index.html']".to_owned(), - "content-['project-b/sub1/sub2/new.html']".to_owned(), + "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']" ] ); } @@ -1611,4 +1621,117 @@ mod scanner { 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']"]); + } }