logiguard fork v3: full patch set on verified 8c74db0 tree

Includes prior-session patches (carry forward so the app compiles):
  - crates/gpui/build.rs: cross-compile manifest fix
  - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method
  - crates/gpui/src/window.rs: Window::activate_with_token public API
  - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix

Plus the focus-serial fix:
  - serial.rs: SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; latest_serial_of()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

69
crates/util/Cargo.toml Normal file
View File

@@ -0,0 +1,69 @@
[package]
name = "util"
version = "0.1.0"
edition.workspace = true
publish = false
license = "Apache-2.0"
description = "A collection of utility structs and functions used by Zed and GPUI"
[lints]
workspace = true
[lib]
path = "src/util.rs"
doctest = true
[features]
test-support = ["git2", "rand", "util_macros"]
[dependencies]
anyhow.workspace = true
async_zip.workspace = true
collections.workspace = true
dunce.workspace = true
futures-lite.workspace = true
futures.workspace = true
globset.workspace = true
itertools.workspace = true
log.workspace = true
rand = { workspace = true, optional = true }
regex.workspace = true
rust-embed.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
shlex.workspace = true
take-until.workspace = true
tempfile.workspace = true
unicase.workspace = true
url.workspace = true
percent-encoding.workspace = true
util_macros = { workspace = true, optional = true }
gpui_util.workspace = true
[target.'cfg(not(target_family = "wasm"))'.dependencies]
smol.workspace = true
which.workspace = true
git2 = { workspace = true, optional = true }
async-fs.workspace = true
walkdir.workspace = true
dirs.workspace = true
[target.'cfg(unix)'.dependencies]
command-fds = "0.3.1"
libc.workspace = true
nix = { workspace = true, features = ["user"] }
[target.'cfg(target_os = "macos")'.dependencies]
mach2.workspace = true
[target.'cfg(windows)'.dependencies]
tendril = "0.4.3"
[dev-dependencies]
git2.workspace = true
rand.workspace = true
util_macros.workspace = true
pretty_assertions.workspace = true

1
crates/util/LICENSE-APACHE Symbolic link
View File

@@ -0,0 +1 @@
../../LICENSE-APACHE

383
crates/util/src/archive.rs Normal file
View File

@@ -0,0 +1,383 @@
use std::path::Path;
use anyhow::{Context as _, Result};
use async_zip::base::read;
#[cfg(not(windows))]
use futures::AsyncSeek;
use futures::{AsyncRead, io::BufReader};
#[cfg(any(unix, windows))]
fn archive_path_is_normal(filename: &str) -> bool {
Path::new(filename).components().all(|c| {
matches!(
c,
std::path::Component::Normal(_) | std::path::Component::CurDir
)
})
}
#[cfg(windows)]
pub async fn extract_zip<R: AsyncRead + Unpin>(destination: &Path, reader: R) -> Result<()> {
let mut reader = read::stream::ZipFileReader::new(BufReader::new(reader));
let destination = &destination
.canonicalize()
.unwrap_or_else(|_| destination.to_path_buf());
while let Some(mut item) = reader.next_with_entry().await? {
let entry_reader = item.reader_mut();
let entry = entry_reader.entry();
let filename = entry
.filename()
.as_str()
.context("reading zip entry file name")?;
if !archive_path_is_normal(filename) {
reader = item.skip().await.context("reading next zip entry")?;
continue;
}
let path = destination.join(filename);
if entry
.dir()
.with_context(|| format!("reading zip entry metadata for path {path:?}"))?
{
std::fs::create_dir_all(&path)
.with_context(|| format!("creating directory {path:?}"))?;
} else {
let parent_dir = path
.parent()
.with_context(|| format!("no parent directory for {path:?}"))?;
std::fs::create_dir_all(parent_dir)
.with_context(|| format!("creating parent directory {parent_dir:?}"))?;
let mut file = smol::fs::File::create(&path)
.await
.with_context(|| format!("creating file {path:?}"))?;
futures::io::copy(entry_reader, &mut file)
.await
.with_context(|| format!("extracting into file {path:?}"))?;
}
reader = item.skip().await.context("reading next zip entry")?;
}
Ok(())
}
#[cfg(unix)]
pub async fn extract_zip<R: AsyncRead + Unpin>(destination: &Path, reader: R) -> Result<()> {
// Unix needs file permissions copied when extracting.
// This is only possible to do when a reader impls `AsyncSeek` and `seek::ZipFileReader` is used.
// `stream::ZipFileReader` also has the `unix_permissions` method, but it will always return `Some(0)`.
//
// A typical `reader` comes from a streaming network response, so cannot be sought right away,
// and reading the entire archive into the memory seems wasteful.
//
// So, save the stream into a temporary file first and then get it read with a seeking reader.
let mut file = async_fs::File::from(tempfile::tempfile().context("creating a temporary file")?);
futures::io::copy(&mut BufReader::new(reader), &mut file)
.await
.context("saving archive contents into the temporary file")?;
extract_seekable_zip(destination, file).await
}
#[cfg(unix)]
pub async fn extract_seekable_zip<R: AsyncRead + AsyncSeek + Unpin>(
destination: &Path,
reader: R,
) -> Result<()> {
let mut reader = read::seek::ZipFileReader::new(BufReader::new(reader))
.await
.context("reading the zip archive")?;
let destination = &destination
.canonicalize()
.unwrap_or_else(|_| destination.to_path_buf());
for (i, entry) in reader.file().entries().to_vec().into_iter().enumerate() {
let filename = entry
.filename()
.as_str()
.context("reading zip entry file name")?;
if !archive_path_is_normal(filename) {
continue;
}
let path = destination.join(filename);
if entry
.dir()
.with_context(|| format!("reading zip entry metadata for path {path:?}"))?
{
std::fs::create_dir_all(&path)
.with_context(|| format!("creating directory {path:?}"))?;
} else {
let parent_dir = path
.parent()
.with_context(|| format!("no parent directory for {path:?}"))?;
std::fs::create_dir_all(parent_dir)
.with_context(|| format!("creating parent directory {parent_dir:?}"))?;
let mut file = smol::fs::File::create(&path)
.await
.with_context(|| format!("creating file {path:?}"))?;
let mut entry_reader = reader
.reader_with_entry(i)
.await
.with_context(|| format!("reading entry for path {path:?}"))?;
futures::io::copy(&mut entry_reader, &mut file)
.await
.with_context(|| format!("extracting into file {path:?}"))?;
if let Some(perms) = entry.unix_permissions()
&& perms != 0o000
{
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(u32::from(perms));
file.set_permissions(permissions)
.await
.with_context(|| format!("setting permissions for file {path:?}"))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use async_zip::ZipEntryBuilder;
use async_zip::base::write::ZipFileWriter;
use futures::{AsyncSeek, AsyncWriteExt};
use smol::io::Cursor;
use tempfile::TempDir;
use super::*;
#[allow(unused_variables)]
async fn compress_zip(src_dir: &Path, dst: &Path, keep_file_permissions: bool) -> Result<()> {
let mut out = smol::fs::File::create(dst).await?;
let mut writer = ZipFileWriter::new(&mut out);
for entry in walkdir::WalkDir::new(src_dir) {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
continue;
}
let relative_path = path.strip_prefix(src_dir)?;
let data = smol::fs::read(&path).await?;
let filename = relative_path.display().to_string();
#[cfg(unix)]
{
let mut builder =
ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate);
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(path)?;
let perms = keep_file_permissions.then(|| metadata.permissions().mode() as u16);
builder = builder.unix_permissions(perms.unwrap_or_default());
writer.write_entry_whole(builder, &data).await?;
}
#[cfg(not(unix))]
{
let builder =
ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate);
writer.write_entry_whole(builder, &data).await?;
}
}
writer.close().await?;
out.flush().await?;
out.sync_all().await?;
Ok(())
}
#[track_caller]
fn assert_file_content(path: &Path, content: &str) {
assert!(path.exists(), "file not found: {:?}", path);
let actual = std::fs::read_to_string(path).unwrap();
assert_eq!(actual, content);
}
#[track_caller]
fn make_test_data() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let dst = dir.path();
std::fs::write(dst.join("test"), "Hello world.").unwrap();
std::fs::create_dir_all(dst.join("foo/bar")).unwrap();
std::fs::write(dst.join("foo/bar.txt"), "Foo bar.").unwrap();
std::fs::write(dst.join("foo/dar.md"), "Bar dar.").unwrap();
std::fs::write(dst.join("foo/bar/dar你好.txt"), "你好世界").unwrap();
dir
}
async fn read_archive(path: &Path) -> impl AsyncRead + AsyncSeek + Unpin {
let data = smol::fs::read(&path).await.unwrap();
Cursor::new(data)
}
#[test]
fn test_extract_zip() {
let test_dir = make_test_data();
let zip_file = test_dir.path().join("test.zip");
smol::block_on(async {
compress_zip(test_dir.path(), &zip_file, true)
.await
.unwrap();
let reader = read_archive(&zip_file).await;
let dir = tempfile::tempdir().unwrap();
let dst = dir.path();
extract_zip(dst, reader).await.unwrap();
assert_file_content(&dst.join("test"), "Hello world.");
assert_file_content(&dst.join("foo/bar.txt"), "Foo bar.");
assert_file_content(&dst.join("foo/dar.md"), "Bar dar.");
assert_file_content(&dst.join("foo/bar/dar你好.txt"), "你好世界");
});
}
#[cfg(unix)]
#[test]
fn test_extract_zip_preserves_executable_permissions() {
use std::os::unix::fs::PermissionsExt;
smol::block_on(async {
let test_dir = tempfile::tempdir().unwrap();
let executable_path = test_dir.path().join("my_script");
// Create an executable file
std::fs::write(&executable_path, "#!/bin/bash\necho 'Hello'").unwrap();
let mut perms = std::fs::metadata(&executable_path).unwrap().permissions();
perms.set_mode(0o755); // rwxr-xr-x
std::fs::set_permissions(&executable_path, perms).unwrap();
// Create zip
let zip_file = test_dir.path().join("test.zip");
compress_zip(test_dir.path(), &zip_file, true)
.await
.unwrap();
// Extract to new location
let extract_dir = tempfile::tempdir().unwrap();
let reader = read_archive(&zip_file).await;
extract_zip(extract_dir.path(), reader).await.unwrap();
// Check permissions are preserved
let extracted_path = extract_dir.path().join("my_script");
assert!(extracted_path.exists());
let extracted_perms = std::fs::metadata(&extracted_path).unwrap().permissions();
assert_eq!(extracted_perms.mode() & 0o777, 0o755);
});
}
#[cfg(unix)]
#[test]
fn test_extract_zip_sets_default_permissions() {
use std::os::unix::fs::PermissionsExt;
smol::block_on(async {
let test_dir = tempfile::tempdir().unwrap();
let file_path = test_dir.path().join("my_script");
std::fs::write(&file_path, "#!/bin/bash\necho 'Hello'").unwrap();
// The permissions will be shaped by the umask in the test environment
let original_perms = std::fs::metadata(&file_path).unwrap().permissions();
// Create zip
let zip_file = test_dir.path().join("test.zip");
compress_zip(test_dir.path(), &zip_file, false)
.await
.unwrap();
// Extract to new location
let extract_dir = tempfile::tempdir().unwrap();
let reader = read_archive(&zip_file).await;
extract_zip(extract_dir.path(), reader).await.unwrap();
// Permissions were not stored, so will be whatever the umask generates
// by default for new files. This should match what we saw when we previously wrote
// the file.
let extracted_path = extract_dir.path().join("my_script");
assert!(extracted_path.exists());
let extracted_perms = std::fs::metadata(&extracted_path).unwrap().permissions();
assert_eq!(
extracted_perms.mode(),
original_perms.mode(),
"Expected matching Unix file mode for unzipped file without keep_file_permissions"
);
assert_eq!(
extracted_perms, original_perms,
"Expected default set of permissions for unzipped file without keep_file_permissions"
);
});
}
#[test]
fn test_archive_path_is_normal_rejects_traversal() {
assert!(!archive_path_is_normal("../parent.txt"));
assert!(!archive_path_is_normal("foo/../../grandparent.txt"));
assert!(!archive_path_is_normal("/tmp/absolute.txt"));
assert!(archive_path_is_normal("foo/bar.txt"));
assert!(archive_path_is_normal("foo/bar/baz.txt"));
assert!(archive_path_is_normal("./foo/bar.txt"));
assert!(archive_path_is_normal("normal.txt"));
}
async fn build_zip_with_entries(entries: &[(&str, &[u8])]) -> Cursor<Vec<u8>> {
let mut buf = Cursor::new(Vec::new());
let mut writer = ZipFileWriter::new(&mut buf);
for (name, data) in entries {
let builder = ZipEntryBuilder::new((*name).into(), async_zip::Compression::Stored);
writer.write_entry_whole(builder, data).await.unwrap();
}
writer.close().await.unwrap();
buf.set_position(0);
buf
}
#[test]
fn test_extract_zip_skips_path_traversal_entries() {
smol::block_on(async {
let base_dir = tempfile::tempdir().unwrap();
let extract_dir = base_dir.path().join("subdir");
std::fs::create_dir_all(&extract_dir).unwrap();
let absolute_target = base_dir.path().join("absolute.txt");
let reader = build_zip_with_entries(&[
("normal.txt", b"normal file"),
("subdir/nested.txt", b"nested file"),
("../parent.txt", b"parent file"),
("foo/../../grandparent.txt", b"grandparent file"),
(absolute_target.to_str().unwrap(), b"absolute file"),
])
.await;
extract_zip(&extract_dir, reader).await.unwrap();
assert_file_content(&extract_dir.join("normal.txt"), "normal file");
assert_file_content(&extract_dir.join("subdir/nested.txt"), "nested file");
assert!(
!base_dir.path().join("parent.txt").exists(),
"parent traversal entry should have been skipped"
);
assert!(
!base_dir.path().join("grandparent.txt").exists(),
"nested traversal entry should have been skipped"
);
assert!(
!absolute_target.exists(),
"absolute path entry should have been skipped"
);
});
}
}

140
crates/util/src/command.rs Normal file
View File

