Skip to content

Commit 4bd0b6c

Browse files
RobinMalfaitphilipp-spiess
authored andcommitted
Fix symlink issues when resolving @source directives (#17391)
This PR fixes some issues related to symlinks when using them in the `@source` directive. Fixes: #16765 Fixes: #16038 ## Test plan 1. Added tests to prove this works - Added a recursive symlink test as well to make sure we don't hang 2. Existing tests still pass [ci-all]
1 parent 7b2f825 commit 4bd0b6c

File tree

4 files changed

+158
-24
lines changed

4 files changed

+158
-24
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525

2626
### Fixed
2727

28+
- Fix symlink issues when resolving `@source` directives ([#17391](https://github.com/tailwindlabs/tailwindcss/pull/17391))
29+
30+
## [4.0.17] - 2025-03-26
31+
32+
### Fixed
33+
2834
- Fix an issue causing the CLI to hang when processing Ruby files ([#17383](https://github.com/tailwindlabs/tailwindcss/pull/17383))
2935

3036
## [4.0.16] - 2025-03-25

crates/oxide/src/scanner/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,9 @@ fn create_walker(sources: Sources) -> Option<WalkBuilder> {
523523

524524
let mut builder = WalkBuilder::new(first_root?);
525525

526+
// We have to follow symlinks
527+
builder.follow_links(true);
528+
526529
// Scan hidden files / directories
527530
builder.hidden(false);
528531

crates/oxide/src/scanner/sources.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,9 @@ impl From<PublicSourceEntry> for SourceEntry {
243243
std::path::MAIN_SEPARATOR,
244244
dir,
245245
std::path::MAIN_SEPARATOR
246-
))
246+
)) || value
247+
.base
248+
.ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir,))
247249
});
248250

