use minijinja::Error;
mod diagnostics;
mod errors;
mod normalize;
mod validate;
mod walk;
use errors::{GlobErrorContext, GlobErrorType, create_glob_error};
use normalize::normalize_separators;
use validate::validate_brace_matching;
use walk::{open_root_dir, process_glob_entry};
#[cfg(unix)]
use normalize::force_literal_escapes;
#[derive(Debug, Clone)]
struct GlobPattern {
raw: String,
normalized: String,
}
impl GlobPattern {
#[must_use]
#[expect(
clippy::missing_const_for_fn,
reason = "const String::as_str() not available on all MSRV targets"
)]
fn raw(&self) -> &str {
self.raw.as_str()
}
#[must_use]
#[expect(
clippy::missing_const_for_fn,
reason = "const String::as_str() not available on all MSRV targets"
)]
fn normalized(&self) -> &str {
self.normalized.as_str()
}
fn new(raw: &str) -> std::result::Result<Self, Error> {
validate_brace_matching(raw)?;
#[cfg(unix)]
let normalized = {
let normalized = normalize_separators(raw);
force_literal_escapes(&normalized)
};
#[cfg(not(unix))]
let normalized = normalize_separators(raw);
Ok(Self {
raw: raw.to_owned(),
normalized,
})
}
}
type GlobEntryResult = std::result::Result<std::path::PathBuf, glob::GlobError>;
pub(super) struct GlobExpansion {
paths: Vec<String>,
outcome: GlobOutcome,
skipped: GlobSkippedEntries,
}
enum GlobOutcome {
Matched,
UnopenablePrefix,
}
const MAX_UNREACHABLE_SYMLINK_SAMPLES: usize = 4;
#[derive(Default)]
struct GlobSkippedEntries {
unreachable_symlinks: usize,
unreachable_symlink_samples: Vec<camino::Utf8PathBuf>,
not_a_file: usize,
}
impl GlobSkippedEntries {
fn record_unreachable_symlink(&mut self, relative: camino::Utf8PathBuf) {
self.unreachable_symlinks += 1;
if self.unreachable_symlink_samples.len() < MAX_UNREACHABLE_SYMLINK_SAMPLES {
self.unreachable_symlink_samples.push(relative);
}
}
const fn record_not_a_file(&mut self) {
self.not_a_file += 1;
}
}
#[derive(Debug)]
pub(super) enum GlobEntry {
Path(String),
UnreachableSymlink(camino::Utf8PathBuf),
NotAFile,
}
impl GlobExpansion {
pub(super) fn into_paths(self) -> Vec<String> {
self.paths
}
}
pub fn glob_paths(pattern: &str) -> std::result::Result<Vec<String>, Error> {
expand_glob(pattern).map(GlobExpansion::into_paths)
}
pub(super) fn expand_glob(pattern: &str) -> std::result::Result<GlobExpansion, Error> {
use glob::{MatchOptions, glob_with};
let opts = MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: false,
};
let pattern_state = GlobPattern::new(pattern)?;
let entries = glob_with(pattern_state.normalized(), opts).map_err(|e| {
create_glob_error(
&GlobErrorContext {
pattern: pattern_state.raw().to_owned(),
error_char: char::from(0),
position: 0,
error_type: GlobErrorType::InvalidPattern,
},
Some(e.to_string()),
)
})?;
let Some(root) = open_root_dir(&pattern_state).map_err(|e| {
create_glob_error(
&GlobErrorContext {
pattern: pattern_state.raw().to_owned(),
error_char: char::from(0),
position: 0,
error_type: GlobErrorType::IoError,
},
Some(e.to_string()),
)
})?
else {
return Ok(GlobExpansion {
outcome: GlobOutcome::UnopenablePrefix,
paths: Vec::new(),
skipped: GlobSkippedEntries::default(),
});
};
let mut paths = Vec::new();
let mut skipped = GlobSkippedEntries::default();
for entry in entries {
match process_glob_entry(entry, &pattern_state, &root)? {
GlobEntry::Path(path) => paths.push(path),
GlobEntry::UnreachableSymlink(relative) => {
skipped.record_unreachable_symlink(relative);
}
GlobEntry::NotAFile => skipped.record_not_a_file(),
}
}
Ok(GlobExpansion {
paths,
outcome: GlobOutcome::Matched,
skipped,
})
}
pub(super) fn record_expansion(expansion: &GlobExpansion) {
diagnostics::record(expansion);
}
#[cfg(test)]
mod tests;