@@ -0,0 +1,140 @@
use std::ffi::OsStr;
#[cfg(not(target_os = "macos"))]
use std::path::Path;
#[cfg(target_os = "macos")]
mod darwin;
#[cfg(target_os = "macos")]
pub use darwin::{Child, Command, Stdio};
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000_u32;
pub fn new_command(program: impl AsRef<OsStr>) -> Command {
Command::new(program)
}
#[cfg(target_os = "windows")]
pub fn new_std_command(program: impl AsRef<OsStr>) -> std::process::Command {
use std::os::windows::process::CommandExt;
let mut command = std::process::Command::new(program);
command.creation_flags(CREATE_NO_WINDOW);
command
}
#[cfg(not(target_os = "windows"))]
pub fn new_std_command(program: impl AsRef<OsStr>) -> std::process::Command {
std::process::Command::new(program)
}
#[cfg(not(target_os = "macos"))]
pub type Child = smol::process::Child;
#[cfg(not(target_os = "macos"))]
pub use std::process::Stdio;
#[cfg(not(target_os = "macos"))]
#[derive(Debug)]
pub struct Command(smol::process::Command);
#[cfg(not(target_os = "macos"))]
impl Command {
#[inline]
pub fn new(program: impl AsRef<OsStr>) -> Self {
#[cfg(target_os = "windows")]
{
use smol::process::windows::CommandExt;
let mut cmd = smol::process::Command::new(program);
cmd.creation_flags(CREATE_NO_WINDOW);
Self(cmd)
}
#[cfg(not(target_os = "windows"))]
Self(smol::process::Command::new(program))
}
pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
self.0.arg(arg);
self
}
pub fn args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.0.args(args);
self
}
pub fn get_args(&self) -> impl Iterator<Item = &OsStr> {
self.0.get_args()
}
pub fn env(&mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> &mut Self {
self.0.env(key, val);
self
}
pub fn envs<I, K, V>(&mut self, vars: I) -> &mut Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.0.envs(vars);
self
}
pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
self.0.env_remove(key);
self
}
pub fn env_clear(&mut self) -> &mut Self {
self.0.env_clear();
self
}
pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
self.0.current_dir(dir);
self
}
pub fn stdin(&mut self, cfg: impl Into<Stdio>) -> &mut Self {
self.0.stdin(cfg.into());
self
}
pub fn stdout(&mut self, cfg: impl Into<Stdio>) -> &mut Self {
self.0.stdout(cfg.into());
self
}
pub fn stderr(&mut self, cfg: impl Into<Stdio>) -> &mut Self {
self.0.stderr(cfg.into());
self
}
pub fn kill_on_drop(&mut self, kill_on_drop: bool) -> &mut Self {
self.0.kill_on_drop(kill_on_drop);
self
}
pub fn spawn(&mut self) -> std::io::Result<Child> {
self.0.spawn()
}
pub async fn output(&mut self) -> std::io::Result<std::process::Output> {
self.0.output().await
}
pub async fn status(&mut self) -> std::io::Result<std::process::ExitStatus> {
self.0.status().await
}
pub fn get_program(&self) -> &OsStr {
self.0.get_program()
}
}

View File

@@ -0,0 +1,833 @@
use mach2::exception_types::{
EXC_MASK_ALL, EXCEPTION_DEFAULT, exception_behavior_t, exception_mask_t,
};
use mach2::port::{MACH_PORT_NULL, mach_port_t};
use mach2::thread_status::{THREAD_STATE_NONE, thread_state_flavor_t};
use smol::Unblock;
use std::collections::BTreeMap;
use std::ffi::{CString, OsStr, OsString};
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::FromRawFd;
use std::os::unix::process::ExitStatusExt;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Output};
use std::ptr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Stdio {
/// A new pipe should be arranged to connect the parent and child processes.
#[default]
Piped,
/// The child inherits from the corresponding parent descriptor.
Inherit,
/// This stream will be ignored (redirected to `/dev/null`).
Null,
}
impl Stdio {
pub fn piped() -> Self {
Self::Piped
}
pub fn inherit() -> Self {
Self::Inherit
}
pub fn null() -> Self {
Self::Null
}
}
unsafe extern "C" {
fn posix_spawnattr_setexceptionports_np(
attr: *mut libc::posix_spawnattr_t,
mask: exception_mask_t,
new_port: mach_port_t,
behavior: exception_behavior_t,
new_flavor: thread_state_flavor_t,
) -> libc::c_int;
fn posix_spawn_file_actions_addchdir_np(
file_actions: *mut libc::posix_spawn_file_actions_t,
path: *const libc::c_char,
) -> libc::c_int;
fn posix_spawn_file_actions_addinherit_np(
file_actions: *mut libc::posix_spawn_file_actions_t,
filedes: libc::c_int,
) -> libc::c_int;
static environ: *const *mut libc::c_char;
}
#[derive(Debug)]
pub struct Command {
program: OsString,
args: Vec<OsString>,
envs: BTreeMap<OsString, Option<OsString>>,
env_clear: bool,
current_dir: Option<PathBuf>,
stdin_cfg: Option<Stdio>,
stdout_cfg: Option<Stdio>,
stderr_cfg: Option<Stdio>,
kill_on_drop: bool,
}
impl Command {
pub fn new(program: impl AsRef<OsStr>) -> Self {
Self {
program: program.as_ref().to_owned(),
args: Vec::new(),
envs: BTreeMap::new(),
env_clear: false,
current_dir: None,
stdin_cfg: None,
stdout_cfg: None,
stderr_cfg: None,
kill_on_drop: false,
}
}
pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
self.args.push(arg.as_ref().to_owned());
self
}
pub fn args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.args
.extend(args.into_iter().map(|a| a.as_ref().to_owned()));
self
}
pub fn get_args(&self) -> impl Iterator<Item = &OsStr> {
self.args.iter().map(|s| s.as_os_str())
}
pub fn env(&mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> &mut Self {
self.envs
.insert(key.as_ref().to_owned(), Some(val.as_ref().to_owned()));
self
}
pub fn envs<I, K, V>(&mut self, vars: I) -> &mut Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
for (key, val) in vars {
self.envs
.insert(key.as_ref().to_owned(), Some(val.as_ref().to_owned()));
}
self
}
pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
let key = key.as_ref().to_owned();
if self.env_clear {
self.envs.remove(&key);
} else {
self.envs.insert(key, None);
}
self
}
pub fn env_clear(&mut self) -> &mut Self {
self.env_clear = true;
self.envs.clear();
self
}
pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
self.current_dir = Some(dir.as_ref().to_owned());
self
}
pub fn stdin(&mut self, cfg: Stdio) -> &mut Self {
self.stdin_cfg = Some(cfg);
self
}
pub fn stdout(&mut self, cfg: Stdio) -> &mut Self {
self.stdout_cfg = Some(cfg);
self
}
pub fn stderr(&mut self, cfg: Stdio) -> &mut Self {
self.stderr_cfg = Some(cfg);
self
}
pub fn kill_on_drop(&mut self, kill_on_drop: bool) -> &mut Self {
self.kill_on_drop = kill_on_drop;
self
}
pub fn spawn(&mut self) -> io::Result<Child> {
let current_dir = self
.current_dir
.as_deref()
.unwrap_or_else(|| Path::new("."));
// Optimization: if no environment modifications were requested, pass None
// to spawn_posix so it uses the `environ` global directly, avoiding a
// full copy of the environment. This matches std::process::Command behavior.
let envs = if self.env_clear || !self.envs.is_empty() {
let mut result = BTreeMap::<OsString, OsString>::new();
if !self.env_clear {
for (key, val) in std::env::vars_os() {
result.insert(key, val);
}
}
for (key, maybe_val) in &self.envs {
if let Some(val) = maybe_val {
result.insert(key.clone(), val.clone());
} else {
result.remove(key);
}
}
Some(result.into_iter().collect::<Vec<_>>())
} else {
None
};
spawn_posix_spawn(
&self.program,
&self.args,
current_dir,
envs.as_deref(),
self.stdin_cfg.unwrap_or_default(),
self.stdout_cfg.unwrap_or_default(),
self.stderr_cfg.unwrap_or_default(),
self.kill_on_drop,
)
}
pub async fn output(&mut self) -> io::Result<Output> {
self.stdin_cfg.get_or_insert(Stdio::null());
self.stdout_cfg.get_or_insert(Stdio::piped());
self.stderr_cfg.get_or_insert(Stdio::piped());
let child = self.spawn()?;
child.output().await
}
pub async fn status(&mut self) -> io::Result<ExitStatus> {
let mut child = self.spawn()?;
child.status().await
}
pub fn get_program(&self) -> &OsStr {
self.program.as_os_str()
}
}
#[derive(Debug)]
pub struct Child {
pid: libc::pid_t,
pub stdin: Option<Unblock<std::fs::File>>,
pub stdout: Option<Unblock<std::fs::File>>,
pub stderr: Option<Unblock<std::fs::File>>,
kill_on_drop: bool,
status: Option<ExitStatus>,
}
impl Drop for Child {
fn drop(&mut self) {
if self.kill_on_drop && self.status.is_none() {
let _ = self.kill();
}
}
}
impl Child {
pub fn id(&self) -> u32 {
self.pid as u32
}
pub fn kill(&mut self) -> io::Result<()> {
let result = unsafe { libc::kill(self.pid, libc::SIGKILL) };
if result == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
pub fn try_status(&mut self) -> io::Result<Option<ExitStatus>> {
if let Some(status) = self.status {
return Ok(Some(status));
}
let mut status: libc::c_int = 0;
let result = unsafe { libc::waitpid(self.pid, &mut status, libc::WNOHANG) };
if result == -1 {
Err(io::Error::last_os_error())
} else if result == 0 {
Ok(None)
} else {
let exit_status = ExitStatus::from_raw(status);
self.status = Some(exit_status);
Ok(Some(exit_status))
}
}
pub fn status(
&mut self,
) -> impl std::future::Future<Output = io::Result<ExitStatus>> + Send + 'static {
self.stdin.take();
let pid = self.pid;
let cached_status = self.status;
async move {
if let Some(status) = cached_status {
return Ok(status);
}
smol::unblock(move || {
let mut status: libc::c_int = 0;
let result = unsafe { libc::waitpid(pid, &mut status, 0) };
if result == -1 {
Err(io::Error::last_os_error())
} else {
Ok(ExitStatus::from_raw(status))
}
})
.await
}
}
pub async fn output(mut self) -> io::Result<Output> {
use futures_lite::AsyncReadExt;
let status = self.status();
let stdout = self.stdout.take();
let stdout_future = async move {
let mut data = Vec::new();
if let Some(mut stdout) = stdout {
stdout.read_to_end(&mut data).await?;
}
io::Result::Ok(data)
};
let stderr = self.stderr.take();
let stderr_future = async move {
let mut data = Vec::new();
if let Some(mut stderr) = stderr {
stderr.read_to_end(&mut data).await?;
}
io::Result::Ok(data)
};
let (stdout_data, stderr_data) =
futures_lite::future::try_zip(stdout_future, stderr_future).await?;
let status = status.await?;
Ok(Output {
status,
stdout: stdout_data,
stderr: stderr_data,
})
}
}
fn spawn_posix_spawn(
program: &OsStr,
args: &[OsString],
current_dir: &Path,
envs: Option<&[(OsString, OsString)]>,
stdin_cfg: Stdio,
stdout_cfg: Stdio,
stderr_cfg: Stdio,
kill_on_drop: bool,
) -> io::Result<Child> {
let program_cstr = CString::new(program.as_bytes()).map_err(|_| invalid_input_error())?;
let current_dir_cstr =
CString::new(current_dir.as_os_str().as_bytes()).map_err(|_| invalid_input_error())?;
let mut argv_cstrs = vec![program_cstr.clone()];
for arg in args {
let cstr = CString::new(arg.as_bytes()).map_err(|_| invalid_input_error())?;
argv_cstrs.push(cstr);
}
let mut argv_ptrs: Vec<*mut libc::c_char> = argv_cstrs
.iter()
.map(|s| s.as_ptr() as *mut libc::c_char)
.collect();
argv_ptrs.push(ptr::null_mut());
let envp: Vec<CString> = if let Some(envs) = envs {
envs.iter()
.map(|(key, value)| {
let mut env_str = key.as_bytes().to_vec();
env_str.push(b'=');
env_str.extend_from_slice(value.as_bytes());
CString::new(env_str)
})
.collect::<Result<Vec<_>, _>>()
.map_err(|_| invalid_input_error())?
} else {
Vec::new()
};
let mut envp_ptrs: Vec<*mut libc::c_char> = envp
.iter()
.map(|s| s.as_ptr() as *mut libc::c_char)
.collect();
envp_ptrs.push(ptr::null_mut());
let (stdin_read, stdin_write) = match stdin_cfg {
Stdio::Piped => {
let (r, w) = create_pipe()?;
(Some(r), Some(w))
}
Stdio::Null => {
let fd = open_dev_null(libc::O_RDONLY)?;
(Some(fd), None)
}
Stdio::Inherit => (None, None),
};
let (stdout_read, stdout_write) = match stdout_cfg {
Stdio::Piped => {
let (r, w) = create_pipe()?;
(Some(r), Some(w))
}
Stdio::Null => {
let fd = open_dev_null(libc::O_WRONLY)?;
(None, Some(fd))
}
Stdio::Inherit => (None, None),
};
let (stderr_read, stderr_write) = match stderr_cfg {
Stdio::Piped => {
let (r, w) = create_pipe()?;
(Some(r), Some(w))
}
Stdio::Null => {
let fd = open_dev_null(libc::O_WRONLY)?;
(None, Some(fd))
}
Stdio::Inherit => (None, None),
};
let mut attr: libc::posix_spawnattr_t = ptr::null_mut();
let mut file_actions: libc::posix_spawn_file_actions_t = ptr::null_mut();
unsafe {
cvt_nz(libc::posix_spawnattr_init(&mut attr))?;
cvt_nz(libc::posix_spawn_file_actions_init(&mut file_actions))?;
cvt_nz(libc::posix_spawnattr_setflags(
&mut attr,
libc::POSIX_SPAWN_CLOEXEC_DEFAULT as libc::c_short,
))?;
cvt_nz(posix_spawnattr_setexceptionports_np(
&mut attr,
EXC_MASK_ALL,
MACH_PORT_NULL,
EXCEPTION_DEFAULT as exception_behavior_t,
THREAD_STATE_NONE,
))?;
cvt_nz(posix_spawn_file_actions_addchdir_np(
&mut file_actions,
current_dir_cstr.as_ptr(),
))?;
if let Some(fd) = stdin_read {
cvt_nz(libc::posix_spawn_file_actions_adddup2(
&mut file_actions,
fd,
libc::STDIN_FILENO,
))?;
cvt_nz(posix_spawn_file_actions_addinherit_np(
&mut file_actions,
libc::STDIN_FILENO,
))?;
}
if let Some(fd) = stdout_write {
cvt_nz(libc::posix_spawn_file_actions_adddup2(
&mut file_actions,
fd,
libc::STDOUT_FILENO,
))?;
cvt_nz(posix_spawn_file_actions_addinherit_np(
&mut file_actions,
libc::STDOUT_FILENO,
))?;
}
if let Some(fd) = stderr_write {
cvt_nz(libc::posix_spawn_file_actions_adddup2(
&mut file_actions,
fd,
libc::STDERR_FILENO,
))?;
cvt_nz(posix_spawn_file_actions_addinherit_np(
&mut file_actions,
libc::STDERR_FILENO,
))?;
}
let mut pid: libc::pid_t = 0;
let spawn_result = libc::posix_spawnp(
&mut pid,
program_cstr.as_ptr(),
&file_actions,
&attr,
argv_ptrs.as_ptr(),
if envs.is_some() {
envp_ptrs.as_ptr()
} else {
environ
},
);
libc::posix_spawnattr_destroy(&mut attr);
libc::posix_spawn_file_actions_destroy(&mut file_actions);
if let Some(fd) = stdin_read {
libc::close(fd);
}
if let Some(fd) = stdout_write {
libc::close(fd);
}
if let Some(fd) = stderr_write {
libc::close(fd);
}
cvt_nz(spawn_result)?;
Ok(Child {
pid,
stdin: stdin_write.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))),
stdout: stdout_read.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))),
stderr: stderr_read.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))),
kill_on_drop,
status: None,
})
}
}
fn create_pipe() -> io::Result<(libc::c_int, libc::c_int)> {
let mut fds: [libc::c_int; 2] = [0; 2];
let result = unsafe { libc::pipe(fds.as_mut_ptr()) };
if result == -1 {
return Err(io::Error::last_os_error());
}
Ok((fds[0], fds[1]))
}
fn open_dev_null(flags: libc::c_int) -> io::Result<libc::c_int> {
let fd = unsafe { libc::open(c"/dev/null".as_ptr() as *const libc::c_char, flags) };
if fd == -1 {
return Err(io::Error::last_os_error());
}
Ok(fd)
}
/// Zero means `Ok()`, all other values are treated as raw OS errors. Does not look at `errno`.
/// Mirrored after Rust's std `cvt_nz` function.
fn cvt_nz(error: libc::c_int) -> io::Result<()> {
if error == 0 {
Ok(())
} else {
Err(io::Error::from_raw_os_error(error))
}
}
fn invalid_input_error() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
"invalid argument: path or argument contains null byte",
)
}
#[cfg(test)]
mod tests {
use super::*;
use futures_lite::AsyncWriteExt;
#[test]
fn test_spawn_echo() {
smol::block_on(async {
let output = Command::new("/bin/echo")
.args(["-n", "hello world"])
.output()
.await
.expect("failed to run command");
assert!(output.status.success());
assert_eq!(output.stdout, b"hello world");
});
}
#[test]
fn test_spawn_cat_stdin() {
smol::block_on(async {
let mut child = Command::new("/bin/cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("failed to spawn");
if let Some(ref mut stdin) = child.stdin {
stdin
.write_all(b"hello from stdin")
.await
.expect("failed to write");
stdin.close().await.expect("failed to close");
}
drop(child.stdin.take());
let output = child.output().await.expect("failed to get output");
assert!(output.status.success());
assert_eq!(output.stdout, b"hello from stdin");
});
}
#[test]
fn test_spawn_stderr() {
smol::block_on(async {
let output = Command::new("/bin/sh")
.args(["-c", "echo error >&2"])
.output()
.await
.expect("failed to run command");
assert!(output.status.success());
assert_eq!(output.stderr, b"error\n");
});
}
#[test]
fn test_spawn_exit_code() {
smol::block_on(async {
let output = Command::new("/bin/sh")
.args(["-c", "exit 42"])
.output()
.await
.expect("failed to run command");
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(42));
});
}
#[test]
fn test_spawn_current_dir() {
smol::block_on(async {
let output = Command::new("/bin/pwd")
.current_dir("/tmp")
.output()
.await
.expect("failed to run command");
assert!(output.status.success());
let pwd = String::from_utf8_lossy(&output.stdout);
assert!(pwd.trim() == "/tmp" || pwd.trim() == "/private/tmp");
});
}
#[test]
fn test_spawn_env() {
smol::block_on(async {
let output = Command::new("/bin/sh")
.args(["-c", "echo $MY_TEST_VAR"])
.env("MY_TEST_VAR", "test_value")
.output()
.await
.expect("failed to run command");
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test_value");
});
}
#[test]
fn test_spawn_status() {
smol::block_on(async {
let status = Command::new("/usr/bin/true")
.status()
.await
.expect("failed to run command");
assert!(status.success());
let status = Command::new("/usr/bin/false")
.status()
.await
.expect("failed to run command");
assert!(!status.success());
});
}
#[test]
fn test_env_remove_removes_set_env() {
smol::block_on(async {
let output = Command::new("/bin/sh")
.args(["-c", "echo ${MY_VAR:-unset}"])
.env("MY_VAR", "set_value")
.env_remove("MY_VAR")
.output()
.await
.expect("failed to run command");
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "unset");
});
}
#[test]
fn test_env_remove_removes_inherited_env() {
smol::block_on(async {
// SAFETY: This test is single-threaded and we clean up the var at the end
unsafe { std::env::set_var("TEST_INHERITED_VAR", "inherited_value") };
let output = Command::new("/bin/sh")
.args(["-c", "echo ${TEST_INHERITED_VAR:-unset}"])
.env_remove("TEST_INHERITED_VAR")
.output()
.await
.expect("failed to run command");
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "unset");
// SAFETY: Cleaning up test env var
unsafe { std::env::remove_var("TEST_INHERITED_VAR") };
});
}
#[test]
fn test_env_after_env_remove() {
smol::block_on(async {
let output = Command::new("/bin/sh")
.args(["-c", "echo ${MY_VAR:-unset}"])
.env_remove("MY_VAR")
.env("MY_VAR", "new_value")
.output()
.await
.expect("failed to run command");
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "new_value");
});
}
#[test]
fn test_env_remove_after_env_clear() {
smol::block_on(async {
let output = Command::new("/bin/sh")
.args(["-c", "echo ${MY_VAR:-unset}"])
.env_clear()
.env("MY_VAR", "set_value")
.env_remove("MY_VAR")
.output()
.await
.expect("failed to run command");
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "unset");
});
}
#[test]
fn test_stdio_null_stdin() {
smol::block_on(async {
let child = Command::new("/bin/cat")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.spawn()
.expect("failed to spawn");
let output = child.output().await.expect("failed to get output");
assert!(output.status.success());
assert!(
output.stdout.is_empty(),
"stdin from /dev/null should produce no output from cat"
);
});
}
#[test]
fn test_stdio_null_stdout() {
smol::block_on(async {
let mut child = Command::new("/bin/echo")
.args(["hello"])
.stdout(Stdio::null())
.spawn()
.expect("failed to spawn");
assert!(
child.stdout.is_none(),
"stdout should be None when Stdio::null() is used"
);
let status = child.status().await.expect("failed to get status");
assert!(status.success());
});
}
#[test]
fn test_stdio_null_stderr() {
smol::block_on(async {
let mut child = Command::new("/bin/sh")
.args(["-c", "echo error >&2"])
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn");
assert!(
child.stderr.is_none(),
"stderr should be None when Stdio::null() is used"
);
let status = child.status().await.expect("failed to get status");
assert!(status.success());
});
}
#[test]
fn test_stdio_piped_stdin() {
smol::block_on(async {
let mut child = Command::new("/bin/cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("failed to spawn");
assert!(
child.stdin.is_some(),
"stdin should be Some when Stdio::piped() is used"
);
if let Some(ref mut stdin) = child.stdin {
stdin
.write_all(b"piped input")
.await
.expect("failed to write");
stdin.close().await.expect("failed to close");
}
drop(child.stdin.take());
let output = child.output().await.expect("failed to get output");
assert!(output.status.success());
assert_eq!(output.stdout, b"piped input");
});
}
}

