logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
69
crates/util/Cargo.toml
Normal file
69
crates/util/Cargo.toml
Normal 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
1
crates/util/LICENSE-APACHE
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-APACHE
|
||||
383
crates/util/src/archive.rs
Normal file
383
crates/util/src/archive.rs
Normal 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
140
crates/util/src/command.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
833
crates/util/src/command/darwin.rs
Normal file
833
crates/util/src/command/darwin.rs
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
202
crates/util/src/disambiguate.rs
Normal file
202
crates/util/src/disambiguate.rs
Normal 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
111
crates/util/src/fs.rs
Normal 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
376
crates/util/src/markdown.rs
Normal 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. `<` 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, "<")?;
|
||||
start_of_unescaped = None;
|
||||
}
|
||||
// Escaped since `>` is used for blockquotes. `>` 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, ">")?;
|
||||
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:
|
||||
"#;
|
||||
|
||||
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: \
|
||||
"#;
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
233
crates/util/src/path_list.rs
Normal file
233
crates/util/src/path_list.rs
Normal 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
3562
crates/util/src/paths.rs
Normal file
File diff suppressed because it is too large
Load Diff
92
crates/util/src/process.rs
Normal file
92
crates/util/src/process.rs
Normal 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
49
crates/util/src/redact.rs
Normal 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: ®ex::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
638
crates/util/src/rel_path.rs
Normal 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(), "");
|
||||
}
|
||||
}
|
||||
72
crates/util/src/schemars.rs
Normal file
72
crates/util/src/schemars.rs
Normal 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
7
crates/util/src/serde.rs
Normal 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
1051
crates/util/src/shell.rs
Normal file
File diff suppressed because it is too large
Load Diff
327
crates/util/src/shell_builder.rs
Normal file
327
crates/util/src/shell_builder.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
351
crates/util/src/shell_env.rs
Normal file
351
crates/util/src/shell_env.rs
Normal 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("ed_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
46
crates/util/src/size.rs
Normal 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
80
crates/util/src/test.rs
Normal 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
|
||||
}
|
||||
62
crates/util/src/test/assertions.rs
Normal file
62
crates/util/src/test/assertions.rs
Normal 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);
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}};
|
||||
}
|
||||
0
crates/util/src/test/git.rs
Normal file
0
crates/util/src/test/git.rs
Normal file
281
crates/util/src/test/marked_text.rs
Normal file
281
crates/util/src/test/marked_text.rs
Normal 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
33
crates/util/src/time.rs
Normal 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
1076
crates/util/src/util.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user