Skip to content

Commit 8981f28

Browse files
committed
Add glob support for directory and file pattern matching
1 parent 525aa9f commit 8981f28

File tree

7 files changed

+290
-6
lines changed

7 files changed

+290
-6
lines changed

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
==> 2024-02-28 23:05:07 <==
2-
# cmd: /home/runner/work/python-environment-tools/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda create -n conda1
2+
# cmd: /home/kanadig/GIT/projects/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda create -n conda1
33
# conda version: 23.11.0
44
==> 2024-02-28 23:08:59 <==
5-
# cmd: /home/runner/work/python-environment-tools/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda install -c conda-forge --name conda1 ipykernel -y
5+
# cmd: /home/kanadig/GIT/projects/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda install -c conda-forge --name conda1 ipykernel -y
66
# conda version: 23.11.0
77
+conda-forge/noarch::appnope-0.1.4-pyhd8ed1ab_0
88
+conda-forge/noarch::asttokens-2.4.1-pyhd8ed1ab_0

crates/pet-fs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ msvc_spectre_libs = { version = "0.1.1", features = ["error"] }
99
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem", "Win32_Foundation"] }
1010

1111
[dependencies]
12+
glob = "0.3.1"
1213
log = "0.4.21"

crates/pet-fs/src/glob.rs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use glob::glob;
5+
use std::path::PathBuf;
6+
7+
/// Characters that indicate a path contains glob pattern metacharacters.
8+
const GLOB_METACHARACTERS: &[char] = &['*', '?', '[', ']'];
9+
10+
/// Checks whether a path string contains glob metacharacters.
11+
///
12+
/// # Examples
13+
/// - `"/home/user/*"` → `true`
14+
/// - `"/home/user/envs"` → `false`
15+
/// - `"**/*.py"` → `true`
16+
/// - `"/home/user/[abc]"` → `true`
17+
pub fn is_glob_pattern(path: &str) -> bool {
18+
path.contains(GLOB_METACHARACTERS)
19+
}
20+
21+
/// Expands a single glob pattern to matching paths.
22+
///
23+
/// If the path does not contain glob metacharacters, returns it unchanged (if it exists)
24+
/// or as-is (to let downstream code handle non-existent paths).
25+
///
26+
/// If the path is a glob pattern, expands it and returns all matching paths.
27+
/// Pattern errors and unreadable paths are logged and skipped.
28+
///
29+
/// # Examples
30+
/// - `"/home/user/envs"` → `["/home/user/envs"]`
31+
/// - `"/home/user/*/venv"` → `["/home/user/project1/venv", "/home/user/project2/venv"]`
32+
/// - `"**/.venv"` → All `.venv` directories recursively
33+
pub fn expand_glob_pattern(pattern: &str) -> Vec<PathBuf> {
34+
if !is_glob_pattern(pattern) {
35+
// Not a glob pattern, return as-is
36+
return vec![PathBuf::from(pattern)];
37+
}
38+
39+
match glob(pattern) {
40+
Ok(paths) => {
41+
let mut result = Vec::new();
42+
for entry in paths {
43+
match entry {
44+
Ok(path) => result.push(path),
45+
Err(e) => {
46+
log::debug!("Failed to read glob entry: {}", e);
47+
}
48+
}
49+
}
50+
if result.is_empty() {
51+
log::debug!("Glob pattern '{}' matched no paths", pattern);
52+
}
53+
result
54+
}
55+
Err(e) => {
56+
log::warn!("Invalid glob pattern '{}': {}", pattern, e);
57+
Vec::new()
58+
}
59+
}
60+
}
61+
62+
/// Expands a list of paths, where each path may be a glob pattern.
63+
///
64+
/// Non-glob paths are passed through as-is.
65+
/// Glob patterns are expanded to all matching paths.
66+
/// Duplicate paths are preserved (caller should deduplicate if needed).
67+
///
68+
/// # Examples
69+
/// ```ignore
70+
/// let paths = vec![
71+
/// PathBuf::from("/home/user/project"),
72+
/// PathBuf::from("/home/user/*/venv"),
73+
/// ];
74+
/// let expanded = expand_glob_patterns(&paths);
75+
/// // expanded contains "/home/user/project" plus all matching venv dirs
76+
/// ```
77+
pub fn expand_glob_patterns(paths: &[PathBuf]) -> Vec<PathBuf> {
78+
let mut result = Vec::new();
79+
for path in paths {
80+
let path_str = path.to_string_lossy();
81+
let expanded = expand_glob_pattern(&path_str);
82+
result.extend(expanded);
83+
}
84+
result
85+
}
86+
87+
#[cfg(test)]
88+
mod tests {
89+
use super::*;
90+
use std::fs;
91+
92+
#[test]
93+
fn test_is_glob_pattern_with_asterisk() {
94+
assert!(is_glob_pattern("/home/user/*"));
95+
assert!(is_glob_pattern("**/*.py"));
96+
assert!(is_glob_pattern("*.txt"));
97+
}
98+
99+
#[test]
100+
fn test_is_glob_pattern_with_question_mark() {
101+
assert!(is_glob_pattern("/home/user/file?.txt"));
102+
assert!(is_glob_pattern("test?"));
103+
}
104+
105+
#[test]
106+
fn test_is_glob_pattern_with_brackets() {
107+
assert!(is_glob_pattern("/home/user/[abc]"));
108+
assert!(is_glob_pattern("file[0-9].txt"));
109+
}
110+
111+
#[test]
112+
fn test_is_glob_pattern_no_metacharacters() {
113+
assert!(!is_glob_pattern("/home/user/envs"));
114+
assert!(!is_glob_pattern("simple_path"));
115+
assert!(!is_glob_pattern("/usr/local/bin/python3"));
116+
}
117+
118+
#[test]
119+
fn test_expand_non_glob_path() {
120+
let path = "/some/literal/path";
121+
let result = expand_glob_pattern(path);
122+
assert_eq!(result.len(), 1);
123+
assert_eq!(result[0], PathBuf::from(path));
124+
}
125+
126+
#[test]
127+
fn test_expand_glob_pattern_no_matches() {
128+
let pattern = "/this/path/definitely/does/not/exist/*";
129+
let result = expand_glob_pattern(pattern);
130+
assert!(result.is_empty());
131+
}
132+
133+
#[test]
134+
fn test_expand_glob_pattern_with_matches() {
135+
// Create temp directories for testing
136+
let temp_dir = std::env::temp_dir().join("pet_glob_test");
137+
let _ = fs::remove_dir_all(&temp_dir);
138+
fs::create_dir_all(temp_dir.join("project1")).unwrap();
139+
fs::create_dir_all(temp_dir.join("project2")).unwrap();
140+
fs::create_dir_all(temp_dir.join("other")).unwrap();
141+
142+
let pattern = format!("{}/project*", temp_dir.to_string_lossy());
143+
let result = expand_glob_pattern(&pattern);
144+
145+
assert_eq!(result.len(), 2);
146+
assert!(result.iter().any(|p| p.ends_with("project1")));
147+
assert!(result.iter().any(|p| p.ends_with("project2")));
148+
assert!(!result.iter().any(|p| p.ends_with("other")));
149+
150+
// Cleanup
151+
let _ = fs::remove_dir_all(&temp_dir);
152+
}
153+
154+
#[test]
155+
fn test_expand_glob_patterns_mixed() {
156+
let temp_dir = std::env::temp_dir().join("pet_glob_test_mixed");
157+
let _ = fs::remove_dir_all(&temp_dir);
158+
fs::create_dir_all(temp_dir.join("dir1")).unwrap();
159+
fs::create_dir_all(temp_dir.join("dir2")).unwrap();
160+
161+
let paths = vec![
162+
PathBuf::from("/literal/path"),
163+
PathBuf::from(format!("{}/dir*", temp_dir.to_string_lossy())),
164+
];
165+
166+
let result = expand_glob_patterns(&paths);
167+
168+
// Should have literal path + 2 expanded directories
169+
assert_eq!(result.len(), 3);
170+
assert!(result.contains(&PathBuf::from("/literal/path")));
171+
172+
// Cleanup
173+
let _ = fs::remove_dir_all(&temp_dir);
174+
}
175+
176+
#[test]
177+
fn test_expand_glob_pattern_recursive() {
178+
// Create nested temp directories for testing **
179+
let temp_dir = std::env::temp_dir().join("pet_glob_test_recursive");
180+
let _ = fs::remove_dir_all(&temp_dir);
181+
fs::create_dir_all(temp_dir.join("a/b/.venv")).unwrap();
182+
fs::create_dir_all(temp_dir.join("c/.venv")).unwrap();
183+
fs::create_dir_all(temp_dir.join(".venv")).unwrap();
184+
185+
let pattern = format!("{}/**/.venv", temp_dir.to_string_lossy());
186+
let result = expand_glob_pattern(&pattern);
187+
188+
// Should find .venv at multiple levels (behavior depends on glob crate version)
189+
assert!(!result.is_empty());
190+
assert!(result.iter().all(|p| p.ends_with(".venv")));
191+
192+
// Cleanup
193+
let _ = fs::remove_dir_all(&temp_dir);
194+
}
195+
196+
#[test]
197+
fn test_expand_glob_pattern_filename_patterns() {
198+
// Create temp files for testing filename patterns like python_* and python.*
199+
let temp_dir = std::env::temp_dir().join("pet_glob_test_filenames");
200+
let _ = fs::remove_dir_all(&temp_dir);
201+
fs::create_dir_all(&temp_dir).unwrap();
202+
203+
// Create files matching python_* pattern
204+
fs::write(temp_dir.join("python_foo"), "").unwrap();
205+
fs::write(temp_dir.join("python_bar"), "").unwrap();
206+
fs::write(temp_dir.join("python_3.12"), "").unwrap();
207+
fs::write(temp_dir.join("other_file"), "").unwrap();
208+
209+
// Test python_* pattern
210+
let pattern = format!("{}/python_*", temp_dir.to_string_lossy());
211+
let result = expand_glob_pattern(&pattern);
212+
213+
assert_eq!(result.len(), 3);
214+
assert!(result.iter().any(|p| p.ends_with("python_foo")));
215+
assert!(result.iter().any(|p| p.ends_with("python_bar")));
216+
assert!(result.iter().any(|p| p.ends_with("python_3.12")));
217+
assert!(!result.iter().any(|p| p.ends_with("other_file")));
218+
219+
// Create files matching python.* pattern
220+
fs::write(temp_dir.join("python.exe"), "").unwrap();
221+
fs::write(temp_dir.join("python.sh"), "").unwrap();
222+
fs::write(temp_dir.join("pythonrc"), "").unwrap();
223+
224+
// Test python.* pattern
225+
let pattern = format!("{}/python.*", temp_dir.to_string_lossy());
226+
let result = expand_glob_pattern(&pattern);
227+
228+
assert_eq!(result.len(), 2);
229+
assert!(result.iter().any(|p| p.ends_with("python.exe")));
230+
assert!(result.iter().any(|p| p.ends_with("python.sh")));
231+
assert!(!result.iter().any(|p| p.ends_with("pythonrc")));
232+
233+
// Cleanup
234+
let _ = fs::remove_dir_all(&temp_dir);
235+
}
236+
}