View File

@@ -0,0 +1,202 @@
use std::collections::HashMap;
use std::hash::Hash;
/// Computes the minimum detail level needed for each item so that no two items
/// share the same description. Items whose descriptions are unique at level 0
/// stay at 0; items that collide get their detail level incremented until either
/// the collision is resolved or increasing the level no longer changes the
/// description (preventing infinite loops for truly identical items).
///
/// The `get_description` closure must return a sequence that eventually reaches
/// a "fixed point" where increasing `detail` no longer changes the output. If
/// an item reaches its fixed point, it is assumed it will no longer change and
/// will no longer be checked for collisions.
pub fn compute_disambiguation_details<T, D>(
items: &[T],
get_description: impl Fn(&T, usize) -> D,
) -> Vec<usize>
where
D: Eq + Hash + Clone,
{
let mut details = vec![0usize; items.len()];
let mut descriptions: HashMap<D, Vec<usize>> = HashMap::default();
let mut current_descriptions: Vec<D> =
items.iter().map(|item| get_description(item, 0)).collect();
loop {
let mut any_collisions = false;
for (index, (item, &detail)) in items.iter().zip(&details).enumerate() {
if detail > 0 {
let new_description = get_description(item, detail);
if new_description == current_descriptions[index] {
continue;
}
current_descriptions[index] = new_description;
}
descriptions
.entry(current_descriptions[index].clone())
.or_insert_with(Vec::new)
.push(index);
}
for (_, indices) in descriptions.drain() {
if indices.len() > 1 {
any_collisions = true;
for index in indices {
details[index] += 1;
}
}
}
if !any_collisions {
break;
}
}
details
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_conflicts() {
let items = vec!["alpha", "beta", "gamma"];
let details = compute_disambiguation_details(&items, |item, _detail| item.to_string());
assert_eq!(details, vec![0, 0, 0]);
}
#[test]
fn test_simple_two_way_conflict() {
// Two items with the same base name but different parents.
let items = vec![("src/foo.rs", "foo.rs"), ("lib/foo.rs", "foo.rs")];
let details = compute_disambiguation_details(&items, |item, detail| match detail {
0 => item.1.to_string(),
_ => item.0.to_string(),
});
assert_eq!(details, vec![1, 1]);
}
#[test]
fn test_three_way_conflict() {
let items = vec![
("foo.rs", "a/foo.rs"),
("foo.rs", "b/foo.rs"),
("foo.rs", "c/foo.rs"),
];
let details = compute_disambiguation_details(&items, |item, detail| match detail {
0 => item.0.to_string(),
_ => item.1.to_string(),
});
assert_eq!(details, vec![1, 1, 1]);
}
#[test]
fn test_deeper_conflict() {
// At detail 0, all three show "file.rs".
// At detail 1, items 0 and 1 both show "src/file.rs", item 2 shows "lib/file.rs".
// At detail 2, item 0 shows "a/src/file.rs", item 1 shows "b/src/file.rs".
let items = vec![
vec!["file.rs", "src/file.rs", "a/src/file.rs"],
vec!["file.rs", "src/file.rs", "b/src/file.rs"],
vec!["file.rs", "lib/file.rs", "x/lib/file.rs"],
];
let details = compute_disambiguation_details(&items, |item, detail| {
let clamped = detail.min(item.len() - 1);
item[clamped].to_string()
});
assert_eq!(details, vec![2, 2, 1]);
}
#[test]
fn test_mixed_conflicting_and_unique() {
let items = vec![
("src/foo.rs", "foo.rs"),
("lib/foo.rs", "foo.rs"),
("src/bar.rs", "bar.rs"),
];
let details = compute_disambiguation_details(&items, |item, detail| match detail {
0 => item.1.to_string(),
_ => item.0.to_string(),
});
assert_eq!(details, vec![1, 1, 0]);
}
#[test]
fn test_identical_items_terminates() {
// All items return the same description at every detail level.
// The algorithm must terminate rather than looping forever.
let items = vec!["same", "same", "same"];
let details = compute_disambiguation_details(&items, |item, _detail| item.to_string());
// After bumping to 1, the description doesn't change from level 0,
// so the items are skipped and the loop terminates.
assert_eq!(details, vec![1, 1, 1]);
}
#[test]
fn test_single_item() {
let items = vec!["only"];
let details = compute_disambiguation_details(&items, |item, _detail| item.to_string());
assert_eq!(details, vec![0]);
}
#[test]
fn test_empty_input() {
let items: Vec<&str> = vec![];
let details = compute_disambiguation_details(&items, |item, _detail| item.to_string());
let expected: Vec<usize> = vec![];
assert_eq!(details, expected);
}
#[test]
fn test_duplicate_paths_from_multiple_groups() {
use std::path::Path;
// Simulates the sidebar scenario: a path like /Users/rtfeldman/code/zed
// appears in two project groups (e.g. "zed" alone and "zed, roc").
// After deduplication, only unique paths should be disambiguated.
//
// Paths:
// /Users/rtfeldman/code/worktrees/zed/focal-arrow/zed (group 1)
// /Users/rtfeldman/code/zed (group 2)
// /Users/rtfeldman/code/zed (group 3, same path as group 2)
// /Users/rtfeldman/code/roc (group 3)
//
// A naive flat_map collects duplicates. The duplicate /code/zed entries
// collide with each other and drive the detail to the full path.
// The fix is to deduplicate before disambiguating.
fn path_suffix(path: &Path, detail: usize) -> String {
let mut components: Vec<_> = path
.components()
.rev()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy()),
_ => None,
})
.take(detail + 1)
.collect();
components.reverse();
components.join("/")
}
let all_paths: Vec<&Path> = vec![
Path::new("/Users/rtfeldman/code/worktrees/zed/focal-arrow/zed"),
Path::new("/Users/rtfeldman/code/zed"),
Path::new("/Users/rtfeldman/code/roc"),
];
let details =
compute_disambiguation_details(&all_paths, |path, detail| path_suffix(path, detail));
// focal-arrow/zed and code/zed both end in "zed", so they need detail 1.
// "roc" is unique at detail 0.
assert_eq!(details, vec![1, 1, 0]);
assert_eq!(path_suffix(all_paths[0], details[0]), "focal-arrow/zed");
assert_eq!(path_suffix(all_paths[1], details[1]), "code/zed");
assert_eq!(path_suffix(all_paths[2], details[2]), "roc");
}
}

111
crates/util/src/fs.rs Normal file
View File

@@ -0,0 +1,111 @@
use crate::ResultExt;
use anyhow::{Result, bail};
use async_fs as fs;
use futures_lite::StreamExt;
use std::path::{Path, PathBuf};
/// Removes all files and directories matching the given predicate
pub async fn remove_matching<F>(dir: &Path, predicate: F)
where
F: Fn(&Path) -> bool,
{
if let Some(mut entries) = fs::read_dir(dir).await.log_err() {
while let Some(entry) = entries.next().await {
if let Some(entry) = entry.log_err() {
let entry_path = entry.path();
if predicate(entry_path.as_path())
&& let Ok(metadata) = fs::metadata(&entry_path).await
{
if metadata.is_file() {
fs::remove_file(&entry_path).await.log_err();
} else {
fs::remove_dir_all(&entry_path).await.log_err();
}
}
}
}
}
}
pub async fn collect_matching<F>(dir: &Path, predicate: F) -> Vec<PathBuf>
where
F: Fn(&Path) -> bool,
{
let mut matching = vec![];
if let Some(mut entries) = fs::read_dir(dir).await.log_err() {
while let Some(entry) = entries.next().await {
if let Some(entry) = entry.log_err()
&& predicate(entry.path().as_path())
{
matching.push(entry.path());
}
}
}
matching
}
pub async fn find_file_name_in_dir<F>(dir: &Path, predicate: F) -> Option<PathBuf>
where
F: Fn(&str) -> bool,
{
if let Some(mut entries) = fs::read_dir(dir).await.log_err() {
while let Some(entry) = entries.next().await {
if let Some(entry) = entry.log_err() {
let entry_path = entry.path();
if let Some(file_name) = entry_path
.file_name()
.map(|file_name| file_name.to_string_lossy())
&& predicate(&file_name)
{
return Some(entry_path);
}
}
}
}
None
}
pub async fn move_folder_files_to_folder<P: AsRef<Path>>(
source_path: P,
target_path: P,
) -> Result<()> {
if !target_path.as_ref().is_dir() {
bail!("Folder not found or is not a directory");
}
let mut entries = fs::read_dir(source_path.as_ref()).await?;
while let Some(entry) = entries.next().await {
let entry = entry?;
let old_path = entry.path();
let new_path = target_path.as_ref().join(entry.file_name());
fs::rename(&old_path, &new_path).await?;
}
fs::remove_dir(source_path).await?;
Ok(())
}
#[cfg(unix)]
/// Set the permissions for the given path so that the file becomes executable.
/// This is a noop for non-unix platforms.
pub async fn make_file_executable(path: &Path) -> std::io::Result<()> {
fs::set_permissions(
path,
<fs::Permissions as fs::unix::PermissionsExt>::from_mode(0o755),
)
.await
}
#[cfg(not(unix))]
#[allow(clippy::unused_async)]
/// Set the permissions for the given path so that the file becomes executable.
/// This is a noop for non-unix platforms.
pub async fn make_file_executable(_path: &Path) -> std::io::Result<()> {
Ok(())
}

