diff --git a/crates/prek/src/cli/completion.rs b/crates/prek/src/cli/completion.rs index 58aaea871..e5c390f48 100644 --- a/crates/prek/src/cli/completion.rs +++ b/crates/prek/src/cli/completion.rs @@ -4,6 +4,7 @@ use std::path::Path; use clap::builder::StyledStr; use clap_complete::CompletionCandidate; +use rustc_hash::FxHashSet; use crate::config; use crate::fs::CWD; @@ -16,133 +17,39 @@ pub(crate) fn selector_completer(current: &OsStr) -> Vec { return vec![]; }; - let Ok(store) = Store::from_settings() else { + let Some(workspace) = discover_workspace() else { return vec![]; }; - let Ok(workspace) = Workspace::find_root(None, &CWD) - .and_then(|root| Workspace::discover(&store, root, None, None, false)) - else { - return vec![]; - }; - - let mut candidates: Vec = vec![]; - // Support optional `path:hook_prefix` form while typing. - let (path_part, hook_prefix_opt) = match current_str.split_once(':') { - Some((p, rest)) => (p, Some(rest)), - None => (current_str, None), - }; - - if path_part.contains('/') { - // Provide subdirectory matches relative to cwd for the path prefix - let path_obj = Path::new(path_part); - let (base_dir, shown_prefix, filter_prefix) = if path_part.ends_with('/') { - (CWD.join(path_obj), path_part.to_string(), String::new()) - } else { - let parent = path_obj.parent().unwrap_or(Path::new("")); - let file = path_obj.file_name().and_then(OsStr::to_str).unwrap_or(""); - let shown_prefix = if parent.as_os_str().is_empty() { - String::new() - } else { - format!("{}/", parent.display()) - }; - (CWD.join(parent), shown_prefix, file.to_string()) - }; - let mut had_children = false; - if hook_prefix_opt.is_none() { - let mut child_dirs = list_subdirs(&base_dir, &shown_prefix, &filter_prefix, &workspace); - let mut child_colons = - list_direct_project_colons(&base_dir, &shown_prefix, &filter_prefix, &workspace); - had_children = !(child_dirs.is_empty() && child_colons.is_empty()); - candidates.append(&mut child_dirs); - candidates.append(&mut child_colons); - } + let input = SelectorInput::parse(current_str); + let mut candidates = Vec::new(); - // If the path refers to a project directory in the workspace and a colon is present, - // suggest `path:hook_id`. For pure path input (no colon), don't suggest hooks. - let project_dir_abs = if path_part.ends_with('/') { - CWD.join(path_part.trim_end_matches('/')) - } else { - CWD.join(path_obj) - }; - if hook_prefix_opt.is_some() { - if let Some(proj) = workspace - .projects() - .iter() - .find(|p| p.path() == project_dir_abs) - { - let hook_pairs = all_hooks(proj); - let path_prefix_display = if path_part.ends_with('/') { - path_part.trim_end_matches('/') - } else { - path_part - }; - for (hid, name) in hook_pairs { - if let Some(hpref) = hook_prefix_opt { - if !hid.starts_with(hpref) && !hid.contains(hpref) { - continue; - } - } - let value = format!("{path_prefix_display}:{hid}"); - candidates - .push(CompletionCandidate::new(value).help(name.map(StyledStr::from))); - } - } - } else if path_part.ends_with('/') { - // No colon and trailing slash: if this base dir is a leaf project (no child projects), - // suggest the directory itself (with trailing '/'). - let is_project = workspace - .projects() - .iter() - .any(|p| p.path() == project_dir_abs); - if is_project && !had_children { - candidates.push(CompletionCandidate::new(path_part.to_string())); - } - } - - return candidates; + if input.path.contains('/') { + return complete_path_selector(&input, &workspace); } - // No slash: match subdirectories under cwd and hook ids across workspace - candidates.extend(list_subdirs(&CWD, "", current_str, &workspace)); - // Also suggest immediate child project roots as `name:` - candidates.extend(list_direct_project_colons( - &CWD, - "", - current_str, - &workspace, - )); - - // If the input ends with `:`, suggest hooks for that project - if let Some(hook_prefix) = hook_prefix_opt { - if !path_part.is_empty() { - let project_dir_abs = CWD.join(Path::new(path_part)); - if let Some(proj) = workspace - .projects() - .iter() - .find(|p| p.path() == project_dir_abs) - { - for (hid, name) in all_hooks(proj) { - if !hook_prefix.is_empty() - && !hid.starts_with(hook_prefix) - && !hid.contains(hook_prefix) - { - continue; - } - let value = format!("{path_part}:{hid}"); - candidates - .push(CompletionCandidate::new(value).help(name.map(StyledStr::from))); - } + // No slash: match subdirectories under cwd and hook ids across workspace. + candidates.extend(list_subdirs(&CWD, "", input.raw, &workspace)); + // Also suggest immediate child project roots as `name:`. + candidates.extend(list_direct_project_colons(&CWD, "", input.raw, &workspace)); + + // If the input includes a colon, suggest hooks for that project. + if let Some(hook_prefix) = input.hook_prefix { + if !input.path.is_empty() { + let project_dir_abs = CWD.join(Path::new(input.path)); + if let Some(proj) = find_project_by_abs_path(&workspace, &project_dir_abs) { + push_project_hooks(&mut candidates, proj, input.path, hook_prefix); } } } - // Aggregate unique hooks and filter by id + // Aggregate unique hooks and filter by id. let mut uniq: BTreeMap> = BTreeMap::new(); for proj in workspace.projects() { - for (id, name) in all_hooks(proj) { - if id.contains(current_str) || id.starts_with(current_str) { - uniq.entry(id).or_insert(name); + for (id, name) in iter_hooks(proj) { + if id.contains(input.raw) { + uniq.entry(id.to_owned()) + .or_insert_with(|| name.map(ToOwned::to_owned)); } } } @@ -154,33 +61,146 @@ pub(crate) fn selector_completer(current: &OsStr) -> Vec { candidates } -fn all_hooks(proj: &Project) -> Vec<(String, Option)> { - let mut out = Vec::new(); - for repo in &proj.config().repos { - match repo { - config::Repo::Remote(cfg) => { - for h in &cfg.hooks { - out.push((h.id.clone(), h.name.as_ref().map(ToString::to_string))); - } - } - config::Repo::Local(cfg) => { - for h in &cfg.hooks { - out.push((h.id.clone(), Some(h.name.clone()))); - } - } - config::Repo::Meta(cfg) => { - for h in &cfg.hooks { - out.push((h.id.clone(), Some(h.name.clone()))); - } - } - config::Repo::Builtin(cfg) => { - for h in &cfg.hooks { - out.push((h.id.clone(), Some(h.name.clone()))); - } - } +struct SelectorInput<'a> { + raw: &'a str, + path: &'a str, + hook_prefix: Option<&'a str>, +} + +impl<'a> SelectorInput<'a> { + fn parse(raw: &'a str) -> Self { + let (path, hook_prefix) = match raw.split_once(':') { + Some((path, hook_prefix)) => (path, Some(hook_prefix)), + None => (raw, None), + }; + Self { + raw, + path, + hook_prefix, } } - out +} + +fn discover_workspace() -> Option { + let store = Store::from_settings().ok()?; + let root = Workspace::find_root(None, &CWD).ok()?; + Workspace::discover(&store, root, None, None, false).ok() +} + +fn find_project_by_abs_path<'a>( + workspace: &'a Workspace, + abs: &Path, +) -> Option<&'a std::sync::Arc> { + workspace.projects().iter().find(|p| p.path() == abs) +} + +enum RepoHooksIter<'a> { + Remote(std::slice::Iter<'a, config::RemoteHook>), + Local(std::slice::Iter<'a, config::LocalHook>), + Meta(std::slice::Iter<'a, config::MetaHook>), + Builtin(std::slice::Iter<'a, config::BuiltinHook>), +} + +impl<'a> Iterator for RepoHooksIter<'a> { + type Item = (&'a str, Option<&'a str>); + + fn next(&mut self) -> Option { + match self { + Self::Remote(it) => it.next().map(|h| (h.id.as_str(), h.name.as_deref())), + Self::Local(it) => it.next().map(|h| (h.id.as_str(), Some(h.name.as_str()))), + Self::Meta(it) => it.next().map(|h| (h.id.as_str(), Some(h.name.as_str()))), + Self::Builtin(it) => it.next().map(|h| (h.id.as_str(), Some(h.name.as_str()))), + } + } +} + +fn repo_hooks_iter(repo: &config::Repo) -> RepoHooksIter<'_> { + match repo { + config::Repo::Remote(cfg) => RepoHooksIter::Remote(cfg.hooks.iter()), + config::Repo::Local(cfg) => RepoHooksIter::Local(cfg.hooks.iter()), + config::Repo::Meta(cfg) => RepoHooksIter::Meta(cfg.hooks.iter()), + config::Repo::Builtin(cfg) => RepoHooksIter::Builtin(cfg.hooks.iter()), + } +} + +fn iter_hooks(proj: &Project) -> impl Iterator)> { + proj.config() + .repos + .iter() + .flat_map(|repo| repo_hooks_iter(repo)) +} + +fn push_project_hooks( + candidates: &mut Vec, + proj: &Project, + selector_prefix: &str, + hook_prefix: &str, +) { + for (hook_id, hook_name) in iter_hooks(proj) { + if !hook_prefix.is_empty() && !hook_id.contains(hook_prefix) { + continue; + } + let value = format!("{selector_prefix}:{hook_id}"); + candidates.push( + CompletionCandidate::new(value) + .help(hook_name.map(|name| StyledStr::from(name.to_owned()))), + ); + } +} + +fn complete_path_selector( + input: &SelectorInput<'_>, + workspace: &Workspace, +) -> Vec { + let mut candidates = Vec::new(); + + // Provide subdirectory matches relative to cwd for the path prefix. + let path_obj = Path::new(input.path); + let (base_dir, shown_prefix, filter_prefix) = if input.path.ends_with('/') { + (CWD.join(path_obj), input.path.to_string(), String::new()) + } else { + let parent = path_obj.parent().unwrap_or(Path::new("")); + let file = path_obj.file_name().and_then(OsStr::to_str).unwrap_or(""); + let shown_prefix = if parent.as_os_str().is_empty() { + String::new() + } else { + format!("{}/", parent.display()) + }; + (CWD.join(parent), shown_prefix, file.to_string()) + }; + + let mut had_children = false; + if input.hook_prefix.is_none() { + let mut child_dirs = list_subdirs(&base_dir, &shown_prefix, &filter_prefix, workspace); + let mut child_colons = + list_direct_project_colons(&base_dir, &shown_prefix, &filter_prefix, workspace); + had_children = !(child_dirs.is_empty() && child_colons.is_empty()); + candidates.append(&mut child_dirs); + candidates.append(&mut child_colons); + } + + let project_dir_abs = if input.path.ends_with('/') { + CWD.join(input.path.trim_end_matches('/')) + } else { + CWD.join(path_obj) + }; + + // If the path refers to a project directory in the workspace and a colon is present, + // suggest `path:hook_id`. For pure path input (no colon), don't suggest hooks. + if let Some(hook_prefix) = input.hook_prefix { + if let Some(proj) = find_project_by_abs_path(workspace, &project_dir_abs) { + let selector_prefix = input.path.trim_end_matches('/'); + push_project_hooks(&mut candidates, proj, selector_prefix, hook_prefix); + } + } else if input.path.ends_with('/') { + // No colon and trailing slash: if this base dir is a leaf project (no child projects), + // suggest the directory itself (with trailing '/'). + if find_project_by_abs_path(workspace, &project_dir_abs).is_some() && !had_children { + candidates.push(CompletionCandidate::new(input.path.to_string())); + } + } + + candidates } // List subdirectories under base that contain projects (immediate or nested), @@ -192,36 +212,38 @@ fn list_subdirs( workspace: &Workspace, ) -> Vec { let mut out = Vec::new(); - let mut first_components: BTreeSet = BTreeSet::new(); + + for name in child_project_names(base, workspace) { + if !filter_prefix.is_empty() && !name.contains(filter_prefix) { + continue; + } + + let mut value = String::new(); + value.push_str(shown_prefix); + value.push_str(&name); + if !value.ends_with('/') { + value.push('/'); + } + out.push(CompletionCandidate::new(value)); + } + + out +} + +fn child_project_names(base: &Path, workspace: &Workspace) -> BTreeSet { + let mut names = BTreeSet::new(); for proj in workspace.projects() { let p = proj.path(); if let Ok(rel) = p.strip_prefix(base) { if rel.as_os_str().is_empty() { - // Project is exactly at base; doesn't yield a child directory continue; } if let Some(first) = rel.components().next() { - let name = first.as_os_str().to_string_lossy().to_string(); - first_components.insert(name); - } - } - } - for name in first_components { - if filter_prefix.is_empty() - || name.starts_with(filter_prefix) - || name.contains(filter_prefix) - { - let mut value = String::new(); - value.push_str(shown_prefix); - value.push_str(&name); - if !value.ends_with('/') { - value.push('/'); + names.insert(first.as_os_str().to_string_lossy().to_string()); } - out.push(CompletionCandidate::new(value)); } } - - out + names } // List immediate child directories under `base` that are themselves project roots, @@ -232,45 +254,25 @@ fn list_direct_project_colons( filter_prefix: &str, workspace: &Workspace, ) -> Vec { - // Build a set of absolute project paths for quick lookup - let proj_paths: BTreeSet<_> = workspace - .projects() - .iter() - .map(|p| p.path().to_path_buf()) - .collect(); + let mut out = Vec::new(); - // Compute immediate child names that lead to at least one project (same logic as list_subdirs) - // then keep only those where `base/child` is itself a project root. - let mut names: BTreeSet = BTreeSet::new(); - for proj in workspace.projects() { - let p = proj.path(); - if let Ok(rel) = p.strip_prefix(base) { - if rel.as_os_str().is_empty() { - continue; - } - if let Some(first) = rel.components().next() { - let name = first.as_os_str().to_string_lossy().to_string(); - // Only keep if this immediate child is a project root - let child_abs = base.join(&name); - if proj_paths.contains(&child_abs) { - names.insert(name); - } - } - } - } + // Build a set of absolute project paths for quick lookup. + let proj_paths: FxHashSet<_> = workspace.projects().iter().map(|p| p.path()).collect(); - let mut out = Vec::new(); - for name in names { - if filter_prefix.is_empty() - || name.starts_with(filter_prefix) - || name.contains(filter_prefix) - { - let mut value = String::new(); - value.push_str(shown_prefix); - value.push_str(&name); - value.push(':'); - out.push(CompletionCandidate::new(value)); + for name in child_project_names(base, workspace) { + if !filter_prefix.is_empty() && !name.contains(filter_prefix) { + continue; } + let child_abs = base.join(&name); + if !proj_paths.contains(child_abs.as_path()) { + continue; + } + + let mut value = String::new(); + value.push_str(shown_prefix); + value.push_str(&name); + value.push(':'); + out.push(CompletionCandidate::new(value)); } out } diff --git a/crates/prek/src/cli/run/filter.rs b/crates/prek/src/cli/run/filter.rs index 4eaff0e0d..25bdaeef4 100644 --- a/crates/prek/src/cli/run/filter.rs +++ b/crates/prek/src/cli/run/filter.rs @@ -85,7 +85,6 @@ pub(crate) struct FileFilter<'a> { impl<'a> FileFilter<'a> { // Here, `filenames` are paths relative to the workspace root. - #[instrument(level = "trace", skip_all, fields(project = %project))] pub(crate) fn for_project( filenames: I, project: &'a Project,