crates/pet-fs/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
pub mod glob;
45
pub mod path;

crates/pet/src/jsonrpc.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use pet_core::{
2121
Configuration, Locator,
2222
};
2323
use pet_env_var_path::get_search_paths_from_env_variables;
24+
use pet_fs::glob::expand_glob_patterns;
2425
use pet_jsonrpc::{
2526
send_error, send_reply,
2627
server::{start_server, HandlersKeyedByMethodName},
@@ -92,11 +93,13 @@ pub fn start_jsonrpc_server() {
9293
#[serde(rename_all = "camelCase")]
9394
pub struct ConfigureOptions {
9495
/// These are paths like workspace folders, where we can look for environments.
96+
/// Glob patterns are supported (e.g., "/home/user/projects/*").
9597
pub workspace_directories: Option<Vec<PathBuf>>,
9698
pub conda_executable: Option<PathBuf>,
9799
pub poetry_executable: Option<PathBuf>,
98100
/// Custom locations where environments can be found. Generally global locations where virtualenvs & the like can be found.
99101
/// Workspace directories should not be included into this list.
102+
/// Glob patterns are supported (e.g., "/home/user/envs/*").
100103
pub environment_directories: Option<Vec<PathBuf>>,
101104
/// Directory to cache the Python environment details.
102105
pub cache_directory: Option<PathBuf>,
@@ -108,9 +111,22 @@ pub fn handle_configure(context: Arc<Context>, id: u32, params: Value) {
108111
// Start in a new thread, we can have multiple requests.
109112
thread::spawn(move || {
110113
let mut cfg = context.configuration.write().unwrap();
111-
cfg.workspace_directories = configure_options.workspace_directories;
114+
// Expand glob patterns in workspace_directories
115+
cfg.workspace_directories = configure_options.workspace_directories.map(|dirs| {
116+
expand_glob_patterns(&dirs)
117+
.into_iter()
118+
.filter(|p| p.is_dir())
119+
.collect()
120+
});
112121
cfg.conda_executable = configure_options.conda_executable;
113-
cfg.environment_directories = configure_options.environment_directories;
122+
// Expand glob patterns in environment_directories
123+
cfg.environment_directories =
124+
configure_options.environment_directories.map(|dirs| {
125+
expand_glob_patterns(&dirs)
126+
.into_iter()
127+
.filter(|p| p.is_dir())
128+
.collect()
129+
});
114130
cfg.poetry_executable = configure_options.poetry_executable;
115131
// We will not support changing the cache directories once set.
116132
// No point, supporting such a use case.
@@ -142,6 +158,7 @@ pub struct RefreshOptions {
142158
/// If provided, then limit the search paths to these.
143159
/// Note: Search paths can also include Python exes or Python env folders.
144160
/// Traditionally, search paths are workspace folders.
161+
/// Glob patterns are supported (e.g., "/home/user/*/venv", "**/.venv").
145162
pub search_paths: Option<Vec<PathBuf>>,
146163
}
147164

@@ -187,16 +204,23 @@ pub fn handle_refresh(context: Arc<Context>, id: u32, params: Value) {
187204
// Always clear this, as we will either serach in specified folder or a specific kind in global locations.
188205
config.workspace_directories = None;
189206
if let Some(search_paths) = refresh_options.search_paths {
207+
// Expand any glob patterns in the search paths
208+
let expanded_paths = expand_glob_patterns(&search_paths);
209+
trace!(
210+
"Expanded {} search paths to {} paths",
211+
search_paths.len(),
212+
expanded_paths.len()
213+
);
190214
// These workspace folders are only for this refresh.
191215
config.workspace_directories = Some(
192-
search_paths
216+
expanded_paths
193217
.iter()
194218
.filter(|p| p.is_dir())
195219
.cloned()
196220
.collect(),
197221
);
198222
config.executables = Some(
199-
search_paths
223+
expanded_paths
200224
.iter()
201225
.filter(|p| p.is_file())
202226
.cloned()

docs/JSONRPC.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,17 @@ interface ConfigureParams {
3737
*
3838
* If not provided, then environments such as poetry, pipenv, and the like will not be reported.
3939
* This is because poetry, pipenv, and the like are project specific enviornents.
40+
*
41+
* Glob patterns are supported (e.g., "/home/user/projects/*", "**/.venv").
4042
*/
4143
workspaceDirectories?: string[];
4244
/**
4345
* This is a list of directories where we should look for python environments such as Virtual Environments created/managed by the user.
4446
* This is useful when the virtual environments are stored in some custom locations.
4547
*
4648
* Useful for VS Code so users can configure where they store virtual environments.
49+
*
50+
* Glob patterns are supported (e.g., "/home/user/envs/*", "/home/user/*/venv").
4751
*/
4852
environmentDirectories?: string[];
4953
/**
@@ -95,6 +99,17 @@ interface RefreshParams {
9599
* Limits the search to a specific set of paths.
96100
* searchPaths can either by directories or Python prefixes/executables or combination of both.
97101
* Ignores workspace folders passed in configuration request.
102+
*
103+
* Glob patterns are supported:
104+
* - `*` matches any sequence of characters in a path component
105+
* - `?` matches any single character
106+
* - `**` matches any sequence of path components (recursive)
107+
* - `[...]` matches any character inside the brackets
108+
*
109+
* Examples:
110+
* - "/home/user/projects/*" - all directories under projects
111+
* - "/home/user/**/venv" - all venv directories recursively
112+
* - "/home/user/project[0-9]" - project0, project1, etc.
98113
*/
99114
searchPaths?: string[];
100115
}

0 commit comments

Comments
 (0)