376
crates/util/src/markdown.rs Normal file
View File

@@ -0,0 +1,376 @@
use std::fmt::{Display, Formatter};
/// Generates a URL-friendly slug from heading text (e.g. "Hello World" → "hello-world").
pub fn generate_heading_slug(text: &str) -> String {
text.trim()
.chars()
.filter_map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
Some(c.to_lowercase().next().unwrap_or(c))
} else if c == ' ' {
Some('-')
} else {
None
}
})
.collect()
}
/// Returns true if the URL starts with a URI scheme (RFC 3986 §3.1).
fn has_uri_scheme(url: &str) -> bool {
let mut chars = url.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
for c in chars {
if c == ':' {
return true;
}
if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
return false;
}
}
false
}
/// Splits a relative URL into its path and `#fragment` parts.
/// Absolute URLs are returned as-is with no fragment.
pub fn split_local_url_fragment(url: &str) -> (&str, Option<&str>) {
if has_uri_scheme(url) {
return (url, None);
}
match url.find('#') {
Some(pos) => {
let path = &url[..pos];
let fragment = &url[pos + 1..];
(
path,
if fragment.is_empty() {
None
} else {
Some(fragment)
},
)
}
None => (url, None),
}
}
/// Indicates that the wrapped `String` is markdown text.
#[derive(Debug, Clone)]
pub struct MarkdownString(pub String);
impl Display for MarkdownString {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Escapes markdown special characters in markdown text blocks. Markdown code blocks follow
/// different rules and `MarkdownInlineCode` or `MarkdownCodeBlock` should be used in that case.
///
/// Also escapes the following markdown extensions:
///
/// * `^` for superscripts
/// * `$` for inline math
/// * `~` for strikethrough
///
/// Escape of some characters is unnecessary, because while they are involved in markdown syntax,
/// the other characters involved are escaped:
///
/// * `!`, `]`, `(`, and `)` are used in link syntax, but `[` is escaped so these are parsed as
/// plaintext.
///
/// * `;` is used in HTML entity syntax, but `&` is escaped, so they are parsed as plaintext.
///
/// TODO: There is one escape this doesn't do currently. Period after numbers at the start of the
/// line (`[0-9]*\.`) should also be escaped to avoid it being interpreted as a list item.
pub struct MarkdownEscaped<'a>(pub &'a str);
/// Implements `Display` to format markdown inline code (wrapped in backticks), handling code that
/// contains backticks and spaces. All whitespace is treated as a single space character. For text
/// that does not contain whitespace other than ' ', this escaping roundtrips through
/// pulldown-cmark.
///
/// When used in tables, `|` should be escaped like `\|` in the text provided to this function.
pub struct MarkdownInlineCode<'a>(pub &'a str);
/// Implements `Display` to format markdown code blocks, wrapped in 3 or more backticks as needed.
pub struct MarkdownCodeBlock<'a> {
pub tag: &'a str,
pub text: &'a str,
}
impl Display for MarkdownEscaped<'_> {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
let mut start_of_unescaped = None;
for (ix, c) in self.0.char_indices() {
match c {
// Always escaped.
'\\' | '`' | '*' | '_' | '[' | '^' | '$' | '~' | '&' |
// TODO: these only need to be escaped when they are the first non-whitespace
// character of the line of a block. There should probably be both an `escape_block`
// which does this and an `escape_inline` method which does not escape these.
'#' | '+' | '=' | '-' => {
match start_of_unescaped {
None => {}
Some(start_of_unescaped) => {
write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
}
}
write!(formatter, "\\")?;
// Can include this char in the "unescaped" text since a
// backslash was just emitted.
start_of_unescaped = Some(ix);
}
// Escaped since `<` is used in opening HTML tags. `&lt;` is used since Markdown
// supports HTML entities, and this allows the text to be used directly in HTML.
'<' => {
match start_of_unescaped {
None => {}
Some(start_of_unescaped) => {
write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
}
}
write!(formatter, "&lt;")?;
start_of_unescaped = None;
}
// Escaped since `>` is used for blockquotes. `&gt;` is used since Markdown supports
// HTML entities, and this allows the text to be used directly in HTML.
'>' => {
match start_of_unescaped {
None => {}
Some(start_of_unescaped) => {
write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
}
}
write!(formatter, "&gt;")?;
start_of_unescaped = None;
}
_ => {
if start_of_unescaped.is_none() {
start_of_unescaped = Some(ix);
}
}
}
}
if let Some(start_of_unescaped) = start_of_unescaped {
write!(formatter, "{}", &self.0[start_of_unescaped..])?;
}
Ok(())
}
}
impl Display for MarkdownInlineCode<'_> {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
// Apache License 2.0, same as this crate.
//
// Copied from `pulldown-cmark-to-cmark-20.0.0` with modifications:
//
// * Handling of all whitespace. pulldown-cmark-to-cmark is anticipating
// `Code` events parsed by pulldown-cmark.
//
// https://github.com/Byron/pulldown-cmark-to-cmark/blob/3c850de2d3d1d79f19ca5f375e1089a653cf3ff7/src/lib.rs#L290
let mut all_whitespace = true;
let text = self
.0
.chars()
.map(|c| {
if c.is_whitespace() {
' '
} else {
all_whitespace = false;
c
}
})
.collect::<String>();
// When inline code has leading and trailing ' ' characters, additional space is needed
// to escape it, unless all characters are space.
if all_whitespace {
write!(formatter, "`{text}`")
} else {
// More backticks are needed to delimit the inline code than the maximum number of
// backticks in a consecutive run.
let backticks = "`".repeat(count_max_consecutive_chars(&text, '`') + 1);
let space = match text.as_bytes() {
&[b'`', ..] | &[.., b'`'] => " ", // Space needed to separate backtick.
&[b' ', .., b' '] => " ", // Space needed to escape inner space.
_ => "", // No space needed.
};
write!(formatter, "{backticks}{space}{text}{space}{backticks}")
}
}
}
impl Display for MarkdownCodeBlock<'_> {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
let tag = self.tag;
let text = self.text;
let backticks = "`".repeat(3.max(count_max_consecutive_chars(text, '`') + 1));
write!(formatter, "{backticks}{tag}\n{text}\n{backticks}\n")
}
}
// Copied from `pulldown-cmark-to-cmark-20.0.0` with changed names.
// https://github.com/Byron/pulldown-cmark-to-cmark/blob/3c850de2d3d1d79f19ca5f375e1089a653cf3ff7/src/lib.rs#L1063
// Apache License 2.0, same as this code.
fn count_max_consecutive_chars(text: &str, search: char) -> usize {
let mut in_search_chars = false;
let mut max_count = 0;
let mut cur_count = 0;
for ch in text.chars() {
if ch == search {
cur_count += 1;
in_search_chars = true;
} else if in_search_chars {
max_count = max_count.max(cur_count);
cur_count = 0;
in_search_chars = false;
}
}
max_count.max(cur_count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_markdown_escaped() {
let input = r#"
# Heading
Another heading
===
Another heading variant
---
Paragraph with [link](https://example.com) and `code`, *emphasis*, and ~strikethrough~.
```
code block
```
List with varying leaders:
- Item 1
* Item 2
+ Item 3
Some math: $`\sqrt{3x-1}+(1+x)^2`$
HTML entity: &nbsp;
"#;
let expected = r#"
\# Heading
Another heading
\=\=\=
Another heading variant
\-\-\-
Paragraph with \[link](https://example.com) and \`code\`, \*emphasis\*, and \~strikethrough\~.
\`\`\`
code block
\`\`\`
List with varying leaders:
\- Item 1
\* Item 2
\+ Item 3
Some math: \$\`\\sqrt{3x\-1}\+(1\+x)\^2\`\$
HTML entity: \&nbsp;
"#;
assert_eq!(MarkdownEscaped(input).to_string(), expected);
}
#[test]
fn test_markdown_inline_code() {
assert_eq!(MarkdownInlineCode(" ").to_string(), "` `");
assert_eq!(MarkdownInlineCode("text").to_string(), "`text`");
assert_eq!(MarkdownInlineCode("text ").to_string(), "`text `");
assert_eq!(MarkdownInlineCode(" text ").to_string(), "` text `");
assert_eq!(MarkdownInlineCode("`").to_string(), "`` ` ``");
assert_eq!(MarkdownInlineCode("``").to_string(), "``` `` ```");
assert_eq!(MarkdownInlineCode("`text`").to_string(), "`` `text` ``");
assert_eq!(
MarkdownInlineCode("some `text` no leading or trailing backticks").to_string(),
"``some `text` no leading or trailing backticks``"
);
}
#[test]
fn test_count_max_consecutive_chars() {
assert_eq!(
count_max_consecutive_chars("``a```b``", '`'),
3,
"the highest seen consecutive segment of backticks counts"
);
assert_eq!(
count_max_consecutive_chars("```a``b`", '`'),
3,
"it can't be downgraded later"
);
}
#[test]
fn test_split_local_url_fragment() {
assert_eq!(split_local_url_fragment("#heading"), ("", Some("heading")));
assert_eq!(
split_local_url_fragment("./file.md#heading"),
("./file.md", Some("heading"))
);
assert_eq!(split_local_url_fragment("./file.md"), ("./file.md", None));
assert_eq!(
split_local_url_fragment("https://example.com#frag"),
("https://example.com#frag", None)
);
assert_eq!(
split_local_url_fragment("mailto:user@example.com"),
("mailto:user@example.com", None)
);
assert_eq!(split_local_url_fragment("#"), ("", None));
assert_eq!(
split_local_url_fragment("../other.md#section"),
("../other.md", Some("section"))
);
assert_eq!(
split_local_url_fragment("123:not-a-scheme#frag"),
("123:not-a-scheme", Some("frag"))
);
}
#[test]
fn test_generate_heading_slug() {
assert_eq!(generate_heading_slug("Hello World"), "hello-world");
assert_eq!(generate_heading_slug("Hello World"), "hello--world");
assert_eq!(generate_heading_slug("Hello-World"), "hello-world");
assert_eq!(
generate_heading_slug("Some **bold** text"),
"some-bold-text"
);
assert_eq!(generate_heading_slug("Let's try with Ü"), "lets-try-with-ü");
assert_eq!(
generate_heading_slug("heading with 123 numbers"),
"heading-with-123-numbers"
);
assert_eq!(
generate_heading_slug("What about (parens)?"),
"what-about-parens"
);
assert_eq!(
generate_heading_slug(" leading spaces "),
"leading-spaces"
);
}
}

View File

@@ -0,0 +1,233 @@
use std::{
hash::{Hash, Hasher},
path::{Path, PathBuf},
sync::Arc,
};
use crate::paths::SanitizedPath;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
/// A list of absolute paths, with an associated display order.
///
/// Two `PathList` values are considered equal if they contain the same paths,
/// regardless of the order in which those paths were originally provided.
///
/// The paths can be retrieved in the original order using `ordered_paths()`.
#[derive(Default, Debug, Clone)]
pub struct PathList {
/// The paths, in lexicographic order.
paths: Arc<[PathBuf]>,
/// The order in which the paths were provided.
///
/// See `ordered_paths()` for a way to get the paths in the original order.
order: Arc<[usize]>,
}
impl PartialEq for PathList {
fn eq(&self, other: &Self) -> bool {
self.paths == other.paths
}
}
impl Eq for PathList {}
impl Hash for PathList {
fn hash<H: Hasher>(&self, state: &mut H) {
self.paths.hash(state);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedPathList {
pub paths: String,
pub order: String,
}
impl PathList {
pub fn new<P: AsRef<Path>>(paths: &[P]) -> Self {
let mut indexed_paths: Vec<(usize, PathBuf)> = paths
.iter()
.enumerate()
.map(|(ix, path)| (ix, SanitizedPath::new(path).into()))
.collect();
indexed_paths.sort_by(|(_, a), (_, b)| a.cmp(b));
let order = indexed_paths.iter().map(|e| e.0).collect::<Vec<_>>().into();
let paths = indexed_paths
.into_iter()
.map(|e| e.1)
.collect::<Vec<_>>()
.into();
Self { order, paths }
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
/// Returns a new `PathList` with the given path removed.
pub fn without_path(&self, path_to_remove: &Path) -> PathList {
let paths: Vec<PathBuf> = self
.ordered_paths()
.filter(|p| p.as_path() != path_to_remove)
.cloned()
.collect();
PathList::new(&paths)
}
/// Get the paths in lexicographic order.
pub fn paths(&self) -> &[PathBuf] {
self.paths.as_ref()
}
/// Get the paths in the lexicographic order.
pub fn paths_owned(&self) -> Arc<[PathBuf]> {
self.paths.clone()
}
/// Get the order in which the paths were provided.
pub fn order(&self) -> &[usize] {
self.order.as_ref()
}
/// Get the paths in the original order.
pub fn ordered_paths(&self) -> impl Iterator<Item = &PathBuf> {
self.order
.iter()
.zip(self.paths.iter())
.sorted_by_key(|(i, _)| **i)
.map(|(_, path)| path)
}
pub fn is_lexicographically_ordered(&self) -> bool {
self.order.iter().enumerate().all(|(i, &j)| i == j)
}
pub fn deserialize(serialized: &SerializedPathList) -> Self {
let mut paths: Vec<PathBuf> = if serialized.paths.is_empty() {
Vec::new()
} else {
serialized.paths.split('\n').map(PathBuf::from).collect()
};
let mut order: Vec<usize> = serialized
.order
.split(',')
.filter_map(|s| s.parse().ok())
.collect();
if !paths.is_sorted() || order.len() != paths.len() {
order = (0..paths.len()).collect();
paths.sort();
}
Self {
paths: paths.into(),
order: order.into(),
}
}
pub fn serialize(&self) -> SerializedPathList {
use std::fmt::Write as _;
let mut paths = String::new();
for path in self.paths.iter() {
if !paths.is_empty() {
paths.push('\n');
}
paths.push_str(&path.to_string_lossy());
}
let mut order = String::new();
for ix in self.order.iter() {
if !order.is_empty() {
order.push(',');
}
write!(&mut order, "{}", *ix).unwrap();
}
SerializedPathList { paths, order }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_list() {
let list1 = PathList::new(&["a/d", "a/c"]);
let list2 = PathList::new(&["a/c", "a/d"]);
assert_eq!(list1.paths(), list2.paths(), "paths differ");
assert_eq!(list1.order(), &[1, 0], "list1 order incorrect");
assert_eq!(list2.order(), &[0, 1], "list2 order incorrect");
// Same paths in different order are equal (order is display-only).
assert_eq!(
list1, list2,
"same paths with different order should be equal"
);
let list1_deserialized = PathList::deserialize(&list1.serialize());
assert_eq!(list1_deserialized, list1, "list1 deserialization failed");
let list2_deserialized = PathList::deserialize(&list2.serialize());
assert_eq!(list2_deserialized, list2, "list2 deserialization failed");
assert_eq!(
list1.ordered_paths().collect_array().unwrap(),
[&PathBuf::from("a/d"), &PathBuf::from("a/c")],
"list1 ordered paths incorrect"
);
assert_eq!(
list2.ordered_paths().collect_array().unwrap(),
[&PathBuf::from("a/c"), &PathBuf::from("a/d")],
"list2 ordered paths incorrect"
);
}
#[test]
fn test_path_list_ordering() {
let list = PathList::new(&["b", "a", "c"]);
assert_eq!(
list.paths(),
&[PathBuf::from("a"), PathBuf::from("b"), PathBuf::from("c")]
);
assert_eq!(list.order(), &[1, 0, 2]);
assert!(!list.is_lexicographically_ordered());
let serialized = list.serialize();
let deserialized = PathList::deserialize(&serialized);
assert_eq!(deserialized, list);
assert_eq!(
deserialized.ordered_paths().collect_array().unwrap(),
[
&PathBuf::from("b"),
&PathBuf::from("a"),
&PathBuf::from("c")
]
);
let list = PathList::new(&["b", "c", "a"]);
assert_eq!(
list.paths(),
&[PathBuf::from("a"), PathBuf::from("b"), PathBuf::from("c")]
);
assert_eq!(list.order(), &[2, 0, 1]);
assert!(!list.is_lexicographically_ordered());
let serialized = list.serialize();
let deserialized = PathList::deserialize(&serialized);
assert_eq!(deserialized, list);
assert_eq!(
deserialized.ordered_paths().collect_array().unwrap(),
[
&PathBuf::from("b"),
&PathBuf::from("c"),
&PathBuf::from("a"),
]
);
}
}

3562
crates/util/src/paths.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
use anyhow::{Context as _, Result};
use std::process::Stdio;
/// A wrapper around `smol::process::Child` that ensures all subprocesses
/// are killed when the process is terminated by using process groups.
pub struct Child {
process: smol::process::Child,
}
impl std::ops::Deref for Child {
type Target = smol::process::Child;
fn deref(&self) -> &Self::Target {
&self.process
}
}
impl std::ops::DerefMut for Child {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.process
}
}
impl Child {
#[cfg(not(windows))]
pub fn spawn(
mut command: std::process::Command,
stdin: Stdio,
stdout: Stdio,
stderr: Stdio,
) -> Result<Self> {
crate::set_pre_exec_to_start_new_session(&mut command);
let mut command = smol::process::Command::from(command);
let process = command
.stdin(stdin)
.stdout(stdout)
.stderr(stderr)
.spawn()
.with_context(|| {
format!(
"failed to spawn command {}",
crate::redact::redact_command(&format!("{command:?}"))
)
})?;
Ok(Self { process })
}
#[cfg(windows)]
pub fn spawn(
command: std::process::Command,
stdin: Stdio,
stdout: Stdio,
stderr: Stdio,
) -> Result<Self> {
// TODO(windows): create a job object and add the child process handle to it,
// see https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects
let mut command = smol::process::Command::from(command);
let process = command
.stdin(stdin)
.stdout(stdout)
.stderr(stderr)
.spawn()
.with_context(|| {
format!(
"failed to spawn command {}",
crate::redact::redact_command(&format!("{command:?}"))
)
})?;
Ok(Self { process })
}
pub fn into_inner(self) -> smol::process::Child {
self.process
}
#[cfg(not(windows))]
pub fn kill(&mut self) -> Result<()> {
let pid = self.process.id();
unsafe {
libc::killpg(pid as i32, libc::SIGKILL);
}
Ok(())
}
#[cfg(windows)]
pub fn kill(&mut self) -> Result<()> {
// TODO(windows): terminate the job object in kill
self.process.kill()?;
Ok(())
}
}

49
crates/util/src/redact.rs Normal file
View File

@@ -0,0 +1,49 @@
use std::sync::LazyLock;
static REDACT_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"([A-Z_][A-Z0-9_]*)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)"#).unwrap()
});
/// Whether a given environment variable name should have its value redacted
pub fn should_redact(env_var_name: &str) -> bool {
const REDACTED_SUFFIXES: &[&str] = &[
"KEY",
"TOKEN",
"PASSWORD",
"SECRET",
"PASS",
"CREDENTIALS",
"LICENSE",
];
REDACTED_SUFFIXES
.iter()
.any(|suffix| env_var_name.ends_with(suffix))
}
/// Redact a string which could include a command with environment variables
pub fn redact_command(command: &str) -> String {
REDACT_REGEX
.replace_all(command, |caps: &regex::Captures| {
let var_name = &caps[1];
let value = &caps[2];
if should_redact(var_name) {
format!(r#"{}="[REDACTED]""#, var_name)
} else {
format!("{}={}", var_name, value)
}
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redact_string_with_multiple_env_vars() {
let input = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="sk-ant-api03-WOOOO" COMMAND_MODE="unix2003" GEMINI_API_KEY="AIGEMINIFACE" HOME="/Users/foo""#;
let result = redact_command(input);
let expected = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="[REDACTED]" COMMAND_MODE="unix2003" GEMINI_API_KEY="[REDACTED]" HOME="/Users/foo""#;
assert_eq!(result, expected);
}
}

638
crates/util/src/rel_path.rs Normal file
View File

@@ -0,0 +1,638 @@
use crate::paths::{PathStyle, is_absolute};
use anyhow::{Context as _, Result, anyhow};
use serde::{Deserialize, Serialize};
use std::{
borrow::{Borrow, Cow},
fmt,
ops::Deref,
path::{Path, PathBuf},
sync::Arc,
};
/// A file system path that is guaranteed to be relative and normalized.
///
/// This type can be used to represent paths in a uniform way, regardless of
/// whether they refer to Windows or POSIX file systems, and regardless of
/// the host platform.
///
/// Internally, paths are stored in POSIX ('/'-delimited) format, but they can
/// be displayed in either POSIX or Windows format.
///
/// Relative paths are also guaranteed to be valid unicode.
#[repr(transparent)]
#[derive(PartialEq, Eq, Hash, Serialize)]
pub struct RelPath(str);
/// An owned representation of a file system path that is guaranteed to be
/// relative and normalized.
///
/// This type is to [`RelPath`] as [`std::path::PathBuf`] is to [`std::path::Path`]
#[derive(PartialEq, Eq, Clone, Ord, PartialOrd, Serialize)]
pub struct RelPathBuf(String);
impl RelPath {
/// Creates an empty [`RelPath`].
pub fn empty() -> &'static Self {
Self::new_unchecked("")
}
/// Converts a path with a given style into a [`RelPath`].
///
/// Returns an error if the path is absolute, or is not valid unicode.
///
/// This method will normalize the path by removing `.` components,
/// processing `..` components, and removing trailing separators. It does
/// not allocate unless it's necessary to reformat the path.
#[track_caller]
pub fn new<'a>(path: &'a Path, path_style: PathStyle) -> Result<Cow<'a, Self>> {
let mut path = path.to_str().context("non utf-8 path")?;
let (prefixes, suffixes): (&[_], &[_]) = match path_style {
PathStyle::Posix => (&["./"], &['/']),
PathStyle::Windows => (&["./", ".\\"], &['/', '\\']),
};
while prefixes.iter().any(|prefix| path.starts_with(prefix)) {
path = &path[prefixes[0].len()..];
}
while let Some(prefix) = path.strip_suffix(suffixes)
&& !prefix.is_empty()
{
path = prefix;
}
if is_absolute(&path, path_style) {
return Err(anyhow!("absolute path not allowed: {path:?}"));
}
let mut string = Cow::Borrowed(path);
if path_style == PathStyle::Windows && path.contains('\\') {
string = Cow::Owned(string.as_ref().replace('\\', "/"))
}
let mut result = match string {
Cow::Borrowed(string) => Cow::Borrowed(Self::new_unchecked(string)),
Cow::Owned(string) => Cow::Owned(RelPathBuf(string)),
};
if result
.components()
.any(|component| component == "" || component == "." || component == "..")
{
let mut normalized = RelPathBuf::new();
for component in result.components() {
match component {
"" => {}
"." => {}
".." => {
if !normalized.pop() {
return Err(anyhow!("path is not relative: {result:?}"));
}
}
other => normalized.push(RelPath::new_unchecked(other)),
}
}
result = Cow::Owned(normalized)
}
Ok(result)
}
/// Converts a path that is already normalized and uses '/' separators
/// into a [`RelPath`] .
///
/// Returns an error if the path is not already in the correct format.
#[track_caller]
pub fn unix<S: AsRef<Path> + ?Sized>(path: &S) -> anyhow::Result<&Self> {
let path = path.as_ref();
match Self::new(path, PathStyle::Posix)? {
Cow::Borrowed(path) => Ok(path),
Cow::Owned(_) => Err(anyhow!("invalid relative path {path:?}")),
}
}
fn new_unchecked(s: &str) -> &Self {
// Safety: `RelPath` is a transparent wrapper around `str`.
unsafe { &*(s as *const str as *const Self) }
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn components(&self) -> RelPathComponents<'_> {
RelPathComponents(&self.0)
}
pub fn ancestors(&self) -> RelPathAncestors<'_> {
RelPathAncestors(Some(&self.0))
}
pub fn file_name(&self) -> Option<&str> {
self.components().next_back()
}
pub fn file_stem(&self) -> Option<&str> {
Some(self.as_std_path().file_stem()?.to_str().unwrap())
}
pub fn extension(&self) -> Option<&str> {
Some(self.as_std_path().extension()?.to_str().unwrap())
}
pub fn parent(&self) -> Option<&Self> {
let mut components = self.components();
components.next_back()?;
Some(components.rest())
}
pub fn starts_with(&self, other: &Self) -> bool {
self.strip_prefix(other).is_ok()
}
pub fn ends_with(&self, other: &Self) -> bool {
if let Some(suffix) = self.0.strip_suffix(&other.0) {
if suffix.ends_with('/') {
return true;
} else if suffix.is_empty() {
return true;
}
}
false
}
pub fn strip_prefix<'a>(&'a self, other: &Self) -> Result<&'a Self, StripPrefixError> {
if other.is_empty() {
return Ok(self);
}
if let Some(suffix) = self.0.strip_prefix(&other.0) {
if let Some(suffix) = suffix.strip_prefix('/') {
return Ok(Self::new_unchecked(suffix));
} else if suffix.is_empty() {
return Ok(Self::empty());
}
}
Err(StripPrefixError)
}
pub fn len(&self) -> usize {
self.0.matches('/').count() + 1
}
pub fn last_n_components(&self, count: usize) -> Option<&Self> {
let len = self.len();
if len >= count {
let mut components = self.components();
for _ in 0..(len - count) {
components.next()?;
}
Some(components.rest())
} else {
None
}
}
pub fn join(&self, other: &Self) -> Arc<Self> {
let result = if self.0.is_empty() {
Cow::Borrowed(&other.0)
} else if other.0.is_empty() {
Cow::Borrowed(&self.0)
} else {
Cow::Owned(format!("{}/{}", &self.0, &other.0))
};
Arc::from(Self::new_unchecked(result.as_ref()))
}
pub fn to_rel_path_buf(&self) -> RelPathBuf {
RelPathBuf(self.0.to_string())
}
pub fn into_arc(&self) -> Arc<Self> {
Arc::from(self)
}
/// Convert the path into the wire representation.
pub fn to_proto(&self) -> String {
self.as_unix_str().to_owned()
}
/// Load the path from its wire representation.
pub fn from_proto(path: &str) -> Result<Arc<Self>> {
Ok(Arc::from(Self::unix(path)?))
}
/// Convert the path into a string with the given path style.
///
/// Whenever a path is presented to the user, it should be converted to
/// a string via this method.
pub fn display(&self, style: PathStyle) -> Cow<'_, str> {
match style {
PathStyle::Posix => Cow::Borrowed(&self.0),
PathStyle::Windows if self.0.contains('/') => Cow::Owned(self.0.replace('/', "\\")),
PathStyle::Windows => Cow::Borrowed(&self.0),
}
}
/// Get the internal unix-style representation of the path.
///
/// This should not be shown to the user.
pub fn as_unix_str(&self) -> &str {
&self.0
}
/// Interprets the path as a [`std::path::Path`], suitable for file system calls.
///
/// This is guaranteed to be a valid path regardless of the host platform, because
/// the `/` is accepted as a path separator on windows.
///
/// This should not be shown to the user.
pub fn as_std_path(&self) -> &Path {
Path::new(&self.0)
}
}
#[derive(Debug)]
pub struct StripPrefixError;
impl std::fmt::Display for StripPrefixError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("prefix not found")
}
}
impl std::error::Error for StripPrefixError {}
impl ToOwned for RelPath {
type Owned = RelPathBuf;
fn to_owned(&self) -> Self::Owned {
self.to_rel_path_buf()
}
}
impl Borrow<RelPath> for RelPathBuf {
fn borrow(&self) -> &RelPath {
self.as_rel_path()
}
}
impl PartialOrd for RelPath {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for RelPath {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.components().cmp(other.components())
}
}
impl fmt::Debug for RelPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl fmt::Debug for RelPathBuf {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl RelPathBuf {
pub fn new() -> Self {
Self(String::new())
}
pub fn pop(&mut self) -> bool {
if let Some(ix) = self.0.rfind('/') {
self.0.truncate(ix);
true
} else if !self.is_empty() {
self.0.clear();
true
} else {
false
}
}
pub fn push(&mut self, path: &RelPath) {
if !self.is_empty() {
self.0.push('/');
}
self.0.push_str(&path.0);
}
pub fn as_rel_path(&self) -> &RelPath {
RelPath::new_unchecked(self.0.as_str())
}
pub fn set_extension(&mut self, extension: &str) -> bool {
if let Some(filename) = self.file_name() {
let mut filename = PathBuf::from(filename);
filename.set_extension(extension);
self.pop();
self.0.push_str(filename.to_str().unwrap());
true
} else {
false
}
}
}
impl<'de> Deserialize<'de> for RelPathBuf {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let path = String::deserialize(deserializer)?;
let rel_path =
RelPath::new(Path::new(&path), PathStyle::local()).map_err(serde::de::Error::custom)?;
Ok(rel_path.into_owned())
}
}
impl Into<Arc<RelPath>> for RelPathBuf {
fn into(self) -> Arc<RelPath> {
Arc::from(self.as_rel_path())
}
}
impl AsRef<Path> for RelPathBuf {
fn as_ref(&self) -> &Path {
self.as_std_path()
}
}
impl AsRef<Path> for RelPath {
fn as_ref(&self) -> &Path {
self.as_std_path()
}
}
impl AsRef<RelPath> for RelPathBuf {
fn as_ref(&self) -> &RelPath {
self.as_rel_path()
}
}
impl AsRef<RelPath> for RelPath {
fn as_ref(&self) -> &RelPath {
self
}
}
impl Deref for RelPathBuf {
type Target = RelPath;
fn deref(&self) -> &Self::Target {
self.as_ref()
}
}
impl<'a> From<&'a RelPath> for Cow<'a, RelPath> {
fn from(value: &'a RelPath) -> Self {
Self::Borrowed(value)
}
}
impl From<&RelPath> for Arc<RelPath> {
fn from(rel_path: &RelPath) -> Self {
let bytes: Arc<str> = Arc::from(&rel_path.0);
unsafe { Arc::from_raw(Arc::into_raw(bytes) as *const RelPath) }
}
}
#[cfg(any(test, feature = "test-support"))]
#[track_caller]
pub fn rel_path(path: &str) -> &RelPath {
RelPath::unix(path).unwrap()
}
#[cfg(any(test, feature = "test-support"))]
#[track_caller]
pub fn rel_path_buf(path: &str) -> RelPathBuf {
RelPath::unix(path).unwrap().to_rel_path_buf()
}
impl PartialEq<str> for RelPath {
fn eq(&self, other: &str) -> bool {
self.0 == *other
}
}
pub trait PathExt {
fn to_rel_path_buf(&self) -> Result<RelPathBuf>;
}
impl<T: AsRef<Path> + ?Sized> PathExt for T {
fn to_rel_path_buf(&self) -> Result<RelPathBuf> {
Ok(RelPath::new(self.as_ref(), PathStyle::local())?.into_owned())
}
}
#[derive(Default)]
pub struct RelPathComponents<'a>(&'a str);
pub struct RelPathAncestors<'a>(Option<&'a str>);
const SEPARATOR: char = '/';
impl<'a> RelPathComponents<'a> {
pub fn rest(&self) -> &'a RelPath {
RelPath::new_unchecked(self.0)
}
}
impl<'a> Iterator for RelPathComponents<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<Self::Item> {
if let Some(sep_ix) = self.0.find(SEPARATOR) {
let (head, tail) = self.0.split_at(sep_ix);
self.0 = &tail[1..];
Some(head)
} else if self.0.is_empty() {
None
} else {
let result = self.0;
self.0 = "";
Some(result)
}
}
}
impl<'a> Iterator for RelPathAncestors<'a> {
type Item = &'a RelPath;
fn next(&mut self) -> Option<Self::Item> {
let result = self.0?;
if let Some(sep_ix) = result.rfind(SEPARATOR) {
self.0 = Some(&result[..sep_ix]);
} else if !result.is_empty() {
self.0 = Some("");
} else {
self.0 = None;
}
Some(RelPath::new_unchecked(result))
}
}
impl<'a> DoubleEndedIterator for RelPathComponents<'a> {
fn next_back(&mut self) -> Option<Self::Item> {
if let Some(sep_ix) = self.0.rfind(SEPARATOR) {
let (head, tail) = self.0.split_at(sep_ix);
self.0 = head;
Some(&tail[1..])
} else if self.0.is_empty() {
None
} else {
let result = self.0;
self.0 = "";
Some(result)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use itertools::Itertools;
use pretty_assertions::assert_matches;
#[test]
fn test_rel_path_new() {
assert!(RelPath::new(Path::new("/"), PathStyle::local()).is_err());
assert!(RelPath::new(Path::new("//"), PathStyle::local()).is_err());
assert!(RelPath::new(Path::new("/foo/"), PathStyle::local()).is_err());
let path = RelPath::new("foo/".as_ref(), PathStyle::local()).unwrap();
assert_eq!(path, rel_path("foo").into());
assert_matches!(path, Cow::Borrowed(_));
let path = RelPath::new("foo\\".as_ref(), PathStyle::Windows).unwrap();
assert_eq!(path, rel_path("foo").into());
assert_matches!(path, Cow::Borrowed(_));
assert_eq!(
RelPath::new("foo/bar/../baz/./quux/".as_ref(), PathStyle::local())
.unwrap()
.as_ref(),
rel_path("foo/baz/quux")
);
let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Posix).unwrap();
assert_eq!(path.as_ref(), rel_path("foo/bar"));
assert_matches!(path, Cow::Borrowed(_));
let path = RelPath::new(".\\foo".as_ref(), PathStyle::Windows).unwrap();
assert_eq!(path, rel_path("foo").into());
assert_matches!(path, Cow::Borrowed(_));
let path = RelPath::new("./.\\./foo/\\/".as_ref(), PathStyle::Windows).unwrap();
assert_eq!(path, rel_path("foo").into());
assert_matches!(path, Cow::Borrowed(_));
let path = RelPath::new("foo/./bar".as_ref(), PathStyle::Posix).unwrap();
assert_eq!(path.as_ref(), rel_path("foo/bar"));
assert_matches!(path, Cow::Owned(_));
let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Windows).unwrap();
assert_eq!(path.as_ref(), rel_path("foo/bar"));
assert_matches!(path, Cow::Borrowed(_));
let path = RelPath::new(".\\foo\\bar".as_ref(), PathStyle::Windows).unwrap();
assert_eq!(path.as_ref(), rel_path("foo/bar"));
assert_matches!(path, Cow::Owned(_));
}
#[test]
fn test_rel_path_components() {
let path = rel_path("foo/bar/baz");
assert_eq!(
path.components().collect::<Vec<_>>(),
vec!["foo", "bar", "baz"]
);
assert_eq!(
path.components().rev().collect::<Vec<_>>(),
vec!["baz", "bar", "foo"]
);
let path = rel_path("");
let mut components = path.components();
assert_eq!(components.next(), None);
}
#[test]
fn test_rel_path_ancestors() {
let path = rel_path("foo/bar/baz");
let mut ancestors = path.ancestors();
assert_eq!(ancestors.next(), Some(rel_path("foo/bar/baz")));
assert_eq!(ancestors.next(), Some(rel_path("foo/bar")));
assert_eq!(ancestors.next(), Some(rel_path("foo")));
assert_eq!(ancestors.next(), Some(rel_path("")));
assert_eq!(ancestors.next(), None);
let path = rel_path("foo");
let mut ancestors = path.ancestors();
assert_eq!(ancestors.next(), Some(rel_path("foo")));
assert_eq!(ancestors.next(), Some(RelPath::empty()));
assert_eq!(ancestors.next(), None);
let path = RelPath::empty();
let mut ancestors = path.ancestors();
assert_eq!(ancestors.next(), Some(RelPath::empty()));
assert_eq!(ancestors.next(), None);
}
#[test]
fn test_rel_path_parent() {
assert_eq!(rel_path("foo/bar/baz").parent(), Some(rel_path("foo/bar")));
assert_eq!(rel_path("foo").parent(), Some(RelPath::empty()));
assert_eq!(rel_path("").parent(), None);
}
#[test]
fn test_rel_path_partial_ord_is_compatible_with_std() {
let test_cases = ["a/b/c", "relative/path/with/dot.", "relative/path/with.dot"];
for [lhs, rhs] in test_cases.iter().array_combinations::<2>() {
assert_eq!(
Path::new(lhs).cmp(Path::new(rhs)),
RelPath::unix(lhs)
.unwrap()
.cmp(&RelPath::unix(rhs).unwrap())
);
}
}
#[test]
fn test_strip_prefix() {
let parent = rel_path("");
let child = rel_path(".foo");
assert!(child.starts_with(parent));
assert_eq!(child.strip_prefix(parent).unwrap(), child);
}
#[test]
fn test_rel_path_constructors_absolute_path() {
assert!(RelPath::new(Path::new("/a/b"), PathStyle::Windows).is_err());
assert!(RelPath::new(Path::new("\\a\\b"), PathStyle::Windows).is_err());
assert!(RelPath::new(Path::new("/a/b"), PathStyle::Posix).is_err());
assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Windows).is_err());
assert!(RelPath::new(Path::new("C:\\a\\b"), PathStyle::Windows).is_err());
assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Posix).is_ok());
}
#[test]
fn test_pop() {
let mut path = rel_path("a/b").to_rel_path_buf();
path.pop();
assert_eq!(path.as_rel_path().as_unix_str(), "a");
path.pop();
assert_eq!(path.as_rel_path().as_unix_str(), "");
path.pop();
assert_eq!(path.as_rel_path().as_unix_str(), "");
}
}