249251
match (value.negated, auto, inside_ignored_content_dir) {

crates/oxide/tests/scanner.rs

Lines changed: 146 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#[cfg(test)]
22
mod scanner {
3-
use std::path::PathBuf;
3+
use std::path::{Path, PathBuf};
44
use std::process::Command;
55
use std::thread::sleep;
66
use std::time::Duration;
@@ -9,6 +9,16 @@ mod scanner {
99
use tailwindcss_oxide::*;
1010
use tempfile::tempdir;
1111

12+
fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> std::io::Result<()> {
13+
#[cfg(not(windows))]
14+
let result = std::os::unix::fs::symlink(original, link);
15+
16+
#[cfg(windows)]
17+
let result = std::os::windows::fs::symlink_dir(original, link);
18+
19+
result
20+
}
21+
1222
fn public_source_entry_from_pattern(dir: PathBuf, pattern: &str) -> PublicSourceEntry {
1323
let mut parts = pattern.split_whitespace();
1424
let _ = parts.next().unwrap_or_default();
@@ -646,8 +656,8 @@ mod scanner {
646656
assert_eq!(
647657
candidates,
648658
vec![
649-
"content-['project-a/index.html']".to_owned(),
650-
"content-['project-b/index.html']".to_owned(),
659+
"content-['project-a/index.html']",
660+
"content-['project-b/index.html']"
651661
]
652662
);
653663
}
@@ -702,8 +712,8 @@ mod scanner {
702712
assert_eq!(
703713
candidates,
704714
vec![
705-
"content-['project-a/index.html']".to_owned(),
706-
"content-['project-b/index.html']".to_owned(),
715+
"content-['project-a/index.html']",
716+
"content-['project-b/index.html']"
707717
]
708718
);
709719

@@ -726,10 +736,10 @@ mod scanner {
726736
assert_eq!(
727737
candidates,
728738
vec![
729-
"content-['project-a/index.html']".to_owned(),
730-
"content-['project-a/new.html']".to_owned(),
731-
"content-['project-b/index.html']".to_owned(),
732-
"content-['project-b/new.html']".to_owned(),
739+
"content-['project-a/index.html']",
740+
"content-['project-a/new.html']",
741+
"content-['project-b/index.html']",
742+
"content-['project-b/new.html']"
733743
]
734744
);
735745

@@ -758,12 +768,12 @@ mod scanner {
758768
assert_eq!(
759769
candidates,
760770
vec![
761-
"content-['project-a/index.html']".to_owned(),
762-
"content-['project-a/new.html']".to_owned(),
763-
"content-['project-a/sub1/sub2/index.html']".to_owned(),
764-
"content-['project-b/index.html']".to_owned(),
765-
"content-['project-b/new.html']".to_owned(),
766-
"content-['project-b/sub1/sub2/index.html']".to_owned(),
771+
"content-['project-a/index.html']",
772+
"content-['project-a/new.html']",
773+
"content-['project-a/sub1/sub2/index.html']",
774+
"content-['project-b/index.html']",
775+
"content-['project-b/new.html']",
776+
"content-['project-b/sub1/sub2/index.html']"
767777
]
768778
);
769779

@@ -792,14 +802,14 @@ mod scanner {
792802
assert_eq!(
793803
candidates,
794804
vec![
795-
"content-['project-a/index.html']".to_owned(),
796-
"content-['project-a/new.html']".to_owned(),
797-
"content-['project-a/sub1/sub2/index.html']".to_owned(),
798-
"content-['project-a/sub1/sub2/new.html']".to_owned(),
799-
"content-['project-b/index.html']".to_owned(),
800-
"content-['project-b/new.html']".to_owned(),
801-
"content-['project-b/sub1/sub2/index.html']".to_owned(),
802-
"content-['project-b/sub1/sub2/new.html']".to_owned(),
805+
"content-['project-a/index.html']",
806+
"content-['project-a/new.html']",
807+
"content-['project-a/sub1/sub2/index.html']",
808+
"content-['project-a/sub1/sub2/new.html']",
809+
"content-['project-b/index.html']",
810+
"content-['project-b/new.html']",
811+
"content-['project-b/sub1/sub2/index.html']",
812+
"content-['project-b/sub1/sub2/new.html']"
803813
]
804814
);
805815
}
@@ -1611,4 +1621,117 @@ mod scanner {
16111621
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}"]);
16121622
assert_eq!(normalized_sources, vec!["**/*"]);
16131623
}
1624+
1625+
#[test]
1626+
fn test_glob_with_symlinks() {
1627+
let dir = tempdir().unwrap().into_path();
1628+
create_files_in(
1629+
&dir,
1630+
&[
1631+
(".gitignore", "node_modules\ndist"),
1632+
(
1633+
"node_modules/.pnpm/@org+my-ui-library/dist/index.ts",
1634+
"content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']",
1635+
),
1636+
// Make sure the `@org` does exist
1637+
("node_modules/@org/.gitkeep", ""),
1638+
],
1639+
);
1640+
let _ = symlink(
1641+
dir.join("node_modules/.pnpm/@org+my-ui-library"),
1642+
dir.join("node_modules/@org/my-ui-library"),
1643+
);
1644+
1645+
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
1646+
dir.clone(),
1647+
"@source 'node_modules'",
1648+
)]);
1649+
let candidates = scanner.scan();
1650+
1651+
assert_eq!(
1652+
candidates,
1653+
vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"]
1654+
);
1655+
1656+
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
1657+
dir.clone(),
1658+
"@source 'node_modules/@org/my-ui-library'",
1659+
)]);
1660+
let candidates = scanner.scan();
1661+
1662+
assert_eq!(
1663+
candidates,
1664+
vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"]
1665+
);
1666+
1667+
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
1668+
dir.clone(),
1669+
"@source 'node_modules/@org'",
1670+
)]);
1671+
let candidates = scanner.scan();
1672+
1673+
assert_eq!(
1674+
candidates,
1675+
vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"]
1676+
);
1677+
}
1678+
1679+
#[test]
1680+
fn test_globs_with_recursive_symlinks() {
1681+
let dir = tempdir().unwrap().into_path();
1682+
create_files_in(
1683+
&dir,
1684+
&[
1685+
("b/index.html", "content-['b/index.html']"),
1686+
("z/index.html", "content-['z/index.html']"),
1687+
],
1688+
);
1689+
1690+
// Create recursive symlinks
1691+
let _ = symlink(dir.join("a"), dir.join("b"));
1692+
let _ = symlink(dir.join("b/c"), dir.join("c"));
1693+
let _ = symlink(dir.join("b/root"), &dir);
1694+
let _ = symlink(dir.join("c"), dir.join("a"));
1695+
1696+
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
1697+
dir.clone(),
1698+
"@source '.'",
1699+
)]);
1700+
let candidates = scanner.scan();
1701+
1702+
assert_eq!(
1703+
candidates,
1704+
vec!["content-['b/index.html']", "content-['z/index.html']"]
1705+
);
1706+
}
1707+
1708+
#[test]
1709+
fn test_partial_globs_with_symlinks() {
1710+
let dir = tempdir().unwrap().into_path();
1711+
create_files_in(&dir, &[("abcd/xyz.html", "content-['abcd/xyz.html']")]);
1712+
let _ = symlink(dir.join("abcd"), dir.join("efgh"));
1713+
1714+
// No sources should find nothing
1715+
let mut scanner = Scanner::new(vec![]);
1716+
let candidates = scanner.scan();
1717+
assert!(candidates.is_empty());
1718+
1719+
// Full symlinked folder name, should find the file
1720+
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
1721+
dir.clone(),
1722+
"@source 'efgh/*.html'",
1723+
)]);
1724+
let candidates = scanner.scan();
1725+
1726+
assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]);
1727+
1728+
// Partially referencing the symlinked folder with a glob, should find the file
1729+
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
1730+
dir.clone(),
1731+
"@source 'ef*/*.html'",
1732+
)]);
1733+
let candidates = scanner.scan();
1734+
1735+
assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]);
1736+
}
16141737
}

0 commit comments

Comments
 (0)