Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/pet-fs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ msvc_spectre_libs = { version = "0.1.1", features = ["error"] }
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem", "Win32_Foundation"] }

[dependencies]
glob = "0.3.1"
log = "0.4.21"
236 changes: 236 additions & 0 deletions crates/pet-fs/src/glob.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use glob::glob;
use std::path::PathBuf;

/// Characters that indicate a path contains glob pattern metacharacters.
const GLOB_METACHARACTERS: &[char] = &['*', '?', '[', ']'];

/// Checks whether a path string contains glob metacharacters.
///
/// # Examples
/// - `"/home/user/*"` → `true`
/// - `"/home/user/envs"` → `false`
/// - `"**/*.py"` → `true`
/// - `"/home/user/[abc]"` → `true`
pub fn is_glob_pattern(path: &str) -> bool {
path.contains(GLOB_METACHARACTERS)
}

/// Expands a single glob pattern to matching paths.
///
/// If the path does not contain glob metacharacters, returns it unchanged (if it exists)
/// or as-is (to let downstream code handle non-existent paths).
///
/// If the path is a glob pattern, expands it and returns all matching paths.
/// Pattern errors and unreadable paths are logged and skipped.
///
/// # Examples
/// - `"/home/user/envs"` → `["/home/user/envs"]`
/// - `"/home/user/*/venv"` → `["/home/user/project1/venv", "/home/user/project2/venv"]`
/// - `"**/.venv"` → All `.venv` directories recursively
pub fn expand_glob_pattern(pattern: &str) -> Vec<PathBuf> {
if !is_glob_pattern(pattern) {
// Not a glob pattern, return as-is
return vec![PathBuf::from(pattern)];
}

match glob(pattern) {
Ok(paths) => {
let mut result = Vec::new();
for entry in paths {
match entry {
Ok(path) => result.push(path),
Err(e) => {
log::debug!("Failed to read glob entry: {}", e);
}
}
}
if result.is_empty() {
log::debug!("Glob pattern '{}' matched no paths", pattern);
}
result
}
Err(e) => {
log::warn!("Invalid glob pattern '{}': {}", pattern, e);
Vec::new()
}
}
}