View File

@@ -0,0 +1,72 @@
use schemars::{JsonSchema, transform::transform_subschemas};
const DEFS_PATH: &str = "#/$defs/";
/// Replaces the JSON schema definition for some type if it is in use (in the definitions list), and
/// returns a reference to it.
///
/// This asserts that JsonSchema::schema_name() + "2" does not exist because this indicates that
/// there are multiple types that use this name, and unfortunately schemars APIs do not support
/// resolving this ambiguity - see <https://github.com/GREsau/schemars/issues/449>
///
/// This takes a closure for `schema` because some settings types are not available on the remote
/// server, and so will crash when attempting to access e.g. GlobalThemeRegistry.
pub fn replace_subschema<T: JsonSchema>(
generator: &mut schemars::SchemaGenerator,
schema: impl Fn() -> schemars::Schema,
) -> schemars::Schema {
let schema_name = T::schema_name();
let definitions = generator.definitions_mut();
assert!(!definitions.contains_key(&format!("{schema_name}2")));
assert!(definitions.contains_key(schema_name.as_ref()));
definitions.insert(schema_name.to_string(), schema().to_value());
schemars::Schema::new_ref(format!("{DEFS_PATH}{schema_name}"))
}
/// Adds a new JSON schema definition and returns a reference to it. **Panics** if the name is
/// already in use.
pub fn add_new_subschema(
generator: &mut schemars::SchemaGenerator,
name: &str,
schema: serde_json::Value,
) -> schemars::Schema {
let old_definition = generator.definitions_mut().insert(name.to_string(), schema);
assert_eq!(old_definition, None);
schemars::Schema::new_ref(format!("{DEFS_PATH}{name}"))
}
/// Defaults `additionalProperties` to `true`, as if `#[schemars(deny_unknown_fields)]` was on every
/// struct. Skips structs that have `additionalProperties` set (such as if #[serde(flatten)] is used
/// on a map).
#[derive(Clone)]
pub struct DefaultDenyUnknownFields;
impl schemars::transform::Transform for DefaultDenyUnknownFields {
fn transform(&mut self, schema: &mut schemars::Schema) {
if let Some(object) = schema.as_object_mut()
&& object.contains_key("properties")
&& !object.contains_key("additionalProperties")
&& !object.contains_key("unevaluatedProperties")
{
object.insert("additionalProperties".to_string(), false.into());
}
transform_subschemas(self, schema);
}
}
/// Defaults `allowTrailingCommas` to `true`, for use with `json-language-server`.
/// This can be applied to any schema that will be treated as `jsonc`.
///
/// Note that this is non-recursive and only applied to the root schema.
#[derive(Clone)]
pub struct AllowTrailingCommas;
impl schemars::transform::Transform for AllowTrailingCommas {
fn transform(&mut self, schema: &mut schemars::Schema) {
if let Some(object) = schema.as_object_mut()
&& !object.contains_key("allowTrailingCommas")
{
object.insert("allowTrailingCommas".to_string(), true.into());
}
}
}

