#[cfg(not(windows))]
compile_error!("reveal is Windows-only; build with a Windows target");
mod automation;
mod ffi;
#[cfg(test)]
mod crosscheck;
use std::borrow::Cow;
use std::env;
use std::ffi::OsString;
use std::fmt;
use std::io::Write;
use std::os::windows::ffi::OsStrExt;
use std::path::{Component, Path, PathBuf, Prefix};
use std::ptr;
use std::sync::Mutex;
use std::time::Duration;
pub use automation::Switch;
pub const EXIT_OK: i32 = 0;
pub const EXIT_SHELL: i32 = 1;
pub const EXIT_USAGE: i32 = 2;
const COINIT_APARTMENTTHREADED: u32 = 0x2;
const RPC_E_CHANGED_MODE: i32 = 0x8001_0106_u32 as i32;
const TAB_POLICY_VAR: &str = "REVEAL_TAB_POLICY";
pub const DRY_RUN_VAR: &str = "REVEAL_DRY_RUN";
pub const USAGE: &str = "usage: reveal <file-or-folder-path>";
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
MissingArgument,
TooManyArguments(usize),
EmptyArgument,
NotFound(PathBuf),
NoParent(PathBuf),
BadSetting {
variable: &'static str,
value: OsString,
allowed: &'static str,
},
Shell { step: &'static str, hresult: u32 },
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::MissingArgument => write!(f, "missing file path argument"),
Error::TooManyArguments(n) => {
write!(f, "expected exactly 1 file path argument, got {n}")
}
Error::EmptyArgument => write!(f, "file path argument is empty"),
Error::NotFound(p) => write!(f, "path does not exist: {}", p.display()),
Error::NoParent(p) => write!(f, "path has no containing folder: {}", p.display()),
Error::BadSetting { variable, value, allowed } => write!(
f,
"{variable} must be {allowed}, got '{}'",
value.to_string_lossy()
),
Error::Shell { step, hresult } => {
write!(f, "{step} failed (HRESULT 0x{hresult:08X})")
}
}
}
}
impl std::error::Error for Error {}
pub trait Revealer {
fn reveal(&self, path: &Path) -> Result<(), Error>;
}
pub struct ShellRevealer;
impl ShellRevealer {
pub fn reveal_with(path: &Path, switch: Switch) -> Result<(), Error> {
let wide: Vec<u16> = path.as_os_str().encode_wide().chain([0]).collect();
let ours = init_apartment()?;
let result = match select_in_already_open_view(path, switch) {
Found::Done => Ok(()),
Found::Hidden => {
ensure_selected(path, switch);
Ok(())
}
Found::NotOpen => reveal_parsed(&wide).map(|()| ensure_selected(path, switch)),
};
if ours {
unsafe { ffi::CoUninitialize() };
}
result
}
}
impl Revealer for ShellRevealer {
fn reveal(&self, path: &Path) -> Result<(), Error> {
let switch = tab_policy(env::var_os(TAB_POLICY_VAR))?;
ShellRevealer::reveal_with(path, switch)
}
}
enum Found {
Done,
Hidden,
NotOpen,
}
fn tab_policy(raw: Option<OsString>) -> Result<Switch, Error> {
let Some(value) = raw else {
return Ok(Switch::NewTab);
};
match value.to_string_lossy().to_ascii_lowercase().as_str() {
"newtab" => Ok(Switch::NewTab),
"overwrite" => Ok(Switch::Overwrite),
_ => Err(Error::BadSetting {
variable: TAB_POLICY_VAR,
value,
allowed: "'newtab' or 'overwrite'",
}),
}
}
pub fn dry_run_setting(raw: Option<OsString>) -> Result<bool, Error> {
let Some(value) = raw else { return Ok(false) };
match value.to_string_lossy().to_ascii_lowercase().as_str() {
"" | "0" | "false" | "no" => Ok(false),
"1" | "true" | "yes" => Ok(true),
_ => Err(Error::BadSetting {
variable: DRY_RUN_VAR,
value,
allowed: "'1'/'0', 'true'/'false' or 'yes'/'no'",
}),
}
}
fn select_in_already_open_view(path: &Path, switch: Switch) -> Found {
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
return Found::NotOpen;
};
automation::poll_within(
Duration::from_millis(2000),
Found::NotOpen,
|| match automation::select_in_open_view(parent, name, switch) {
Ok(hwnd) => {
automation::bring_to_front(hwnd);
Some(Found::Done)
}
Err(automation::Miss::ItemNotListed(_)) => None,
Err(automation::Miss::Hidden(_)) => Some(Found::Hidden),
Err(automation::Miss::NoView) => Some(Found::NotOpen),
},
)
}
fn ensure_selected(path: &Path, switch: Switch) {
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
return;
};
let mut asked = false;
automation::poll_within(Duration::from_millis(3000), (), || {
match automation::select_in_open_view(parent, name, switch) {
Ok(hwnd) => {
automation::bring_to_front(hwnd);
Some(())
}
Err(automation::Miss::ItemNotListed(hwnd)) => {
automation::bring_to_front(hwnd);
None
}
Err(automation::Miss::Hidden(hwnd)) => {
automation::bring_to_front(hwnd);
if switch == Switch::NewTab && !asked {
asked = true;
automation::activate_tab_by_path(parent);
}
None
}
Err(automation::Miss::NoView) => None,
}
});
}
#[derive(Default)]
pub struct RecordingRevealer(pub Mutex<Vec<PathBuf>>);
impl RecordingRevealer {
pub fn recorded(&self) -> Vec<PathBuf> {
self.0.lock().unwrap().clone()
}
}
impl Revealer for RecordingRevealer {
fn reveal(&self, path: &Path) -> Result<(), Error> {
self.0.lock().unwrap().push(path.to_path_buf());
Ok(())
}
}
fn normalize(path: &Path) -> Cow<'_, Path> {
let mut components = path.components();
let head = match components.next() {
Some(Component::Prefix(prefix)) if !path.has_root() => {
match prefix.kind() {
Prefix::Disk(disk) | Prefix::VerbatimDisk(disk) => {
Some(OsString::from(format!("{}:\\", disk as char)))
}
_ => None,
}
}
Some(Component::Prefix(prefix)) => match prefix.kind() {
Prefix::VerbatimDisk(disk) => Some(OsString::from(format!("{}:\\", disk as char))),
Prefix::VerbatimUNC(server, share) => {
let mut head = OsString::from(r"\\");
head.push(server);
head.push("\\");
head.push(share);
head.push("\\");
Some(head)
}
_ => None,
},
_ => None,
};
let Some(mut rewritten) = head else {
return Cow::Borrowed(path);
};
let rest = components.as_path();
let rest = rest.strip_prefix("\\").unwrap_or(rest);
rewritten.push(rest.as_os_str());
Cow::Owned(PathBuf::from(rewritten))
}
pub fn resolve(raw: &std::ffi::OsStr, cwd: &Path) -> Result<PathBuf, Error> {
if raw.is_empty() {
return Err(Error::EmptyArgument);
}
let path = normalize(Path::new(raw));
let joined = if path.is_relative() {
cwd.join(&*path)
} else {
path.to_path_buf()
};
let abs: PathBuf = joined.components().collect();
if !abs.exists() {
return Err(Error::NotFound(abs));
}
match abs.parent() {
Some(parent) if !parent.as_os_str().is_empty() => Ok(abs),
_ => Err(Error::NoParent(abs)),
}
}
pub fn run(
args: &[OsString],
cwd: &Path,
revealer: &dyn Revealer,
stderr: &mut dyn Write,
) -> i32 {
let result = match args.len() {
0 => Err(Error::MissingArgument),
1 => resolve(&args[0], cwd).and_then(|p| revealer.reveal(&p).map(|()| p)),
n => Err(Error::TooManyArguments(n)),
};
match result {
Ok(_) => EXIT_OK,
Err(e) => {
let code = match e {
Error::Shell { .. } => EXIT_SHELL,
_ => EXIT_USAGE,
};
let _ = writeln!(stderr, "reveal: {e}");
if code == EXIT_USAGE {
let _ = writeln!(stderr, "{USAGE}");
}
code
}
}
}
fn init_apartment() -> Result<bool, Error> {
let hr = unsafe { ffi::CoInitializeEx(ptr::null(), COINIT_APARTMENTTHREADED) };
if hr == RPC_E_CHANGED_MODE {
Ok(false)
} else if hr >= 0 {
Ok(true)
} else {
Err(Error::Shell {
step: "CoInitializeEx",
hresult: hr as u32,
})
}
}
fn reveal_parsed(wide: &[u16]) -> Result<(), Error> {
unsafe {
let mut pidl = ptr::null_mut();
let hr = ffi::SHParseDisplayName(wide.as_ptr(), ptr::null(), &mut pidl, 0, ptr::null_mut());
if hr < 0 || pidl.is_null() {
return Err(Error::Shell {
step: "SHParseDisplayName",
hresult: hr as u32,
});
}
let child = ffi::ILFindLastID(pidl);
ffi::ILRemoveLastID(pidl);
let child_ptr: *const std::os::raw::c_void = child;
let hr = ffi::SHOpenFolderAndSelectItems(pidl, 1, &child_ptr, 0);
ffi::ILFree(pidl);
if hr < 0 {
return Err(Error::Shell {
step: "SHOpenFolderAndSelectItems",
hresult: hr as u32,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests;