use minijinja::Error;
mod diagnostics;
mod errors;
mod escape;
mod normalize;
mod validate;
mod walk;
pub(super) use base::GlobBaseCache;
use base::PreparedGlob;
use camino::{Utf8Path, Utf8PathBuf};
pub(super) use diagnostics::expand_manifest_template_glob;
use errors::{GlobErrorContext, GlobErrorType, GlobExpansionFailure, 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(Utf8PathBuf),
UnreachableSymlink(camino::Utf8PathBuf),
NotAFile,
}
impl GlobExpansion {
pub(super) fn into_paths(self) -> Vec<String> {
self.paths
}
pub(super) fn into_template_paths(
self,
pattern: &str,
) -> std::result::Result<Vec<String>, Error> {
if self.paths.iter().all(|path| is_shell_inert_path(path)) {
return Ok(self.paths);
}
diagnostics::record_template_path_rejection();
Err(create_glob_error(
&GlobErrorContext {
pattern: pattern.to_owned(),
error_char: char::from(0),
position: pattern.len(),
error_type: GlobErrorType::IoError,
},
Some("glob matched a path containing characters that require shell quoting".to_owned()),
))
}
}
fn is_shell_inert_path(path: &str) -> bool {
!path.is_empty()
&& path.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b',' | b'.' | b'/' | b'_' | b'-' | b':')
})
}
pub fn glob_paths(
pattern: &str,
base: Option<&Utf8Path>,
) -> std::result::Result<Vec<String>, Error> {
expand_glob(pattern, base).map(GlobExpansion::into_paths)
}
pub(super) fn expand_glob(
pattern: &str,
base: Option<&Utf8Path>,
) -> std::result::Result<GlobExpansion, Error> {
let prepared = PreparedGlob::new(pattern, base)?;
expand_prepared_glob(&prepared).map_err(GlobExpansionFailure::into_error)
}
fn expand_glob_with_base_cache(
pattern: &str,
base: &GlobBaseCache,
) -> std::result::Result<GlobExpansion, GlobExpansionFailure> {
let prepared = PreparedGlob::new_with_base_cache_for_template(pattern, base)?;
expand_prepared_glob(&prepared)
}
fn expand_prepared_glob(
prepared: &PreparedGlob,
) -> std::result::Result<GlobExpansion, GlobExpansionFailure> {
use glob::{MatchOptions, glob_with};
let opts = MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: false,
};
let entries = glob_with(prepared.search(), opts).map_err(|e| {
GlobExpansionFailure::InvalidPattern(create_glob_error(
&GlobErrorContext {
pattern: prepared.pattern.raw().to_owned(),
error_char: char::from(0),
position: 0,
error_type: GlobErrorType::InvalidPattern,
},
Some(e.to_string()),
))
})?;
let Some(root) = open_root_dir(prepared.search(), None).map_err(|e| {
GlobExpansionFailure::CapabilityRootIo(create_glob_error(
&GlobErrorContext {
pattern: prepared.pattern.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, &prepared.pattern, &root)
.map_err(GlobExpansionFailure::GlobEntryProcessing)?
{
GlobEntry::Path(path) => paths.push(strip_base(prepared.strip.as_deref(), &path)),
GlobEntry::UnreachableSymlink(relative) => {
skipped.record_unreachable_symlink(relative);
}
GlobEntry::NotAFile => skipped.record_not_a_file(),
}
}
Ok(GlobExpansion {
paths,
outcome: GlobOutcome::Matched,
skipped,
})
}
fn strip_base(base: Option<&Utf8Path>, path: &Utf8Path) -> String {
let relative = base
.and_then(|dir| path.strip_prefix(dir).ok())
.unwrap_or(path);
relative.as_str().replace('\\', "/")
}
#[cfg(test)]
mod tests;
mod base;