7
crates/util/src/serde.rs Normal file
View File

@@ -0,0 +1,7 @@
pub const fn default_true() -> bool {
true
}
pub fn is_default<T: Default + PartialEq>(value: &T) -> bool {
*value == T::default()
}

1051
crates/util/src/shell.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,327 @@
use std::borrow::Cow;
use crate::shell::get_system_shell;
use crate::shell::{Shell, ShellKind};
/// ShellBuilder is used to turn a user-requested task into a
/// program that can be executed by the shell.
pub struct ShellBuilder {
/// The shell to run
program: String,
args: Vec<String>,
interactive: bool,
/// Whether to redirect stdin to /dev/null for the spawned command as a subshell.
redirect_stdin: bool,
kind: ShellKind,
}
impl ShellBuilder {
/// Create a new ShellBuilder as configured.
pub fn new(shell: &Shell, is_windows: bool) -> Self {
let (program, args) = match shell {
Shell::System => (get_system_shell(), Vec::new()),
Shell::Program(shell) => (shell.clone(), Vec::new()),
Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
};
let kind = ShellKind::new(&program, is_windows);
Self {
program,
args,
interactive: true,
kind,
redirect_stdin: false,
}
}
pub fn non_interactive(mut self) -> Self {
self.interactive = false;
self
}
/// Returns the label to show in the terminal tab
pub fn command_label(&self, command_to_use_in_label: &str) -> String {
if command_to_use_in_label.trim().is_empty() {
self.program.clone()
} else {
match self.kind {
ShellKind::PowerShell | ShellKind::Pwsh => {
format!("{} -C '{}'", self.program, command_to_use_in_label)
}
ShellKind::Cmd => {
format!("{} /C \"{}\"", self.program, command_to_use_in_label)
}
ShellKind::Posix
| ShellKind::Nushell
| ShellKind::Fish
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Xonsh
| ShellKind::Elvish => {
let interactivity = self.interactive.then_some("-i ").unwrap_or_default();
format!(
"{PROGRAM} {interactivity}-c '{command_to_use_in_label}'",
PROGRAM = self.program
)
}
}
}
}
pub fn redirect_stdin_to_dev_null(mut self) -> Self {
self.redirect_stdin = true;
self
}
/// Returns the program and arguments to run this task in a shell.
pub fn build(
mut self,
task_command: Option<String>,
task_args: &[String],
) -> (String, Vec<String>) {
if let Some(task_command) = task_command {
let task_command = if !task_args.is_empty() {
match self.kind.try_quote_prefix_aware(&task_command) {
Some(task_command) => task_command.into_owned(),
None => task_command,
}
} else {
task_command
};
let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
command.push(' ');
let shell_variable = self.kind.to_shell_variable(arg);
command.push_str(&match self.kind.try_quote(&shell_variable) {
Some(shell_variable) => shell_variable,
None => Cow::Owned(shell_variable),
});
command
});
if self.redirect_stdin {
match self.kind {
ShellKind::Fish => {
combined_command.insert_str(0, "begin; ");
combined_command.push_str("; end </dev/null");
}
ShellKind::Posix
| ShellKind::Nushell
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Xonsh
| ShellKind::Elvish => {
combined_command.insert(0, '(');
combined_command.push_str("\n) </dev/null");
}
ShellKind::PowerShell | ShellKind::Pwsh => {
combined_command.insert_str(0, "$null | & {");
combined_command.push_str("}");
}
ShellKind::Cmd => {
combined_command.push_str("< NUL");
}
}
}
self.args
.extend(self.kind.args_for_shell(self.interactive, combined_command));
}
(self.program, self.args)
}
// This should not exist, but our task infra is broken beyond repair right now
#[doc(hidden)]
pub fn build_no_quote(
mut self,
task_command: Option<String>,
task_args: &[String],
) -> (String, Vec<String>) {
if let Some(task_command) = task_command {
let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
command.push(' ');
command.push_str(&self.kind.to_shell_variable(arg));
command
});
if self.redirect_stdin {
match self.kind {
ShellKind::Fish => {
combined_command.insert_str(0, "begin; ");
combined_command.push_str("; end </dev/null");
}
ShellKind::Posix
| ShellKind::Nushell
| ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Xonsh
| ShellKind::Elvish => {
combined_command.insert(0, '(');
combined_command.push_str("\n) </dev/null");
}
ShellKind::PowerShell | ShellKind::Pwsh => {
combined_command.insert_str(0, "$null | & {");
combined_command.push_str("}");
}
ShellKind::Cmd => {
combined_command.push_str("< NUL");
}
}
}
self.args
.extend(self.kind.args_for_shell(self.interactive, combined_command));
}
(self.program, self.args)
}
/// Builds a `smol::process::Command` with the given task command and arguments.
///
/// Prefer this over manually constructing a command with the output of `Self::build`,
/// as this method handles `cmd` weirdness on windows correctly.
pub fn build_smol_command(
self,
task_command: Option<String>,
task_args: &[String],
) -> smol::process::Command {
smol::process::Command::from(self.build_std_command(task_command, task_args))
}
/// Builds a `std::process::Command` with the given task command and arguments.
///
/// Prefer this over manually constructing a command with the output of `Self::build`,
/// as this method handles `cmd` weirdness on windows correctly.
pub fn build_std_command(
self,
mut task_command: Option<String>,
task_args: &[String],
) -> std::process::Command {
#[cfg(windows)]
let kind = self.kind;
if task_args.is_empty() {
task_command = task_command
.as_ref()
.map(|cmd| self.kind.try_quote_prefix_aware(&cmd).map(Cow::into_owned))
.unwrap_or(task_command);
}
let (program, args) = self.build(task_command, task_args);
let mut child = crate::command::new_std_command(program);
#[cfg(windows)]
if kind == ShellKind::Cmd {
use std::os::windows::process::CommandExt;
for arg in args {
child.raw_arg(arg);
}
} else {
child.args(args);
}
#[cfg(not(windows))]
child.args(args);
child
}
pub fn kind(&self) -> ShellKind {
self.kind
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_nu_shell_variable_substitution() {
let shell = Shell::Program("nu".to_owned());
let shell_builder = ShellBuilder::new(&shell, false);
let (program, args) = shell_builder.build(
Some("echo".into()),
&[
"${hello}".to_string(),
"$world".to_string(),
"nothing".to_string(),
"--$something".to_string(),
"$".to_string(),
"${test".to_string(),
],
);
assert_eq!(program, "nu");
assert_eq!(
args,
vec![
"-i",
"-c",
"echo '$env.hello' '$env.world' nothing '--($env.something)' '$' '${test'"
]
);
}
#[test]
fn redirect_stdin_to_dev_null_precedence() {
let shell = Shell::Program("nu".to_owned());
let shell_builder = ShellBuilder::new(&shell, false);
let (program, args) = shell_builder
.redirect_stdin_to_dev_null()
.build(Some("echo".into()), &["nothing".to_string()]);
assert_eq!(program, "nu");
assert_eq!(args, vec!["-i", "-c", "(echo nothing\n) </dev/null"]);
}
#[test]
fn redirect_stdin_to_dev_null_fish() {
let shell = Shell::Program("fish".to_owned());
let shell_builder = ShellBuilder::new(&shell, false);
let (program, args) = shell_builder
.redirect_stdin_to_dev_null()
.build(Some("echo".into()), &["test".to_string()]);
assert_eq!(program, "fish");
assert_eq!(args, vec!["-i", "-c", "begin; echo test; end </dev/null"]);
}
#[test]
fn redirect_stdin_to_dev_null_preserves_heredoc() {
let shell = Shell::Program("sh".to_owned());
let shell_builder = ShellBuilder::new(&shell, false);
let command = "cat <<EOF\nhello\nEOF";
let (program, args) = shell_builder
.redirect_stdin_to_dev_null()
.build(Some(command.into()), &[]);
assert_eq!(program, "sh");
assert_eq!(
args,
vec!["-i", "-c", "(cat <<EOF\nhello\nEOF\n) </dev/null"]
);
}
#[test]
fn does_not_quote_sole_command_only() {
let shell = Shell::Program("fish".to_owned());
let shell_builder = ShellBuilder::new(&shell, false);
let (program, args) = shell_builder.build(Some("echo".into()), &[]);
assert_eq!(program, "fish");
assert_eq!(args, vec!["-i", "-c", "echo"]);
let shell = Shell::Program("fish".to_owned());
let shell_builder = ShellBuilder::new(&shell, false);
let (program, args) = shell_builder.build(Some("echo oo".into()), &[]);
assert_eq!(program, "fish");
assert_eq!(args, vec!["-i", "-c", "echo oo"]);
}
}