/// Expands a list of paths, where each path may be a glob pattern.
///
/// Non-glob paths are passed through as-is.
/// Glob patterns are expanded to all matching paths.
/// Duplicate paths are preserved (caller should deduplicate if needed).
///
/// # Examples
/// ```ignore
/// let paths = vec![
/// PathBuf::from("/home/user/project"),
/// PathBuf::from("/home/user/*/venv"),
/// ];
/// let expanded = expand_glob_patterns(&paths);
/// // expanded contains "/home/user/project" plus all matching venv dirs
/// ```
pub fn expand_glob_patterns(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut result = Vec::new();
for path in paths {
let path_str = path.to_string_lossy();
let expanded = expand_glob_pattern(&path_str);
result.extend(expanded);
}
result
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;

#[test]
fn test_is_glob_pattern_with_asterisk() {
assert!(is_glob_pattern("/home/user/*"));
assert!(is_glob_pattern("**/*.py"));
assert!(is_glob_pattern("*.txt"));
}

#[test]
fn test_is_glob_pattern_with_question_mark() {
assert!(is_glob_pattern("/home/user/file?.txt"));
assert!(is_glob_pattern("test?"));
}

#[test]
fn test_is_glob_pattern_with_brackets() {
assert!(is_glob_pattern("/home/user/[abc]"));
assert!(is_glob_pattern("file[0-9].txt"));
}

#[test]
fn test_is_glob_pattern_no_metacharacters() {
assert!(!is_glob_pattern("/home/user/envs"));
assert!(!is_glob_pattern("simple_path"));
assert!(!is_glob_pattern("/usr/local/bin/python3"));
}

#[test]
fn test_expand_non_glob_path() {
let path = "/some/literal/path";
let result = expand_glob_pattern(path);
assert_eq!(result.len(), 1);
assert_eq!(result[0], PathBuf::from(path));
}

#[test]
fn test_expand_glob_pattern_no_matches() {
let pattern = "/this/path/definitely/does/not/exist/*";
let result = expand_glob_pattern(pattern);
assert!(result.is_empty());
}

#[test]
fn test_expand_glob_pattern_with_matches() {
// Create temp directories for testing
let temp_dir = std::env::temp_dir().join("pet_glob_test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(temp_dir.join("project1")).unwrap();
fs::create_dir_all(temp_dir.join("project2")).unwrap();
fs::create_dir_all(temp_dir.join("other")).unwrap();

let pattern = format!("{}/project*", temp_dir.to_string_lossy());
let result = expand_glob_pattern(&pattern);

assert_eq!(result.len(), 2);
assert!(result.iter().any(|p| p.ends_with("project1")));
assert!(result.iter().any(|p| p.ends_with("project2")));
assert!(!result.iter().any(|p| p.ends_with("other")));

// Cleanup
let _ = fs::remove_dir_all(&temp_dir);
}

#[test]
fn test_expand_glob_patterns_mixed() {
let temp_dir = std::env::temp_dir().join("pet_glob_test_mixed");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(temp_dir.join("dir1")).unwrap();
fs::create_dir_all(temp_dir.join("dir2")).unwrap();

let paths = vec![
PathBuf::from("/literal/path"),
PathBuf::from(format!("{}/dir*", temp_dir.to_string_lossy())),
];

let result = expand_glob_patterns(&paths);

// Should have literal path + 2 expanded directories
assert_eq!(result.len(), 3);
assert!(result.contains(&PathBuf::from("/literal/path")));

// Cleanup
let _ = fs::remove_dir_all(&temp_dir);
}

#[test]
fn test_expand_glob_pattern_recursive() {
// Create nested temp directories for testing **
let temp_dir = std::env::temp_dir().join("pet_glob_test_recursive");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(temp_dir.join("a/b/.venv")).unwrap();
fs::create_dir_all(temp_dir.join("c/.venv")).unwrap();
fs::create_dir_all(temp_dir.join(".venv")).unwrap();

let pattern = format!("{}/**/.venv", temp_dir.to_string_lossy());
let result = expand_glob_pattern(&pattern);

// Should find .venv at multiple levels (behavior depends on glob crate version)
assert!(!result.is_empty());
assert!(result.iter().all(|p| p.ends_with(".venv")));

// Cleanup
let _ = fs::remove_dir_all(&temp_dir);
}

#[test]
fn test_expand_glob_pattern_filename_patterns() {
// Create temp files for testing filename patterns like python_* and python.*
let temp_dir = std::env::temp_dir().join("pet_glob_test_filenames");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();

// Create files matching python_* pattern
fs::write(temp_dir.join("python_foo"), "").unwrap();
fs::write(temp_dir.join("python_bar"), "").unwrap();
fs::write(temp_dir.join("python_3.12"), "").unwrap();
fs::write(temp_dir.join("other_file"), "").unwrap();

// Test python_* pattern
let pattern = format!("{}/python_*", temp_dir.to_string_lossy());
let result = expand_glob_pattern(&pattern);

assert_eq!(result.len(), 3);
assert!(result.iter().any(|p| p.ends_with("python_foo")));
assert!(result.iter().any(|p| p.ends_with("python_bar")));
assert!(result.iter().any(|p| p.ends_with("python_3.12")));
assert!(!result.iter().any(|p| p.ends_with("other_file")));

// Create files matching python.* pattern
fs::write(temp_dir.join("python.exe"), "").unwrap();
fs::write(temp_dir.join("python.sh"), "").unwrap();
fs::write(temp_dir.join("pythonrc"), "").unwrap();

// Test python.* pattern
let pattern = format!("{}/python.*", temp_dir.to_string_lossy());
let result = expand_glob_pattern(&pattern);

assert_eq!(result.len(), 2);
assert!(result.iter().any(|p| p.ends_with("python.exe")));
assert!(result.iter().any(|p| p.ends_with("python.sh")));
assert!(!result.iter().any(|p| p.ends_with("pythonrc")));

// Cleanup
let _ = fs::remove_dir_all(&temp_dir);
}
}
1 change: 1 addition & 0 deletions crates/pet-fs/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

pub mod glob;
pub mod path;
32 changes: 28 additions & 4 deletions crates/pet/src/jsonrpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use pet_core::{
Configuration, Locator,
};
use pet_env_var_path::get_search_paths_from_env_variables;
use pet_fs::glob::expand_glob_patterns;
use pet_jsonrpc::{
send_error, send_reply,
server::{start_server, HandlersKeyedByMethodName},
Expand Down Expand Up @@ -92,11 +93,13 @@ pub fn start_jsonrpc_server() {
#[serde(rename_all = "camelCase")]
pub struct ConfigureOptions {
/// These are paths like workspace folders, where we can look for environments.
/// Glob patterns are supported (e.g., "/home/user/projects/*").
pub workspace_directories: Option<Vec<PathBuf>>,
pub conda_executable: Option<PathBuf>,
pub poetry_executable: Option<PathBuf>,
/// Custom locations where environments can be found. Generally global locations where virtualenvs & the like can be found.
/// Workspace directories should not be included into this list.
/// Glob patterns are supported (e.g., "/home/user/envs/*").
pub environment_directories: Option<Vec<PathBuf>>,
/// Directory to cache the Python environment details.
pub cache_directory: Option<PathBuf>,
Expand All @@ -108,9 +111,22 @@ pub fn handle_configure(context: Arc<Context>, id: u32, params: Value) {
// Start in a new thread, we can have multiple requests.
thread::spawn(move || {
let mut cfg = context.configuration.write().unwrap();
cfg.workspace_directories = configure_options.workspace_directories;
// Expand glob patterns in workspace_directories
cfg.workspace_directories = configure_options.workspace_directories.map(|dirs| {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does having glob patterns supported in the workspace dirs get us?

expand_glob_patterns(&dirs)
.into_iter()
.filter(|p| p.is_dir())
.collect()
});
cfg.conda_executable = configure_options.conda_executable;
cfg.environment_directories = configure_options.environment_directories;
// Expand glob patterns in environment_directories
cfg.environment_directories =
configure_options.environment_directories.map(|dirs| {
expand_glob_patterns(&dirs)
.into_iter()
.filter(|p| p.is_dir())
.collect()
});
cfg.poetry_executable = configure_options.poetry_executable;
// We will not support changing the cache directories once set.
// No point, supporting such a use case.
Expand Down Expand Up @@ -142,6 +158,7 @@ pub struct RefreshOptions {
/// If provided, then limit the search paths to these.
/// Note: Search paths can also include Python exes or Python env folders.
/// Traditionally, search paths are workspace folders.
/// Glob patterns are supported (e.g., "/home/user/*/venv", "**/.venv").
pub search_paths: Option<Vec<PathBuf>>,
}

Expand Down Expand Up @@ -187,16 +204,23 @@ pub fn handle_refresh(context: Arc<Context>, id: u32, params: Value) {
// Always clear this, as we will either serach in specified folder or a specific kind in global locations.
config.workspace_directories = None;
if let Some(search_paths) = refresh_options.search_paths {
// Expand any glob patterns in the search paths
let expanded_paths = expand_glob_patterns(&search_paths);
trace!(
"Expanded {} search paths to {} paths",
search_paths.len(),
expanded_paths.len()
);
// These workspace folders are only for this refresh.
config.workspace_directories = Some(
search_paths
expanded_paths
.iter()
.filter(|p| p.is_dir())
.cloned()
.collect(),
);
config.executables = Some(
search_paths
expanded_paths
.iter()
.filter(|p| p.is_file())
.cloned()
Expand Down
15 changes: 15 additions & 0 deletions docs/JSONRPC.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@ interface ConfigureParams {
*
* If not provided, then environments such as poetry, pipenv, and the like will not be reported.
* This is because poetry, pipenv, and the like are project specific enviornents.
*
* Glob patterns are supported (e.g., "/home/user/projects/*", "**/.venv").
*/
workspaceDirectories?: string[];
/**
* This is a list of directories where we should look for python environments such as Virtual Environments created/managed by the user.
* This is useful when the virtual environments are stored in some custom locations.
*
* Useful for VS Code so users can configure where they store virtual environments.
*
* Glob patterns are supported (e.g., "/home/user/envs/*", "/home/user/*/venv").
*/
environmentDirectories?: string[];
/**
Expand Down Expand Up @@ -95,6 +99,17 @@ interface RefreshParams {
* Limits the search to a specific set of paths.
* searchPaths can either by directories or Python prefixes/executables or combination of both.
* Ignores workspace folders passed in configuration request.
*
* Glob patterns are supported:
* - `*` matches any sequence of characters in a path component
* - `?` matches any single character
* - `**` matches any sequence of path components (recursive)
* - `[...]` matches any character inside the brackets
*
* Examples:
* - "/home/user/projects/*" - all directories under projects
* - "/home/user/**/venv" - all venv directories recursively
* - "/home/user/project[0-9]" - project0, project1, etc.
*/
searchPaths?: string[];
}
Expand Down
Loading