Skip to content

Commit d93ed29

Browse files
authored
Escape template filenames in glob patterns (#16407)
## Summary Fixes #9381. This PR fixes errors like ``` Cause: error parsing glob '/Users/me/project/{{cookiecutter.project_dirname}}/__pycache__': nested alternate groups are not allowed ``` caused by glob special characters in filenames like `{{cookiecutter.project_dirname}}`. When the user is matching that directory exactly, they can use the workaround given by #7959 (comment), but that doesn't work for a nested config file with relative paths. For example, the directory tree in the reproduction repo linked [here](#9381 (comment)): ``` . ├── README.md ├── hello.py ├── pyproject.toml ├── uv.lock └── {{cookiecutter.repo_name}} ├── main.py ├── pyproject.toml └── tests └── maintest.py ``` where the inner `pyproject.toml` contains a relative glob: ```toml [tool.ruff.lint.per-file-ignores] "tests/*" = ["F811"] ``` ## Test Plan A new CLI test in both the linter and formatter. The formatter test may not be necessary because I didn't have to modify any additional code to pass it, but the original report mentioned both `check` and `format`, so I wanted to be sure both were fixed.
1 parent 4d92e20 commit d93ed29

File tree

8 files changed

+195
-70
lines changed

8 files changed

+195
-70
lines changed

crates/ruff/tests/format.rs

+34
Original file line numberDiff line numberDiff line change
@@ -2133,3 +2133,37 @@ with open("a_really_long_foo") as foo, open("a_really_long_bar") as bar, open("a
21332133
----- stderr -----
21342134
"#);
21352135
}
2136+
2137+
/// Regression test for <https://github.com/astral-sh/ruff/issues/9381> with very helpful
2138+
/// reproduction repo here: <https://github.com/lucasfijen/example_ruff_glob_bug>
2139+
#[test]
2140+
fn cookiecutter_globbing() -> Result<()> {
2141+
// This is a simplified directory structure from the repo linked above. The essence of the
2142+
// problem is this `{{cookiecutter.repo_name}}` directory containing a config file with a glob.
2143+
// The absolute path of the glob contains the glob metacharacters `{{` and `}}` even though the
2144+
// user's glob does not.
2145+
let tempdir = TempDir::new()?;
2146+
let cookiecutter = tempdir.path().join("{{cookiecutter.repo_name}}");
2147+
let cookiecutter_toml = cookiecutter.join("pyproject.toml");
2148+
let tests = cookiecutter.join("tests");
2149+
fs::create_dir_all(&tests)?;
2150+
fs::write(
2151+
cookiecutter_toml,
2152+
r#"tool.ruff.lint.per-file-ignores = { "tests/*" = ["F811"] }"#,
2153+
)?;
2154+
let maintest = tests.join("maintest.py");
2155+
fs::write(maintest, "import foo\nimport bar\nimport foo\n")?;
2156+
2157+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
2158+
.args(["format", "--no-cache", "--diff"])
2159+
.current_dir(tempdir.path()), @r"
2160+
success: true
2161+
exit_code: 0
2162+
----- stdout -----
2163+
2164+
----- stderr -----
2165+
1 file already formatted
2166+
");
2167+
2168+
Ok(())
2169+
}

crates/ruff/tests/lint.rs

+82
Original file line numberDiff line numberDiff line change
@@ -2782,3 +2782,85 @@ fn cache_syntax_errors() -> Result<()> {
27822782

27832783
Ok(())
27842784
}
2785+
2786+
/// Regression test for <https://github.com/astral-sh/ruff/issues/9381> with very helpful
2787+
/// reproduction repo here: <https://github.com/lucasfijen/example_ruff_glob_bug>
2788+
#[test]
2789+
fn cookiecutter_globbing() -> Result<()> {
2790+
// This is a simplified directory structure from the repo linked above. The essence of the
2791+
// problem is this `{{cookiecutter.repo_name}}` directory containing a config file with a glob.
2792+
// The absolute path of the glob contains the glob metacharacters `{{` and `}}` even though the
2793+
// user's glob does not.
2794+
let tempdir = TempDir::new()?;
2795+
let cookiecutter = tempdir.path().join("{{cookiecutter.repo_name}}");
2796+
let cookiecutter_toml = cookiecutter.join("pyproject.toml");
2797+
let tests = cookiecutter.join("tests");
2798+
fs::create_dir_all(&tests)?;
2799+
fs::write(
2800+
&cookiecutter_toml,
2801+
r#"tool.ruff.lint.per-file-ignores = { "tests/*" = ["F811"] }"#,
2802+
)?;
2803+
// F811 example from the docs to ensure the glob still works
2804+
let maintest = tests.join("maintest.py");
2805+
fs::write(maintest, "import foo\nimport bar\nimport foo")?;
2806+
2807+
insta::with_settings!({filters => vec![(r"\\", "/")]}, {
2808+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
2809+
.args(STDIN_BASE_OPTIONS)
2810+
.arg("--select=F811")
2811+
.current_dir(tempdir.path()), @r"
2812+
success: true
2813+
exit_code: 0
2814+
----- stdout -----
2815+
All checks passed!
2816+
2817+
----- stderr -----
2818+
");
2819+
});
2820+
2821+
// after removing the config file with the ignore, F811 applies, so the glob worked above
2822+
fs::remove_file(cookiecutter_toml)?;
2823+
2824+
insta::with_settings!({filters => vec![(r"\\", "/")]}, {
2825+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
2826+
.args(STDIN_BASE_OPTIONS)
2827+
.arg("--select=F811")
2828+
.current_dir(tempdir.path()), @r"
2829+
success: false
2830+
exit_code: 1
2831+
----- stdout -----
2832+
{{cookiecutter.repo_name}}/tests/maintest.py:3:8: F811 [*] Redefinition of unused `foo` from line 1
2833+
Found 1 error.
2834+
[*] 1 fixable with the `--fix` option.
2835+
2836+
----- stderr -----
2837+
");
2838+
});
2839+
2840+
Ok(())
2841+
}
2842+
2843+
/// Like the test above but exercises the non-absolute path case in `PerFile::new`
2844+
#[test]
2845+
fn cookiecutter_globbing_no_project_root() -> Result<()> {
2846+
let tempdir = TempDir::new()?;
2847+
let tempdir = tempdir.path().join("{{cookiecutter.repo_name}}");
2848+
fs::create_dir(&tempdir)?;
2849+
2850+
insta::with_settings!({filters => vec![(r"\\", "/")]}, {
2851+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
2852+
.current_dir(&tempdir)
2853+
.args(STDIN_BASE_OPTIONS)
2854+
.args(["--extend-per-file-ignores", "generated.py:Q"]), @r"
2855+
success: true
2856+
exit_code: 0
2857+
----- stdout -----
2858+
All checks passed!
2859+
2860+
----- stderr -----
2861+
warning: No Python files found under the given path(s)
2862+
");
2863+
});
2864+
2865+
Ok(())
2866+
}

crates/ruff_linter/src/fs.rs

+14-5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ use path_absolutize::Absolutize;
55
use crate::registry::RuleSet;
66
use crate::settings::types::CompiledPerFileIgnoreList;
77

8+
/// Return the current working directory.
9+
///
10+
/// On WASM this just returns `.`. Otherwise, defer to [`path_absolutize::path_dedot::CWD`].
11+
pub fn get_cwd() -> &'static Path {
12+
#[cfg(target_arch = "wasm32")]
13+
{
14+
static CWD: std::sync::LazyLock<PathBuf> = std::sync::LazyLock::new(|| PathBuf::from("."));
15+
&CWD
16+
}
17+
#[cfg(not(target_arch = "wasm32"))]
18+
path_absolutize::path_dedot::CWD.as_path()
19+
}
20+
821
/// Create a set with codes matching the pattern/code pairs.
922
pub(crate) fn ignores_from_path(path: &Path, ignore_list: &CompiledPerFileIgnoreList) -> RuleSet {
1023
ignore_list
@@ -36,11 +49,7 @@ pub fn normalize_path_to<P: AsRef<Path>, R: AsRef<Path>>(path: P, project_root:
3649
pub fn relativize_path<P: AsRef<Path>>(path: P) -> String {
3750
let path = path.as_ref();
3851

39-
#[cfg(target_arch = "wasm32")]
40-
let cwd = Path::new(".");
41-
#[cfg(not(target_arch = "wasm32"))]
42-
let cwd = path_absolutize::path_dedot::CWD.as_path();
43-
52+
let cwd = get_cwd();
4453
if let Ok(path) = path.strip_prefix(cwd) {
4554
return format!("{}", path.display());
4655
}

crates/ruff_linter/src/settings/mod.rs

+3-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
//! command-line options. Structure is optimized for internal usage, as opposed
33
//! to external visibility or parsing.
44
5-
use path_absolutize::path_dedot;
65
use regex::Regex;
76
use rustc_hash::FxHashSet;
87
use std::fmt::{Display, Formatter};
@@ -24,7 +23,7 @@ use crate::rules::{
2423
pep8_naming, pycodestyle, pydoclint, pydocstyle, pyflakes, pylint, pyupgrade, ruff,
2524
};
2625
use crate::settings::types::{CompiledPerFileIgnoreList, ExtensionMapping, FilePatternSet};
27-
use crate::{codes, RuleSelector};
26+
use crate::{codes, fs, RuleSelector};
2827

2928
use super::line_width::IndentWidth;
3029

@@ -414,7 +413,7 @@ impl LinterSettings {
414413
per_file_ignores: CompiledPerFileIgnoreList::default(),
415414
fix_safety: FixSafetyTable::default(),
416415

417-
src: vec![path_dedot::CWD.clone(), path_dedot::CWD.join("src")],
416+
src: vec![fs::get_cwd().to_path_buf(), fs::get_cwd().join("src")],
418417
// Needs duplicating
419418
tab_size: IndentWidth::default(),
420419
line_length: LineLength::default(),
@@ -474,6 +473,6 @@ impl LinterSettings {
474473

475474
impl Default for LinterSettings {
476475
fn default() -> Self {
477-
Self::new(path_dedot::CWD.as_path())
476+
Self::new(fs::get_cwd())
478477
}
479478
}

crates/ruff_linter/src/settings/types.rs

+40-11
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,41 @@ impl UnsafeFixes {
159159
}
160160
}
161161

162+
/// Represents a path to be passed to [`Glob::new`].
163+
#[derive(Debug, Clone, CacheKey, PartialEq, PartialOrd, Eq, Ord)]
164+
pub struct GlobPath {
165+
path: PathBuf,
166+
}
167+
168+
impl GlobPath {
169+
/// Constructs a [`GlobPath`] by escaping any glob metacharacters in `root` and normalizing
170+
/// `path` to the escaped `root`.
171+
///
172+
/// See [`fs::normalize_path_to`] for details of the normalization.
173+
pub fn normalize(path: impl AsRef<Path>, root: impl AsRef<Path>) -> Self {
174+
let root = root.as_ref().to_string_lossy();
175+
let escaped = globset::escape(&root);
176+
let absolute = fs::normalize_path_to(path, escaped);
177+
Self { path: absolute }
178+
}
179+
180+
pub fn into_inner(self) -> PathBuf {
181+
self.path
182+
}
183+
}
184+
185+
impl Deref for GlobPath {
186+
type Target = PathBuf;
187+
188+
fn deref(&self) -> &Self::Target {
189+
&self.path
190+
}
191+
}
192+
162193
#[derive(Debug, Clone, CacheKey, PartialEq, PartialOrd, Eq, Ord)]
163194
pub enum FilePattern {
164195
Builtin(&'static str),
165-
User(String, PathBuf),
196+
User(String, GlobPath),
166197
}
167198

168199
impl FilePattern {
@@ -202,9 +233,10 @@ impl FromStr for FilePattern {
202233
type Err = anyhow::Error;
203234

204235
fn from_str(s: &str) -> Result<Self, Self::Err> {
205-
let pattern = s.to_string();
206-
let absolute = fs::normalize_path(&pattern);
207-
Ok(Self::User(pattern, absolute))
236+
Ok(Self::User(
237+
s.to_string(),
238+
GlobPath::normalize(s, fs::get_cwd()),
239+
))
208240
}
209241
}
210242

@@ -281,7 +313,7 @@ pub struct PerFile<T> {
281313
/// The glob pattern used to construct the [`PerFile`].
282314
basename: String,
283315
/// The same pattern as `basename` but normalized to the project root directory.
284-
absolute: PathBuf,
316+
absolute: GlobPath,
285317
/// Whether the glob pattern should be negated (e.g. `!*.ipynb`)
286318
negated: bool,
287319
/// The per-file data associated with these glob patterns.
@@ -298,15 +330,12 @@ impl<T> PerFile<T> {
298330
if negated {
299331
pattern.drain(..1);
300332
}
301-
let path = Path::new(&pattern);
302-
let absolute = match project_root {
303-
Some(project_root) => fs::normalize_path_to(path, project_root),
304-
None => fs::normalize_path(path),
305-
};
333+
334+
let project_root = project_root.unwrap_or(fs::get_cwd());
306335

307336
Self {
337+
absolute: GlobPath::normalize(&pattern, project_root),
308338
basename: pattern,
309-
absolute,
310339
negated,
311340
data,
312341
}

crates/ruff_server/src/session/index/ruff_settings.rs

+3-4
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@ use std::sync::Arc;
77
use anyhow::Context;
88
use ignore::{WalkBuilder, WalkState};
99

10-
use ruff_linter::{
11-
fs::normalize_path_to, settings::types::FilePattern, settings::types::PreviewMode,
12-
};
10+
use ruff_linter::settings::types::GlobPath;
11+
use ruff_linter::{settings::types::FilePattern, settings::types::PreviewMode};
1312
use ruff_workspace::resolver::match_exclusion;
1413
use ruff_workspace::Settings;
1514
use ruff_workspace::{
@@ -375,7 +374,7 @@ impl ConfigurationTransformer for EditorConfigurationTransformer<'_> {
375374
exclude
376375
.into_iter()
377376
.map(|pattern| {
378-
let absolute = normalize_path_to(&pattern, project_root);
377+
let absolute = GlobPath::normalize(&pattern, project_root);
379378
FilePattern::User(pattern, absolute)
380379
})
381380
.collect()

crates/ruff_workspace/src/configuration.rs

+8-8
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use ruff_linter::settings::fix_safety_table::FixSafetyTable;
3030
use ruff_linter::settings::rule_table::RuleTable;
3131
use ruff_linter::settings::types::{
3232
CompiledPerFileIgnoreList, CompiledPerFileTargetVersionList, ExtensionMapping, FilePattern,
33-
FilePatternSet, OutputFormat, PerFileIgnore, PerFileTargetVersion, PreviewMode,
33+
FilePatternSet, GlobPath, OutputFormat, PerFileIgnore, PerFileTargetVersion, PreviewMode,
3434
RequiredVersion, UnsafeFixes,
3535
};
3636
use ruff_linter::settings::{LinterSettings, DEFAULT_SELECTORS, DUMMY_VARIABLE_RGX, TASK_TAGS};
@@ -476,7 +476,7 @@ impl Configuration {
476476
paths
477477
.into_iter()
478478
.map(|pattern| {
479-
let absolute = fs::normalize_path_to(&pattern, project_root);
479+
let absolute = GlobPath::normalize(&pattern, project_root);
480480
FilePattern::User(pattern, absolute)
481481
})
482482
.collect()
@@ -495,7 +495,7 @@ impl Configuration {
495495
paths
496496
.into_iter()
497497
.map(|pattern| {
498-
let absolute = fs::normalize_path_to(&pattern, project_root);
498+
let absolute = GlobPath::normalize(&pattern, project_root);
499499
FilePattern::User(pattern, absolute)
500500
})
501501
.collect()
@@ -507,7 +507,7 @@ impl Configuration {
507507
paths
508508
.into_iter()
509509
.map(|pattern| {
510-
let absolute = fs::normalize_path_to(&pattern, project_root);
510+
let absolute = GlobPath::normalize(&pattern, project_root);
511511
FilePattern::User(pattern, absolute)
512512
})
513513
.collect()
@@ -517,7 +517,7 @@ impl Configuration {
517517
paths
518518
.into_iter()
519519
.map(|pattern| {
520-
let absolute = fs::normalize_path_to(&pattern, project_root);
520+
let absolute = GlobPath::normalize(&pattern, project_root);
521521
FilePattern::User(pattern, absolute)
522522
})
523523
.collect()
@@ -700,7 +700,7 @@ impl LintConfiguration {
700700
paths
701701
.into_iter()
702702
.map(|pattern| {
703-
let absolute = fs::normalize_path_to(&pattern, project_root);
703+
let absolute = GlobPath::normalize(&pattern, project_root);
704704
FilePattern::User(pattern, absolute)
705705
})
706706
.collect()
@@ -1203,7 +1203,7 @@ impl FormatConfiguration {
12031203
paths
12041204
.into_iter()
12051205
.map(|pattern| {
1206-
let absolute = fs::normalize_path_to(&pattern, project_root);
1206+
let absolute = GlobPath::normalize(&pattern, project_root);
12071207
FilePattern::User(pattern, absolute)
12081208
})
12091209
.collect()
@@ -1267,7 +1267,7 @@ impl AnalyzeConfiguration {
12671267
paths
12681268
.into_iter()
12691269
.map(|pattern| {
1270-
let absolute = fs::normalize_path_to(&pattern, project_root);
1270+
let absolute = GlobPath::normalize(&pattern, project_root);
12711271
FilePattern::User(pattern, absolute)
12721272
})
12731273
.collect()

0 commit comments

Comments
 (0)