tailwindcss
tailwindcss copied to clipboard
@source not: Cannot ignore symlinked directory because symlink is optimized away
What version of Tailwind CSS are you using?
tailwindcss v4.1.6
What build tool (or framework if it abstracts the build tool) are you using?
Cli
What version of Node.js are you using?
v22.15.0
What browser are you using?
N/A
What operating system are you using?
Ubuntu
Reproduction URL
N/A
Describe your issue
When a directory is symlinked, e.g. a directory_a is symlinked from symlink:
directory_a/a
directory_a/b
symlink # symlink to ./directory_a
tailwind.css
... and I have a @source not "./symlink"; ...
@source not "./symlink";
... it does not ignore it:
2025-05-12T12:35:15.744825Z INFO tailwindcss_oxide::scanner: Provided sources:
2025-05-12T12:35:15.744976Z INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/abc/tmp/tailwind", pattern: "**/*", negated: false }
2025-05-12T12:35:15.745016Z INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/abc/tmp/tailwind", pattern: "./symlink", negated: true }
2025-05-12T12:35:15.745132Z INFO tailwindcss_oxide::scanner: Optimized sources:
2025-05-12T12:35:15.745139Z INFO tailwindcss_oxide::scanner: Source: Auto { base: "/Users/abc/tmp/tailwind" }
2025-05-12T12:35:15.745159Z INFO tailwindcss_oxide::scanner: Source: Ignored { base: "/Users/abc/tmp/tailwind/directory_a", pattern: "**/*" }
2025-05-12T12:35:15.746487Z INFO get_normalized_sources: tailwindcss_oxide::scanner: enter
2025-05-12T12:35:15.746498Z INFO get_normalized_sources: tailwindcss_oxide::scanner: exit
2025-05-12T12:35:15.753492Z INFO scan_sources: tailwindcss_oxide::scanner: enter
2025-05-12T12:35:15.753775Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink"
2025-05-12T12:35:15.753833Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink/a"
2025-05-12T12:35:15.753884Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink/b"
2025-05-12T12:35:15.753939Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/tailwind.css"
2025-05-12T12:35:15.753989Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/output.css"
2025-05-12T12:35:15.754033Z INFO scan_sources: tailwindcss_oxide::scanner: exit
The pattern before optimisation is correct:
2025-05-12T12:35:15.745016Z INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/abc/tmp/tailwind", pattern: "./symlink", negated: true }
But after optimisation it ignores the directory the symlink points to (directory_a in this case):
2025-05-12T12:35:15.745159Z INFO tailwindcss_oxide::scanner: Source: Ignored { base: "/Users/abc/tmp/tailwind/directory_a", pattern: "**/*" }
... and goes on scanning the symlink even though it should be ignored:
2025-05-12T12:35:15.753833Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink/a"
2025-05-12T12:35:15.753884Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink/b"
It should not scan a_symlink_to/* because it was explicitly ignored by a @source not statement.
Because otherwise what is at directory_a cannot be ignored at all:
@source not "./symlink";
@source not "./directory_a";
leads to:
2025-05-12T12:38:12.030696Z INFO tailwindcss_oxide::scanner: Provided sources:
2025-05-12T12:38:12.030840Z INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/abc/tmp/tailwind", pattern: "**/*", negated: false }
2025-05-12T12:38:12.030876Z INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/abc/tmp/tailwind", pattern: "./symlink", negated: true }
2025-05-12T12:38:12.030882Z INFO tailwindcss_oxide::scanner: Source: PublicSourceEntry { base: "/Users/abc/tmp/tailwind", pattern: "./directory_a", negated: true }
2025-05-12T12:38:12.031015Z INFO tailwindcss_oxide::scanner: Optimized sources:
2025-05-12T12:38:12.031023Z INFO tailwindcss_oxide::scanner: Source: Auto { base: "/Users/abc/tmp/tailwind" }
2025-05-12T12:38:12.031042Z INFO tailwindcss_oxide::scanner: Source: Ignored { base: "/Users/abc/tmp/tailwind/directory_a", pattern: "**/*" }
2025-05-12T12:38:12.031046Z INFO tailwindcss_oxide::scanner: Source: Ignored { base: "/Users/abc/tmp/tailwind/directory_a", pattern: "**/*" }
2025-05-12T12:38:12.032357Z INFO get_normalized_sources: tailwindcss_oxide::scanner: enter
2025-05-12T12:38:12.032368Z INFO get_normalized_sources: tailwindcss_oxide::scanner: exit
2025-05-12T12:38:12.039651Z INFO scan_sources: tailwindcss_oxide::scanner: enter
2025-05-12T12:38:12.039973Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink"
2025-05-12T12:38:12.040023Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink/a"
2025-05-12T12:38:12.040084Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink/b"
2025-05-12T12:38:12.040115Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/tailwind.css"
2025-05-12T12:38:12.040152Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/output.css"
It has the same directory twice after optimisation:
2025-05-12T12:38:12.031042Z INFO tailwindcss_oxide::scanner: Source: Ignored { base: "/Users/abc/tmp/tailwind/directory_a", pattern: "**/*" }
2025-05-12T12:38:12.031046Z INFO tailwindcss_oxide::scanner: Source: Ignored { base: "/Users/abc/tmp/tailwind/directory_a", pattern: "**/*" }
... and still scans ./symlink/*:
2025-05-12T12:38:12.040023Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink/a"
2025-05-12T12:38:12.040084Z INFO scan_sources: tailwindcss_oxide::scanner: Discovering "/Users/abc/tmp/tailwind/symlink/b"
refs https://github.com/tailwindlabs/tailwindcss/issues/15750
HI, The tailwindcss_oxide scanner's optimization logic for @source not directives involving symlinks might be flawed. It appears to resolve the symlink to its canonical target path and mark that target for exclusion, but it fails to prevent the scanner from subsequently traversing the original symlink path during the file discovery phase. The exclusion seems to apply to the target's canonical path, not necessarily to all paths (including symlinks) that lead to it.
(Conceptual within tailwindcss_oxide): // src/scanner/mod.rs // src/scanner/source.rs (defining PublicSourceEntry, OptimizedSource, etc.) // src/scanner/discover.rs (file system traversal logic)
- Parsing @source directives (likely in source.rs or a parser module): // In some parsing logic: // Input: "@source not "./symlink";" // Output (as seen in logs): // PublicSourceEntry { // base: PathBuf::from("/Users/abc/tmp/tailwind"), // Assuming this is the CSS file's dir or project root // pattern: String::from("./symlink"), // negated: true, // }
Optimizing" Sources (likely in scanner/mod.rs or source.rs): // In a function that processes PublicSourceEntry list into "Optimized Sources"
// Represents an "optimized" rule for scanning #[derive(Debug)] enum OptimizedSourceRule { // Include files matching this glob pattern from this base Include { base: PathBuf, glob: glob::Pattern }, // Ignore any path that resolves to this canonical path IgnoreCanonicalPath { path: PathBuf }, // Crucially, we might also need: // Do not traverse this specific path (even if it's a symlink) DoNotTraversePath { path: PathBuf }, }
fn optimize_sources( raw_sources: Vec<PublicSourceEntry>, project_base: &Path, ) -> Vec<OptimizedSourceRule> { let mut optimized_rules = Vec::new();
for source_entry in raw_sources {
let mut absolute_pattern_path = project_base.join(&source_entry.pattern);
if !absolute_pattern_path.is_absolute() {
// Ensure it's absolute for consistent handling
absolute_pattern_path = project_base.join(&source_entry.base).join(&source_entry.pattern);
}
// Normalize to clean up ".." etc. This might return an error if path doesn't exist
// or might just clean it lexically. For symlinks, canonicalize is key.
let normalized_path = dunce::canonicalize(&absolute_pattern_path) // `dunce` handles Windows symlinks better
.unwrap_or_else(|_| absolute_pattern_path.clone());
if source_entry.negated {
// --- CURRENT PROBLEMATIC LOGIC (HYPOTHETICAL) ---
// It seems to be doing something like:
// 1. Resolve `absolute_pattern_path` (e.g., "/Users/abc/tmp/tailwind/symlink")
// to its target's canonical path (e.g., "/Users/abc/tmp/tailwind/directory_a").
// 2. Add this canonical target path to an ignore list.
// (Log: `Ignored { base: "/Users/abc/tmp/tailwind/directory_a", pattern: "**/*" }`)
// --- REQUIRED FIX (CONCEPTUAL) ---
// The optimization needs to ensure that the ORIGINAL symlink path is marked as "do not traverse".
// And, optionally, also its canonical target.
// 1. Mark the original path itself as non-traversable.
// This `absolute_pattern_path` is what the user specified (e.g. /Users/abc/tmp/tailwind/symlink)
optimized_rules.push(OptimizedSourceRule::DoNotTraversePath {
path: absolute_pattern_path.clone(), // Path to the symlink itself
});
// 2. (Optional but good) Also ignore the canonical target if it resolves.
// This handles cases where the target might be accessed via another route.
if absolute_pattern_path.is_symlink() || normalized_path != absolute_pattern_path {
// `normalized_path` here would be the canonical path of the target
optimized_rules.push(OptimizedSourceRule::IgnoreCanonicalPath {
path: normalized_path, // Path to the target directory_a
});
} else if absolute_pattern_path.is_dir() {
// If it's a regular directory and not a symlink, ignore its canonical form.
optimized_rules.push(OptimizedSourceRule::IgnoreCanonicalPath {
path: normalized_path,
});
}
} else {
// Handle non-negated (include) sources
// optimized_rules.push(OptimizedSourceRule::Include { base: ..., glob: ... });
}
}
// The logs show "Optimized sources: Source: Auto { base: ... }" and "Source: Ignored { base: ...}"
// This suggests the actual OptimizedSourceRule might be simpler, but the logic flow is similar.
// The key is that the `Ignored` rule for `directory_a` doesn't stop traversal of `symlink`.
optimized_rules
}
// In a recursive directory traversal function:
fn discover_files_in_directory(
current_dir_path: &Path,
optimized_rules: &[OptimizedSourceRule],
// ... other params ...
) {
// --- REQUIRED CHECK ---
// Before reading current_dir_path, check if this path itself should be skipped.
for rule in optimized_rules {
if let OptimizedSourceRule::DoNotTraversePath { path: ignored_path } = rule {
// Must compare current_dir_path (e.g. /Users/.../symlink)
// with ignored_path (e.g. /Users/.../symlink from the @source not rule).
// This comparison needs to be careful about path normalization if paths can be non-canonical.
// For simplicity, assume paths are reasonably comparable or canonicalized where needed.
if current_dir_path == ignored_path {
// Log: "Skipping traversal of explicitly ignored path: {current_dir_path:?}"
return; // Do not enter or process this directory/symlink path
}
}
}
// The logs show "Discovering /Users/abc/tmp/tailwind/symlink",
// which means the above check is either missing or not effective for the symlink path itself.
match std::fs::read_dir(current_dir_path) {
Ok(entries) => {
for entry_result in entries {
if let Ok(entry) = entry_result {
let path = entry.path();
// Further checks:
// - Is `path.canonicalize()` in `IgnoreCanonicalPath` list?
// - Does `path` match an inclusion glob?
// - etc.
if path.is_dir() {
// If it's a symlink to a dir, `is_dir()` might be false depending on how `entry.path()` behaves
// or how metadata is read. Careful handling of symlinks is needed here.
// std::fs::symlink_metadata(&path).is_symlink()
// std::fs::metadata(&path).is_dir() (follows symlinks)
// Recursive call:
discover_files_in_directory(&path, optimized_rules, /* ... */);
} else if path.is_file() {
// Process file
}
}
}
}
Err(e) => {
// Log error: "Failed to read directory {current_dir_path:?}: {e}"
// This can happen if a symlink is broken, or permissions deny access.
}
}
}
Good luck! SUMAN SUHAG