View File

@@ -0,0 +1,351 @@
use std::path::Path;
use anyhow::{Context as _, Result};
use collections::HashMap;
use serde::Deserialize;
use crate::shell::ShellKind;
fn parse_env_map_from_noisy_output(output: &str) -> Result<collections::HashMap<String, String>> {
for (position, _) in output.match_indices('{') {
let candidate = &output[position..];
let mut deserializer = serde_json::Deserializer::from_str(candidate);
if let Ok(env_map) = HashMap::<String, String>::deserialize(&mut deserializer) {
return Ok(env_map);
}
}
anyhow::bail!("Failed to find JSON in shell output: {output}")
}
pub fn print_env() {
let env_vars: HashMap<String, String> = std::env::vars().collect();
let json = serde_json::to_string_pretty(&env_vars).unwrap_or_else(|err| {
eprintln!("Error serializing environment variables: {}", err);
std::process::exit(1);
});
println!("{}", json);
}
/// Capture all environment variables from the login shell in the given directory.
pub async fn capture(
shell_path: impl AsRef<Path>,
args: &[String],
directory: impl AsRef<Path>,
) -> Result<collections::HashMap<String, String>> {
#[cfg(windows)]
return capture_windows(shell_path.as_ref(), args, directory.as_ref()).await;
#[cfg(unix)]
return capture_unix(shell_path.as_ref(), args, directory.as_ref()).await;
}
/// Try to parse the environment output before checking the exit status.
/// The user's shell rc files may contain commands that fail (e.g. editor
/// integrations that call posix_spawnp outside a real PTY), causing a
/// non-zero exit status even though `zed --printenv` ran successfully and
/// produced valid output on its separate fd.
fn parse_env_output(
env_output: &str,
status: &std::process::ExitStatus,
successful_capture_warning: impl FnOnce() -> String,
failed_capture_error: impl FnOnce() -> String,
) -> Result<collections::HashMap<String, String>> {
match parse_env_map_from_noisy_output(env_output) {
Ok(env_map) => {
if !status.success() {
log::warn!("{}", successful_capture_warning());
}
Ok(env_map)
}
Err(parse_error) => {
if !status.success() {
anyhow::bail!(
"{}. Failed to deserialize environment variables from json: {parse_error}. output: {env_output}",
failed_capture_error(),
);
}
anyhow::bail!(
"Failed to deserialize environment variables from json: {parse_error}. output: {env_output}"
);
}
}
}
#[cfg(unix)]
async fn capture_unix(
shell_path: &Path,
args: &[String],
directory: &Path,
) -> Result<collections::HashMap<String, String>> {
use std::os::unix::process::CommandExt;
use crate::command::new_std_command;
let shell_kind = ShellKind::new(shell_path, false);
let quoted_zed_path = super::get_shell_safe_zed_path(shell_kind)?;
let mut command_string = String::new();
let mut command = new_std_command(shell_path);
command.args(args);
// In some shells, file descriptors greater than 2 cannot be used in interactive mode,
// so file descriptor 0 (stdin) is used instead. This impacts zsh, old bash; perhaps others.
// See: https://github.com/zed-industries/zed/pull/32136#issuecomment-2999645482
const FD_STDIN: std::os::fd::RawFd = 0;
const FD_STDOUT: std::os::fd::RawFd = 1;
const FD_STDERR: std::os::fd::RawFd = 2;
let (fd_num, redir) = match shell_kind {
ShellKind::Rc => (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]`
ShellKind::Nushell | ShellKind::Tcsh => (FD_STDOUT, "".to_string()),
// xonsh doesn't support redirecting to stdin, and control sequences are printed to
// stdout on startup
ShellKind::Xonsh => (FD_STDERR, "o>e".to_string()),
ShellKind::PowerShell => (FD_STDIN, format!(">{}", FD_STDIN)),
_ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0`
};
match shell_kind {
ShellKind::Csh | ShellKind::Tcsh => {
// For csh/tcsh, login shell requires passing `-` as 0th argument (instead of `-l`)
command.arg0("-");
}
ShellKind::Fish => {
// in fish, asdf, direnv attach to the `fish_prompt` event
command_string.push_str("emit fish_prompt;");
command.arg("-l");
}
_ => {
command.arg("-l");
}
}
match shell_kind {
// Nushell does not allow non-interactive login shells.
// Instead of doing "-l -i -c '<command>'"
// use "-l -e '<command>; exit'" instead
ShellKind::Nushell => command.arg("-e"),
_ => command.args(["-i", "-c"]),
};
// Prefix with "./" if the path starts with "-" to prevent cd from interpreting it as a flag
let dir_str = directory.to_string_lossy();
let dir_str = if dir_str.starts_with('-') {
format!("./{dir_str}").into()
} else {
dir_str
};
let quoted_dir = shell_kind
.try_quote(&dir_str)
.context("unexpected null in directory name")?;
// cd into the directory, triggering directory specific side-effects (asdf, direnv, etc)
command_string.push_str(&format!("cd {};", quoted_dir));
if let Some(prefix) = shell_kind.command_prefix() {
command_string.push(prefix);
}
command_string.push_str(&format!("{} --printenv {}", quoted_zed_path, redir));
if let ShellKind::Nushell = shell_kind {
command_string.push_str("; exit");
}
command.arg(&command_string);
super::set_pre_exec_to_start_new_session(&mut command);
let (env_output, process_output) = spawn_and_read_fd(command, fd_num).await?;
let env_output = String::from_utf8_lossy(&env_output);
parse_env_output(
&env_output,
&process_output.status,
|| {
format!(
"login shell exited with {} but environment was captured successfully. stderr: {:?}",
process_output.status,
String::from_utf8_lossy(&process_output.stderr),
)
},
|| {
format!(
"login shell exited with {}. stdout: {:?}, stderr: {:?}",
process_output.status,
String::from_utf8_lossy(&process_output.stdout),
String::from_utf8_lossy(&process_output.stderr),
)
},
)
}
#[cfg(unix)]
async fn spawn_and_read_fd(
mut command: std::process::Command,
child_fd: std::os::fd::RawFd,
) -> anyhow::Result<(Vec<u8>, std::process::Output)> {
use command_fds::{CommandFdExt, FdMapping};
use std::{io::Read, process::Stdio};
let (mut reader, writer) = std::io::pipe()?;
command.fd_mappings(vec![FdMapping {
parent_fd: writer.into(),
child_fd,
}])?;
let process = smol::process::Command::from(command)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer)?;
Ok((buffer, process.output().await?))
}
#[cfg(windows)]
async fn capture_windows(
shell_path: &Path,
args: &[String],
directory: &Path,
) -> Result<collections::HashMap<String, String>> {
use std::process::Stdio;
let zed_path =
std::env::current_exe().context("Failed to determine current zed executable path.")?;
let shell_kind = ShellKind::new(shell_path, true);
// Prefix with "./" if the path starts with "-" to prevent cd from interpreting it as a flag
let directory_string = directory.display().to_string();
let directory_string = if directory_string.starts_with('-') {
format!("./{directory_string}")
} else {
directory_string
};
let zed_path_string = zed_path.display().to_string();
let quote_for_shell = |value: &str| {
shell_kind
.try_quote(value)
.map(|quoted| quoted.into_owned())
.context("unexpected null in directory name")
};
let mut cmd = crate::command::new_command(shell_path);
cmd.args(args);
let quoted_directory = quote_for_shell(&directory_string)?;
let quoted_zed_path = quote_for_shell(&zed_path_string)?;
let cmd = match shell_kind {
ShellKind::Csh
| ShellKind::Tcsh
| ShellKind::Rc
| ShellKind::Fish
| ShellKind::Xonsh
| ShellKind::Posix => cmd.args([
"-l",
"-i",
"-c",
&format!("cd {}; {} --printenv", quoted_directory, quoted_zed_path),
]),
ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([
"-NonInteractive",
"-NoProfile",
"-Command",
&format!(
"Set-Location {}; & {} --printenv",
quoted_directory, quoted_zed_path
),
]),
ShellKind::Elvish => cmd.args([
"-c",
&format!("cd {}; {} --printenv", quoted_directory, quoted_zed_path),
]),
ShellKind::Nushell => {
let zed_command = shell_kind
.prepend_command_prefix(&quoted_zed_path)
.into_owned();
cmd.args([
"-c",
&format!("cd {}; {} --printenv", quoted_directory, zed_command),
])
}
ShellKind::Cmd => {
let dir = directory_string.trim_end_matches('\\');
cmd.args(["/d", "/c", "cd", dir, "&&", &zed_path_string, "--printenv"])
}
}
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = cmd
.output()
.await
.with_context(|| format!("command {cmd:?}"))?;
let env_output = String::from_utf8_lossy(&output.stdout);
parse_env_output(
&env_output,
&output.status,
|| {
format!(
"Command {cmd:?} exited with {} but environment was captured successfully. stderr: {:?}",
output.status,
String::from_utf8_lossy(&output.stderr),
)
},
|| {
format!(
"Command {cmd:?} failed with {}. stdout: {:?}, stderr: {:?}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
)
},
)
}
#[cfg(test)]
mod tests {
use std::process::ExitStatus;
use super::*;
use crate::path;
#[cfg(unix)]
fn exit_status(code: i32) -> ExitStatus {
use std::os::unix::process::ExitStatusExt;
ExitStatus::from_raw(code << 8)
}
#[cfg(windows)]
fn exit_status(code: u32) -> ExitStatus {
use std::os::windows::process::ExitStatusExt;
ExitStatus::from_raw(code)
}
#[test]
fn parse_env_output_accepts_valid_env_when_shell_exits_nonzero() {
let env_json = serde_json::json!({
"PATH": path!("/usr/bin"),
"SHELL": path!("/bin/zsh"),
});
let env_output = format!("shell startup noise\n{env_json}\nshell shutdown noise");
let env_map = parse_env_output(
&env_output,
&exit_status(1),
|| "shell exited with 1 but environment was captured successfully".to_string(),
|| panic!("failed capture error should not be evaluated for valid environment output"),
)
.expect("valid environment output should be returned despite non-zero shell exit");
assert_eq!(
env_map.get("PATH").map(String::as_str),
Some(path!("/usr/bin"))
);
assert_eq!(
env_map.get("SHELL").map(String::as_str),
Some(path!("/bin/zsh"))
);
}
}

