use std::path::Path;
use globset::GlobSet;
use napi::{
bindgen_prelude::*,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use napi_derive::napi;
pub use crate::fs_cache::{FileType, GlobMatch};
use crate::{fs_cache, glob_util, task};
#[napi(object)]
pub struct GlobOptions<'env> {
pub pattern: String,
pub path: String,
pub file_type: Option<FileType>,
pub recursive: Option<bool>,
pub hidden: Option<bool>,
pub max_results: Option<u32>,
pub gitignore: Option<bool>,
pub cache: Option<bool>,
pub sort_by_mtime: Option<bool>,
pub include_node_modules: Option<bool>,
pub signal: Option<Unknown<'env>>,
pub timeout_ms: Option<u32>,
}
#[napi(object)]
pub struct GlobResult {
pub matches: Vec<GlobMatch>,
pub total_matches: u32,
}
struct GlobConfig {
root: std::path::PathBuf,
pattern: String,
recursive: bool,
include_hidden: bool,
file_type_filter: Option<FileType>,
max_results: usize,
use_gitignore: bool,
mentions_node_modules: bool,
sort_by_mtime: bool,
use_cache: bool,
}
fn resolve_symlink_target_type(root: &Path, relative_path: &str) -> Option<FileType> {
let target_path = root.join(relative_path);
let metadata = std::fs::metadata(target_path).ok()?;
if metadata.is_dir() {
Some(FileType::Dir)
} else if metadata.is_file() {
Some(FileType::File)
} else {
None
}
}
fn apply_file_type_filter(entry: &GlobMatch, config: &GlobConfig) -> Option<FileType> {
let Some(filter) = config.file_type_filter else {
return Some(entry.file_type);
};
if entry.file_type == filter {
return Some(entry.file_type);
}
if entry.file_type != FileType::Symlink {
return None;
}
match filter {
FileType::File | FileType::Dir => {
let resolved = resolve_symlink_target_type(&config.root, &entry.path)?;
if resolved == filter {
Some(resolved)
} else {
None
}
},
FileType::Symlink => None,
}
}
fn filter_entries(
entries: &[GlobMatch],
glob_set: &GlobSet,
config: &GlobConfig,
on_match: Option<&ThreadsafeFunction<GlobMatch>>,
ct: &task::CancelToken,
) -> Result<Vec<GlobMatch>> {
let mut matches = Vec::new();
if config.max_results == 0 {
return Ok(matches);
}
for entry in entries {
ct.heartbeat()?;
if fs_cache::should_skip_path(Path::new(&entry.path), config.mentions_node_modules) {
continue;
}
if !glob_set.is_match(&entry.path) {
continue;
}
let Some(effective_file_type) = apply_file_type_filter(entry, config) else {
continue;
};
let mut matched_entry = entry.clone();
matched_entry.file_type = effective_file_type;
if let Some(callback) = on_match {
callback.call(Ok(matched_entry.clone()), ThreadsafeFunctionCallMode::NonBlocking);
}
matches.push(matched_entry);
if !config.sort_by_mtime && matches.len() >= config.max_results {
break;
}
}
Ok(matches)
}
fn run_glob(
config: GlobConfig,
on_match: Option<&ThreadsafeFunction<GlobMatch>>,
ct: task::CancelToken,
) -> Result<GlobResult> {
let glob_set = glob_util::compile_glob(&config.pattern, config.recursive)?;
if config.max_results == 0 {
return Ok(GlobResult { matches: Vec::new(), total_matches: 0 });
}
let skip_node_modules = !config.mentions_node_modules;
let scan_options = fs_cache::ScanOptions {
include_hidden: config.include_hidden,
use_gitignore: config.use_gitignore,
skip_node_modules,
follow_links: false,
detail: if config.sort_by_mtime {
fs_cache::ScanDetail::Full
} else {
fs_cache::ScanDetail::Minimal
},
};
let mut matches = if config.use_cache {
let scan = fs_cache::get_or_scan(&config.root, scan_options, &ct)?;
let mut matches = filter_entries(&scan.entries, &glob_set, &config, on_match, &ct)?;
if matches.is_empty() && scan.cache_age_ms >= fs_cache::empty_recheck_ms() {
let fresh = fs_cache::force_rescan(&config.root, scan_options, true, &ct)?;
matches = filter_entries(&fresh, &glob_set, &config, on_match, &ct)?;
}
matches
} else {
let fresh = fs_cache::force_rescan(&config.root, scan_options, false, &ct)?;
filter_entries(&fresh, &glob_set, &config, on_match, &ct)?
};
if config.sort_by_mtime {
matches.sort_by(|a, b| {
let a_mtime = a.mtime.unwrap_or(0.0);
let b_mtime = b.mtime.unwrap_or(0.0);
b_mtime
.partial_cmp(&a_mtime)
.unwrap_or(std::cmp::Ordering::Equal)
});
matches.truncate(config.max_results);
}
let total_matches = matches.len().min(u32::MAX as usize) as u32;
Ok(GlobResult { matches, total_matches })
}
#[napi]
pub fn glob(
options: GlobOptions<'_>,
#[napi(ts_arg_type = "((error: Error | null, match: GlobMatch) => void) | undefined | null")]
on_match: Option<ThreadsafeFunction<GlobMatch>>,
) -> task::Promise<GlobResult> {
let GlobOptions {
pattern,
path,
file_type,
recursive,
hidden,
max_results,
gitignore,
sort_by_mtime,
cache,
include_node_modules,
timeout_ms,
signal,
} = options;
let pattern = pattern.trim();
let pattern = if pattern.is_empty() { "*" } else { pattern };
let pattern = pattern.to_string();
let ct = task::CancelToken::new(timeout_ms, signal);
task::blocking("glob", ct, move |ct| {
run_glob(
GlobConfig {
root: fs_cache::resolve_search_path(&path)?,
include_hidden: hidden.unwrap_or(false),
file_type_filter: file_type,
recursive: recursive.unwrap_or(true),
max_results: max_results.map_or(usize::MAX, |value| value as usize),
use_gitignore: gitignore.unwrap_or(true),
mentions_node_modules: include_node_modules
.unwrap_or_else(|| pattern.contains("node_modules")),
sort_by_mtime: sort_by_mtime.unwrap_or(false),
use_cache: cache.unwrap_or(false),
pattern,
},
on_match.as_ref(),
ct,
)
})
}