46
crates/util/src/size.rs Normal file
View File

@@ -0,0 +1,46 @@
pub fn format_file_size(size: u64, use_decimal: bool) -> String {
if use_decimal {
if size < 1000 {
format!("{size}B")
} else if size < 1000 * 1000 {
format!("{:.1}KB", size as f64 / 1000.0)
} else {
format!("{:.1}MB", size as f64 / (1000.0 * 1000.0))
}
} else if size < 1024 {
format!("{size}B")
} else if size < 1024 * 1024 {
format!("{:.1}KiB", size as f64 / 1024.0)
} else {
format!("{:.1}MiB", size as f64 / (1024.0 * 1024.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_file_size_decimal() {
assert_eq!(format_file_size(0, true), "0B");
assert_eq!(format_file_size(999, true), "999B");
assert_eq!(format_file_size(1000, true), "1.0KB");
assert_eq!(format_file_size(1500, true), "1.5KB");
assert_eq!(format_file_size(999999, true), "1000.0KB");
assert_eq!(format_file_size(1000000, true), "1.0MB");
assert_eq!(format_file_size(1500000, true), "1.5MB");
assert_eq!(format_file_size(10000000, true), "10.0MB");
}
#[test]
fn test_format_file_size_binary() {
assert_eq!(format_file_size(0, false), "0B");
assert_eq!(format_file_size(1023, false), "1023B");
assert_eq!(format_file_size(1024, false), "1.0KiB");
assert_eq!(format_file_size(1536, false), "1.5KiB");
assert_eq!(format_file_size(1048575, false), "1024.0KiB");
assert_eq!(format_file_size(1048576, false), "1.0MiB");
assert_eq!(format_file_size(1572864, false), "1.5MiB");
assert_eq!(format_file_size(10485760, false), "10.0MiB");
}
}

80
crates/util/src/test.rs Normal file
View File

@@ -0,0 +1,80 @@
mod assertions;
mod marked_text;
pub use assertions::*;
pub use marked_text::*;
use git2;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
pub struct TempTree {
_temp_dir: TempDir,
path: PathBuf,
}
impl TempTree {
pub fn new(tree: serde_json::Value) -> Self {
let dir = TempDir::new().unwrap();
let path = std::fs::canonicalize(dir.path()).unwrap();
write_tree(path.as_path(), tree);
Self {
_temp_dir: dir,
path,
}
}
pub fn path(&self) -> &Path {
self.path.as_path()
}
}
fn write_tree(path: &Path, tree: serde_json::Value) {
use serde_json::Value;
use std::fs;
if let Value::Object(map) = tree {
for (name, contents) in map {
let mut path = PathBuf::from(path);
path.push(name);
match contents {
Value::Object(_) => {
fs::create_dir(&path).unwrap();
#[cfg(not(target_family = "wasm"))]
if path.file_name() == Some(OsStr::new(".git")) {
git2::Repository::init(path.parent().unwrap()).unwrap();
}
write_tree(&path, contents);
}
Value::Null => {
fs::create_dir(&path).unwrap();
}
Value::String(contents) => {
fs::write(&path, contents).unwrap();
}
_ => {
panic!("JSON object must contain only objects, strings, or null");
}
}
}
} else {
panic!("You must pass a JSON object to this helper")
}
}
pub fn sample_text(rows: usize, cols: usize, start_char: char) -> String {
let mut text = String::new();
for row in 0..rows {
let c: char = (start_char as u32 + row as u32) as u8 as char;
let mut line = c.to_string().repeat(cols);
if row < rows - 1 {
line.push('\n');
}
text += &line;
}
text
}

View File

@@ -0,0 +1,62 @@
pub enum SetEqError<T> {
LeftMissing(T),
RightMissing(T),
}
impl<T> SetEqError<T> {
pub fn map<R, F: FnOnce(T) -> R>(self, update: F) -> SetEqError<R> {
match self {
SetEqError::LeftMissing(missing) => SetEqError::LeftMissing(update(missing)),
SetEqError::RightMissing(missing) => SetEqError::RightMissing(update(missing)),
}
}
}
#[macro_export]
macro_rules! set_eq {
($left:expr,$right:expr) => {{
use util::test::*;
let left = $left;
let right = $right;
let mut result = Ok(());
for right_value in right.iter() {
if !left.contains(right_value) {
result = Err(SetEqError::LeftMissing(right_value.clone()));
break;
}
}
if result.is_ok() {
for left_value in left.iter() {
if !right.contains(left_value) {
result = Err(SetEqError::RightMissing(left_value.clone()));
}
}
}
result
}};
}
#[macro_export]
macro_rules! assert_set_eq {
($left:expr,$right:expr) => {{
use util::test::*;
use util::set_eq;
let left = $left;
let right = $right;
match set_eq!(&left, &right) {
Err(SetEqError::LeftMissing(missing)) => {
panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nleft does not contain {:?}", &left, &right, &missing);
},
Err(SetEqError::RightMissing(missing)) => {
panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nright does not contain {:?}", &left, &right, &missing);
},
_ => {}
}
}};
}

View File

View File

@@ -0,0 +1,281 @@
use collections::HashMap;
use std::{cmp::Ordering, ops::Range};
/// Construct a string and a list of offsets within that string using a single
/// string containing embedded position markers.
pub fn marked_text_offsets_by(
marked_text: &str,
markers: Vec<char>,
) -> (String, HashMap<char, Vec<usize>>) {
let mut extracted_markers: HashMap<char, Vec<usize>> = Default::default();
let mut unmarked_text = String::new();
for char in marked_text.chars() {
if markers.contains(&char) {
let char_offsets = extracted_markers.entry(char).or_default();
char_offsets.push(unmarked_text.len());
} else {
unmarked_text.push(char);
}
}
(unmarked_text, extracted_markers)
}
/// Construct a string and a list of ranges within that string using a single
/// string containing embedded range markers, using arbitrary characters as
/// range markers. By using multiple different range markers, you can construct
/// ranges that overlap each other.
///
/// The returned ranges will be grouped by their range marking characters.
pub fn marked_text_ranges_by(
marked_text: &str,
markers: Vec<TextRangeMarker>,
) -> (String, HashMap<TextRangeMarker, Vec<Range<usize>>>) {
let all_markers = markers.iter().flat_map(|m| m.markers()).collect();
let (unmarked_text, mut marker_offsets) = marked_text_offsets_by(marked_text, all_markers);
let range_lookup = markers
.into_iter()
.map(|marker| {
(
marker.clone(),
match marker {
TextRangeMarker::Empty(empty_marker_char) => marker_offsets
.remove(&empty_marker_char)
.unwrap_or_default()
.into_iter()
.map(|empty_index| empty_index..empty_index)
.collect::<Vec<Range<usize>>>(),
TextRangeMarker::Range(start_marker, end_marker) => {
let starts = marker_offsets.remove(&start_marker).unwrap_or_default();
let ends = marker_offsets.remove(&end_marker).unwrap_or_default();
assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced");
starts
.into_iter()
.zip(ends)
.map(|(start, end)| {
assert!(end >= start, "marked ranges must be disjoint");
start..end
})
.collect::<Vec<Range<usize>>>()
}
TextRangeMarker::ReverseRange(start_marker, end_marker) => {
let starts = marker_offsets.remove(&start_marker).unwrap_or_default();
let ends = marker_offsets.remove(&end_marker).unwrap_or_default();
assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced");
starts
.into_iter()
.zip(ends)
.map(|(start, end)| {
assert!(end >= start, "marked ranges must be disjoint");
end..start
})
.collect::<Vec<Range<usize>>>()
}
},
)
})
.collect();
(unmarked_text, range_lookup)
}
/// Construct a string and a list of ranges within that string using a single
/// string containing embedded range markers. The characters used to mark the
/// ranges are as follows:
///
/// 1. To mark a range of text, surround it with the `«` and `»` angle brackets,
/// which can be typed on a US keyboard with the `alt-|` and `alt-shift-|` keys.
///
/// ```text
/// foo «selected text» bar
/// ```
///
/// 2. To mark a single position in the text, use the `ˇ` caron,
/// which can be typed on a US keyboard with the `alt-shift-t` key.
///
/// ```text
/// the cursors are hereˇ and hereˇ.
/// ```
///
/// 3. To mark a range whose direction is meaningful (like a selection),
/// put a caron character beside one of its bounds, on the inside:
///
/// ```text
/// one «ˇreversed» selection and one «forwardˇ» selection
/// ```
///
/// Any • characters in the input string will be replaced with spaces. This makes
/// it easier to test cases with trailing spaces, which tend to get trimmed from the
/// source code.
#[track_caller]
pub fn marked_text_ranges(
marked_text: &str,
ranges_are_directed: bool,
) -> (String, Vec<Range<usize>>) {
let mut unmarked_text = String::with_capacity(marked_text.len());
let mut ranges = Vec::new();
let mut prev_marked_ix = 0;
let mut current_range_start = None;
let mut current_range_cursor = None;
let marked_text = marked_text.replace('•', " ");
for (marked_ix, marker) in marked_text.match_indices(&['«', '»', 'ˇ']) {
unmarked_text.push_str(&marked_text[prev_marked_ix..marked_ix]);
let unmarked_len = unmarked_text.len();
let len = marker.len();
prev_marked_ix = marked_ix + len;
match marker {
"ˇ" => {
if current_range_start.is_some() {
if current_range_cursor.is_some() {
panic!("duplicate point marker 'ˇ' at index {marked_ix}");
}
current_range_cursor = Some(unmarked_len);
} else {
ranges.push(unmarked_len..unmarked_len);
}
}
"«" => {
if current_range_start.is_some() {
panic!("unexpected range start marker '«' at index {marked_ix}");
}
current_range_start = Some(unmarked_len);
}
"»" => {
let current_range_start = if let Some(start) = current_range_start.take() {
start
} else {
panic!("unexpected range end marker '»' at index {marked_ix}");
};
let mut reversed = false;
if let Some(current_range_cursor) = current_range_cursor.take() {
if current_range_cursor == current_range_start {
reversed = true;
} else if current_range_cursor != unmarked_len {
panic!("unexpected 'ˇ' marker in the middle of a range");
}
} else if ranges_are_directed {
panic!("missing 'ˇ' marker to indicate range direction");
}
ranges.push(if reversed {
unmarked_len..current_range_start
} else {
current_range_start..unmarked_len
});
}
_ => unreachable!(),
}
}
unmarked_text.push_str(&marked_text[prev_marked_ix..]);
(unmarked_text, ranges)
}
#[track_caller]
pub fn marked_text_offsets(marked_text: &str) -> (String, Vec<usize>) {
let (text, ranges) = marked_text_ranges(marked_text, false);
(
text,
ranges
.into_iter()
.map(|range| {
assert_eq!(range.start, range.end);
range.start
})
.collect(),
)
}
pub fn generate_marked_text(
unmarked_text: &str,
ranges: &[Range<usize>],
indicate_cursors: bool,
) -> String {
let mut marked_text = unmarked_text.to_string();
for range in ranges.iter().rev() {
if indicate_cursors {
match range.start.cmp(&range.end) {
Ordering::Less => {
marked_text.insert_str(range.end, "ˇ»");
marked_text.insert(range.start, '«');
}
Ordering::Equal => {
marked_text.insert(range.start, 'ˇ');
}
Ordering::Greater => {
marked_text.insert(range.start, '»');
marked_text.insert_str(range.end, "«ˇ");
}
}
} else {
match range.start.cmp(&range.end) {
Ordering::Equal => {
marked_text.insert(range.start, 'ˇ');
}
_ => {
marked_text.insert(range.end, '»');
marked_text.insert(range.start, '«');
}
}
}
}
marked_text
}
#[derive(Clone, Eq, PartialEq, Hash)]
pub enum TextRangeMarker {
Empty(char),
Range(char, char),
ReverseRange(char, char),
}
impl TextRangeMarker {
fn markers(&self) -> Vec<char> {
match self {
Self::Empty(m) => vec![*m],
Self::Range(l, r) => vec![*l, *r],
Self::ReverseRange(l, r) => vec![*l, *r],
}
}
}
impl From<char> for TextRangeMarker {
fn from(marker: char) -> Self {
Self::Empty(marker)
}
}
impl From<(char, char)> for TextRangeMarker {
fn from((left_marker, right_marker): (char, char)) -> Self {
Self::Range(left_marker, right_marker)
}
}
#[cfg(test)]
mod tests {
use super::{generate_marked_text, marked_text_ranges};
#[allow(clippy::reversed_empty_ranges)]
#[test]
fn test_marked_text() {
let (text, ranges) = marked_text_ranges("one «ˇtwo» «threeˇ» «ˇfour» fiveˇ six", true);
assert_eq!(text, "one two three four five six");
assert_eq!(ranges.len(), 4);
assert_eq!(ranges[0], 7..4);
assert_eq!(ranges[1], 8..13);
assert_eq!(ranges[2], 18..14);
assert_eq!(ranges[3], 23..23);
assert_eq!(
generate_marked_text(&text, &ranges, true),
"one «ˇtwo» «threeˇ» «ˇfour» fiveˇ six"
);
}
}

33
crates/util/src/time.rs Normal file
View File

@@ -0,0 +1,33 @@
use std::time::Duration;
pub fn duration_alt_display(duration: Duration) -> String {
let hours = duration.as_secs() / 3600;
let minutes = (duration.as_secs() % 3600) / 60;
let seconds = duration.as_secs() % 60;
if hours > 0 {
format!("{hours}h {minutes}m {seconds}s")
} else if minutes > 0 {
format!("{minutes}m {seconds}s")
} else {
format!("{seconds}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_duration_alt_display() {
use duration_alt_display as f;
assert_eq!("0s", f(Duration::from_secs(0)));
assert_eq!("59s", f(Duration::from_secs(59)));
assert_eq!("1m 0s", f(Duration::from_secs(60)));
assert_eq!("10m 0s", f(Duration::from_secs(600)));
assert_eq!("1h 0m 0s", f(Duration::from_secs(3600)));
assert_eq!("3h 2m 1s", f(Duration::from_secs(3600 * 3 + 60 * 2 + 1)));
assert_eq!("23h 59m 59s", f(Duration::from_secs(3600 * 24 - 1)));
assert_eq!("100h 0m 0s", f(Duration::from_secs(3600 * 100)));
}
}

1076
crates/util/src/util.rs Normal file

File diff suppressed because it is too large Load Diff