update
This commit is contained in:
+194
@@ -0,0 +1,194 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# Local copy keeps the registry manifest shape and narrows log features so
|
||||
# GPUI and Servo can share the same workspace dependency graph.
|
||||
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "gpui_util"
|
||||
version = "0.2.2"
|
||||
build = false
|
||||
publish = true
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "A collection of utility structs and functions used by Zed and GPUI"
|
||||
readme = false
|
||||
license = "Apache-2.0"
|
||||
resolver = "2"
|
||||
|
||||
[features]
|
||||
test-support = [
|
||||
"git2",
|
||||
"rand",
|
||||
"util_macros",
|
||||
]
|
||||
|
||||
[lib]
|
||||
name = "gpui_util"
|
||||
path = "src/util.rs"
|
||||
doctest = true
|
||||
|
||||
[dependencies.anyhow]
|
||||
version = "1.0.86"
|
||||
|
||||
[dependencies.async-fs]
|
||||
version = "2.1"
|
||||
|
||||
[dependencies.async_zip]
|
||||
version = "0.0.17"
|
||||
features = [
|
||||
"deflate",
|
||||
"deflate64",
|
||||
]
|
||||
|
||||
[dependencies.collections]
|
||||
version = "0.2.2"
|
||||
package = "gpui_collections"
|
||||
|
||||
[dependencies.dirs]
|
||||
version = "4.0"
|
||||
|
||||
[dependencies.dunce]
|
||||
version = "1.0"
|
||||
|
||||
[dependencies.futures]
|
||||
version = "0.3"
|
||||
|
||||
[dependencies.futures-lite]
|
||||
version = "1.13"
|
||||
|
||||
[dependencies.git2]
|
||||
version = "0.20.1"
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dependencies.globset]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.itertools]
|
||||
version = "0.14.0"
|
||||
|
||||
[dependencies.log]
|
||||
version = "0.4.16"
|
||||
|
||||
[dependencies.rand]
|
||||
version = "0.9"
|
||||
optional = true
|
||||
|
||||
[dependencies.regex]
|
||||
version = "1.5"
|
||||
|
||||
[dependencies.rust-embed]
|
||||
version = "8.4"
|
||||
features = ["include-exclude"]
|
||||
|
||||
[dependencies.schemars]
|
||||
version = "1.0"
|
||||
features = ["indexmap2"]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1.0.221"
|
||||
features = [
|
||||
"derive",
|
||||
"rc",
|
||||
]
|
||||
|
||||
[dependencies.serde_json]
|
||||
version = "1.0.144"
|
||||
features = [
|
||||
"preserve_order",
|
||||
"raw_value",
|
||||
]
|
||||
|
||||
[dependencies.serde_json_lenient]
|
||||
version = "0.2"
|
||||
features = [
|
||||
"preserve_order",
|
||||
"raw_value",
|
||||
]
|
||||
|
||||
[dependencies.shlex]
|
||||
version = "1.3.0"
|
||||
|
||||
[dependencies.smol]
|
||||
version = "2.0"
|
||||
|
||||
[dependencies.take-until]
|
||||
version = "0.2.0"
|
||||
|
||||
[dependencies.tempfile]
|
||||
version = "3.20.0"
|
||||
|
||||
[dependencies.unicase]
|
||||
version = "2.6"
|
||||
|
||||
[dependencies.util_macros]
|
||||
version = "0.2.2"
|
||||
optional = true
|
||||
package = "gpui_util_macros"
|
||||
|
||||
[dependencies.walkdir]
|
||||
version = "2.5"
|
||||
|
||||
[dependencies.which]
|
||||
version = "6.0.0"
|
||||
|
||||
[dev-dependencies.git2]
|
||||
version = "0.20.1"
|
||||
default-features = false
|
||||
|
||||
[dev-dependencies.indoc]
|
||||
version = "2"
|
||||
|
||||
[dev-dependencies.pretty_assertions]
|
||||
version = "1.3.0"
|
||||
features = ["unstable"]
|
||||
|
||||
[dev-dependencies.rand]
|
||||
version = "0.9"
|
||||
|
||||
[dev-dependencies.util_macros]
|
||||
version = "0.2.2"
|
||||
package = "gpui_util_macros"
|
||||
|
||||
[target."cfg(unix)".dependencies.command-fds]
|
||||
version = "0.3.1"
|
||||
|
||||
[target."cfg(unix)".dependencies.libc]
|
||||
version = "0.2"
|
||||
|
||||
[target."cfg(unix)".dependencies.nix]
|
||||
version = "0.29"
|
||||
features = ["user"]
|
||||
|
||||
[target."cfg(windows)".dependencies.tendril]
|
||||
version = "0.4.3"
|
||||
|
||||
[lints.clippy]
|
||||
dbg_macro = "deny"
|
||||
declare_interior_mutable_const = "deny"
|
||||
disallowed_methods = "deny"
|
||||
large_enum_variant = "allow"
|
||||
let_underscore_future = "allow"
|
||||
nonminimal_bool = "allow"
|
||||
redundant_clone = "deny"
|
||||
single_range_in_vec_init = "allow"
|
||||
todo = "deny"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
[lints.clippy.style]
|
||||
level = "allow"
|
||||
priority = -1
|
||||
|
||||
[lints.rust.unexpected_cfgs]
|
||||
level = "allow"
|
||||
priority = 0
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
Copyright 2022 - 2025 Zed Industries, Inc.
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
|
||||
1. Definitions.
|
||||
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
cmp::Ordering,
|
||||
fmt::{self, Debug},
|
||||
hash::{Hash, Hasher},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub enum ArcCow<'a, T: ?Sized> {
|
||||
Borrowed(&'a T),
|
||||
Owned(Arc<T>),
|
||||
}
|
||||
|
||||
impl<T: ?Sized + PartialEq> PartialEq for ArcCow<'_, T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
let a = self.as_ref();
|
||||
let b = other.as_ref();
|
||||
a == b
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized + PartialOrd> PartialOrd for ArcCow<'_, T> {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.as_ref().partial_cmp(other.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized + Ord> Ord for ArcCow<'_, T> {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.as_ref().cmp(other.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized + Eq> Eq for ArcCow<'_, T> {}
|
||||
|
||||
impl<T: ?Sized + Hash> Hash for ArcCow<'_, T> {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
match self {
|
||||
Self::Borrowed(borrowed) => Hash::hash(borrowed, state),
|
||||
Self::Owned(owned) => Hash::hash(&**owned, state),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> Clone for ArcCow<'_, T> {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
Self::Borrowed(borrowed) => Self::Borrowed(borrowed),
|
||||
Self::Owned(owned) => Self::Owned(owned.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: ?Sized> From<&'a T> for ArcCow<'a, T> {
|
||||
fn from(s: &'a T) -> Self {
|
||||
Self::Borrowed(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> From<Arc<T>> for ArcCow<'_, T> {
|
||||
fn from(s: Arc<T>) -> Self {
|
||||
Self::Owned(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> From<&'_ Arc<T>> for ArcCow<'_, T> {
|
||||
fn from(s: &'_ Arc<T>) -> Self {
|
||||
Self::Owned(s.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ArcCow<'_, str> {
|
||||
fn from(value: String) -> Self {
|
||||
Self::Owned(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&String> for ArcCow<'_, str> {
|
||||
fn from(value: &String) -> Self {
|
||||
Self::Owned(value.clone().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<Cow<'a, str>> for ArcCow<'a, str> {
|
||||
fn from(value: Cow<'a, str>) -> Self {
|
||||
match value {
|
||||
Cow::Borrowed(borrowed) => Self::Borrowed(borrowed),
|
||||
Cow::Owned(owned) => Self::Owned(owned.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Vec<T>> for ArcCow<'_, [T]> {
|
||||
fn from(vec: Vec<T>) -> Self {
|
||||
ArcCow::Owned(Arc::from(vec))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for ArcCow<'a, [u8]> {
|
||||
fn from(s: &'a str) -> Self {
|
||||
ArcCow::Borrowed(s.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized + ToOwned> std::borrow::Borrow<T> for ArcCow<'_, T> {
|
||||
fn borrow(&self) -> &T {
|
||||
match self {
|
||||
ArcCow::Borrowed(borrowed) => borrowed,
|
||||
ArcCow::Owned(owned) => owned.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> std::ops::Deref for ArcCow<'_, T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
match self {
|
||||
ArcCow::Borrowed(s) => s,
|
||||
ArcCow::Owned(s) => s.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> AsRef<T> for ArcCow<'_, T> {
|
||||
fn as_ref(&self) -> &T {
|
||||
match self {
|
||||
ArcCow::Borrowed(borrowed) => borrowed,
|
||||
ArcCow::Owned(owned) => owned.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized + Debug> Debug for ArcCow<'_, T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ArcCow::Borrowed(borrowed) => Debug::fmt(borrowed, f),
|
||||
ArcCow::Owned(owned) => Debug::fmt(&**owned, f),
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
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(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 path = destination.join(
|
||||
entry
|
||||
.filename()
|
||||
.as_str()
|
||||
.context("reading zip entry file name")?,
|
||||
);
|
||||
|
||||
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(not(windows))]
|
||||
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(not(windows))]
|
||||
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 path = destination.join(
|
||||
entry
|
||||
.filename()
|
||||
.as_str()
|
||||
.context("reading zip entry file name")?,
|
||||
);
|
||||
|
||||
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() {
|
||||
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::*;
|
||||
|
||||
async fn compress_zip(src_dir: &Path, dst: &Path) -> 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 = metadata.permissions().mode() as u16;
|
||||
builder = builder.unix_permissions(perms);
|
||||
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?;
|
||||
|
||||
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).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).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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000_u32;
|
||||
|
||||
#[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(target_os = "windows")]
|
||||
pub fn new_smol_command(program: impl AsRef<OsStr>) -> smol::process::Command {
|
||||
use smol::process::windows::CommandExt;
|
||||
|
||||
let mut command = smol::process::Command::new(program);
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn new_smol_command(program: impl AsRef<OsStr>) -> smol::process::Command {
|
||||
smol::process::Command::new(program)
|
||||
}
|
||||
+111
@@ -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(())
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1934
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
/// 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))
|
||||
}
|
||||
+579
@@ -0,0 +1,579 @@
|
||||
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(Clone, Serialize, Deserialize)]
|
||||
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> {
|
||||
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(anyhow!("failed to strip prefix: {other:?} from {self:?}"))
|
||||
}
|
||||
|
||||
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 => Cow::Owned(self.0.replace('/', "\\")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 Into<Arc<RelPath>> for RelPathBuf {
|
||||
fn into(self) -> Arc<RelPath> {
|
||||
Arc::from(self.as_rel_path())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<RelPath> for RelPathBuf {
|
||||
fn as_ref(&self) -> &RelPath {
|
||||
self.as_rel_path()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
impl PartialEq<str> for RelPath {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
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(), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn is_default<T: Default + PartialEq>(value: &T) -> bool {
|
||||
*value == T::default()
|
||||
}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{borrow::Cow, fmt, path::Path, sync::LazyLock};
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ShellKind {
|
||||
#[default]
|
||||
Posix,
|
||||
Csh,
|
||||
Tcsh,
|
||||
Rc,
|
||||
Fish,
|
||||
PowerShell,
|
||||
Nushell,
|
||||
Cmd,
|
||||
Xonsh,
|
||||
}
|
||||
|
||||
pub fn get_system_shell() -> String {
|
||||
if cfg!(windows) {
|
||||
get_windows_system_shell()
|
||||
} else {
|
||||
std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_default_system_shell() -> String {
|
||||
if cfg!(windows) {
|
||||
get_windows_system_shell()
|
||||
} else {
|
||||
"/bin/sh".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default system shell, preferring git-bash on Windows.
|
||||
pub fn get_default_system_shell_preferring_bash() -> String {
|
||||
if cfg!(windows) {
|
||||
get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
|
||||
} else {
|
||||
"/bin/sh".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_windows_git_bash() -> Option<String> {
|
||||
static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
|
||||
// /path/to/git/cmd/git.exe/../../bin/bash.exe
|
||||
let git = which::which("git").ok()?;
|
||||
let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
|
||||
if git_bash.is_file() {
|
||||
log::info!("Found git-bash at {}", git_bash.display());
|
||||
Some(git_bash.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
(*GIT_BASH).clone()
|
||||
}
|
||||
|
||||
pub fn get_windows_system_shell() -> String {
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
let env_var = if find_alternate {
|
||||
"ProgramFiles(x86)"
|
||||
} else {
|
||||
"ProgramFiles"
|
||||
};
|
||||
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
let env_var = if find_alternate {
|
||||
"ProgramW6432"
|
||||
} else {
|
||||
"ProgramFiles"
|
||||
};
|
||||
|
||||
let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
|
||||
install_base_dir
|
||||
.read_dir()
|
||||
.ok()?
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
|
||||
.filter_map(|entry| {
|
||||
let dir_name = entry.file_name();
|
||||
let dir_name = dir_name.to_string_lossy();
|
||||
|
||||
let version = if find_preview {
|
||||
let dash_index = dir_name.find('-')?;
|
||||
if &dir_name[dash_index + 1..] != "preview" {
|
||||
return None;
|
||||
};
|
||||
dir_name[..dash_index].parse::<u32>().ok()?
|
||||
} else {
|
||||
dir_name.parse::<u32>().ok()?
|
||||
};
|
||||
|
||||
let exe_path = entry.path().join("pwsh.exe");
|
||||
if exe_path.exists() {
|
||||
Some((version, exe_path))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.max_by_key(|(version, _)| *version)
|
||||
.map(|(_, path)| path)
|
||||
}
|
||||
|
||||
fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
|
||||
let msix_app_dir =
|
||||
PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
|
||||
if !msix_app_dir.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = if find_preview {
|
||||
"Microsoft.PowerShellPreview_"
|
||||
} else {
|
||||
"Microsoft.PowerShell_"
|
||||
};
|
||||
msix_app_dir
|
||||
.read_dir()
|
||||
.ok()?
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !entry.file_name().to_string_lossy().starts_with(prefix) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let exe_path = entry.path().join("pwsh.exe");
|
||||
exe_path.exists().then_some(exe_path)
|
||||
})
|
||||
.next()
|
||||
}
|
||||
|
||||
fn find_pwsh_in_scoop() -> Option<PathBuf> {
|
||||
let pwsh_exe =
|
||||
PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
|
||||
pwsh_exe.exists().then_some(pwsh_exe)
|
||||
}
|
||||
|
||||
static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
|
||||
find_pwsh_in_programfiles(false, false)
|
||||
.or_else(|| find_pwsh_in_programfiles(true, false))
|
||||
.or_else(|| find_pwsh_in_msix(false))
|
||||
.or_else(|| find_pwsh_in_programfiles(false, true))
|
||||
.or_else(|| find_pwsh_in_msix(true))
|
||||
.or_else(|| find_pwsh_in_programfiles(true, true))
|
||||
.or_else(find_pwsh_in_scoop)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or("powershell.exe".to_string())
|
||||
});
|
||||
|
||||
(*SYSTEM_SHELL).clone()
|
||||
}
|
||||
|
||||
impl fmt::Display for ShellKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ShellKind::Posix => write!(f, "sh"),
|
||||
ShellKind::Csh => write!(f, "csh"),
|
||||
ShellKind::Tcsh => write!(f, "tcsh"),
|
||||
ShellKind::Fish => write!(f, "fish"),
|
||||
ShellKind::PowerShell => write!(f, "powershell"),
|
||||
ShellKind::Nushell => write!(f, "nu"),
|
||||
ShellKind::Cmd => write!(f, "cmd"),
|
||||
ShellKind::Rc => write!(f, "rc"),
|
||||
ShellKind::Xonsh => write!(f, "xonsh"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellKind {
|
||||
pub fn system() -> Self {
|
||||
Self::new(&get_system_shell(), cfg!(windows))
|
||||
}
|
||||
|
||||
pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
|
||||
let program = program.as_ref();
|
||||
let program = program
|
||||
.file_stem()
|
||||
.unwrap_or_else(|| program.as_os_str())
|
||||
.to_string_lossy();
|
||||
|
||||
if program == "powershell" || program == "pwsh" {
|
||||
ShellKind::PowerShell
|
||||
} else if program == "cmd" {
|
||||
ShellKind::Cmd
|
||||
} else if program == "nu" {
|
||||
ShellKind::Nushell
|
||||
} else if program == "fish" {
|
||||
ShellKind::Fish
|
||||
} else if program == "csh" {
|
||||
ShellKind::Csh
|
||||
} else if program == "tcsh" {
|
||||
ShellKind::Tcsh
|
||||
} else if program == "rc" {
|
||||
ShellKind::Rc
|
||||
} else if program == "xonsh" {
|
||||
ShellKind::Xonsh
|
||||
} else if program == "sh" || program == "bash" {
|
||||
ShellKind::Posix
|
||||
} else {
|
||||
if is_windows {
|
||||
ShellKind::PowerShell
|
||||
} else {
|
||||
// Some other shell detected, the user might install and use a
|
||||
// unix-like shell.
|
||||
ShellKind::Posix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_shell_variable(self, input: &str) -> String {
|
||||
match self {
|
||||
Self::PowerShell => Self::to_powershell_variable(input),
|
||||
Self::Cmd => Self::to_cmd_variable(input),
|
||||
Self::Posix => input.to_owned(),
|
||||
Self::Fish => input.to_owned(),
|
||||
Self::Csh => input.to_owned(),
|
||||
Self::Tcsh => input.to_owned(),
|
||||
Self::Rc => input.to_owned(),
|
||||
Self::Nushell => Self::to_nushell_variable(input),
|
||||
Self::Xonsh => input.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cmd_variable(input: &str) -> String {
|
||||
if let Some(var_str) = input.strip_prefix("${") {
|
||||
if var_str.find(':').is_none() {
|
||||
// If the input starts with "${", remove the trailing "}"
|
||||
format!("%{}%", &var_str[..var_str.len() - 1])
|
||||
} else {
|
||||
// `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
|
||||
// which will result in the task failing to run in such cases.
|
||||
input.into()
|
||||
}
|
||||
} else if let Some(var_str) = input.strip_prefix('$') {
|
||||
// If the input starts with "$", directly append to "$env:"
|
||||
format!("%{}%", var_str)
|
||||
} else {
|
||||
// If no prefix is found, return the input as is
|
||||
input.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn to_powershell_variable(input: &str) -> String {
|
||||
if let Some(var_str) = input.strip_prefix("${") {
|
||||
if var_str.find(':').is_none() {
|
||||
// If the input starts with "${", remove the trailing "}"
|
||||
format!("$env:{}", &var_str[..var_str.len() - 1])
|
||||
} else {
|
||||
// `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
|
||||
// which will result in the task failing to run in such cases.
|
||||
input.into()
|
||||
}
|
||||
} else if let Some(var_str) = input.strip_prefix('$') {
|
||||
// If the input starts with "$", directly append to "$env:"
|
||||
format!("$env:{}", var_str)
|
||||
} else {
|
||||
// If no prefix is found, return the input as is
|
||||
input.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn to_nushell_variable(input: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut source = input;
|
||||
let mut is_start = true;
|
||||
|
||||
loop {
|
||||
match source.chars().next() {
|
||||
None => return result,
|
||||
Some('$') => {
|
||||
source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
|
||||
is_start = false;
|
||||
}
|
||||
Some(_) => {
|
||||
is_start = false;
|
||||
let chunk_end = source.find('$').unwrap_or(source.len());
|
||||
let (chunk, rest) = source.split_at(chunk_end);
|
||||
result.push_str(chunk);
|
||||
source = rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
|
||||
if source.starts_with("env.") {
|
||||
text.push('$');
|
||||
return source;
|
||||
}
|
||||
|
||||
match source.chars().next() {
|
||||
Some('{') => {
|
||||
let source = &source[1..];
|
||||
if let Some(end) = source.find('}') {
|
||||
let var_name = &source[..end];
|
||||
if !var_name.is_empty() {
|
||||
if !is_start {
|
||||
text.push_str("(");
|
||||
}
|
||||
text.push_str("$env.");
|
||||
text.push_str(var_name);
|
||||
if !is_start {
|
||||
text.push_str(")");
|
||||
}
|
||||
&source[end + 1..]
|
||||
} else {
|
||||
text.push_str("${}");
|
||||
&source[end + 1..]
|
||||
}
|
||||
} else {
|
||||
text.push_str("${");
|
||||
source
|
||||
}
|
||||
}
|
||||
Some(c) if c.is_alphabetic() || c == '_' => {
|
||||
let end = source
|
||||
.find(|c: char| !c.is_alphanumeric() && c != '_')
|
||||
.unwrap_or(source.len());
|
||||
let var_name = &source[..end];
|
||||
if !is_start {
|
||||
text.push_str("(");
|
||||
}
|
||||
text.push_str("$env.");
|
||||
text.push_str(var_name);
|
||||
if !is_start {
|
||||
text.push_str(")");
|
||||
}
|
||||
&source[end..]
|
||||
}
|
||||
_ => {
|
||||
text.push('$');
|
||||
source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
|
||||
match self {
|
||||
ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
|
||||
ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
|
||||
ShellKind::Posix
|
||||
| ShellKind::Nushell
|
||||
| ShellKind::Fish
|
||||
| ShellKind::Csh
|
||||
| ShellKind::Tcsh
|
||||
| ShellKind::Rc
|
||||
| ShellKind::Xonsh => interactive
|
||||
.then(|| "-i".to_owned())
|
||||
.into_iter()
|
||||
.chain(["-c".to_owned(), combined_command])
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn command_prefix(&self) -> Option<char> {
|
||||
match self {
|
||||
ShellKind::PowerShell => Some('&'),
|
||||
ShellKind::Nushell => Some('^'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn sequential_commands_separator(&self) -> char {
|
||||
match self {
|
||||
ShellKind::Cmd => '&',
|
||||
_ => ';',
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
|
||||
shlex::try_quote(arg).ok().map(|arg| match self {
|
||||
// If we are running in PowerShell, we want to take extra care when escaping strings.
|
||||
// In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
|
||||
// TODO double escaping backslashes is not necessary in PowerShell and probably CMD
|
||||
ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"")),
|
||||
_ => arg,
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn activate_keyword(&self) -> &'static str {
|
||||
match self {
|
||||
ShellKind::Cmd => "",
|
||||
ShellKind::Nushell => "overlay use",
|
||||
ShellKind::PowerShell => ".",
|
||||
ShellKind::Fish => "source",
|
||||
ShellKind::Csh => "source",
|
||||
ShellKind::Tcsh => "source",
|
||||
ShellKind::Posix | ShellKind::Rc => "source",
|
||||
ShellKind::Xonsh => "source",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn clear_screen_command(&self) -> &'static str {
|
||||
match self {
|
||||
ShellKind::Cmd => "cls",
|
||||
_ => "clear",
|
||||
}
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
|
||||
use crate::shell::ShellKind;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#[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 std::process::Stdio;
|
||||
|
||||
let zed_path = super::get_shell_safe_zed_path()?;
|
||||
let shell_kind = ShellKind::new(shell_path, false);
|
||||
|
||||
let mut command_string = String::new();
|
||||
let mut command = std::process::Command::new(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()),
|
||||
_ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0`
|
||||
};
|
||||
command.stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped());
|
||||
command.stderr(Stdio::piped());
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
// cd into the directory, triggering directory specific side-effects (asdf, direnv, etc)
|
||||
command_string.push_str(&format!("cd '{}';", directory.display()));
|
||||
if let Some(prefix) = shell_kind.command_prefix() {
|
||||
command_string.push(prefix);
|
||||
}
|
||||
command_string.push_str(&format!("{} --printenv {}", zed_path, redir));
|
||||
command.args(["-i", "-c", &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);
|
||||
|
||||
anyhow::ensure!(
|
||||
process_output.status.success(),
|
||||
"login shell exited with {}. stdout: {:?}, stderr: {:?}",
|
||||
process_output.status,
|
||||
String::from_utf8_lossy(&process_output.stdout),
|
||||
String::from_utf8_lossy(&process_output.stderr),
|
||||
);
|
||||
|
||||
// Parse the JSON output from zed --printenv
|
||||
let env_map: collections::HashMap<String, String> = serde_json::from_str(&env_output)
|
||||
.with_context(|| "Failed to deserialize environment variables from json")?;
|
||||
Ok(env_map)
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
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).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);
|
||||
let env_output = match shell_kind {
|
||||
ShellKind::Posix
|
||||
| ShellKind::Csh
|
||||
| ShellKind::Tcsh
|
||||
| ShellKind::Rc
|
||||
| ShellKind::Fish
|
||||
| ShellKind::Xonsh => {
|
||||
return Err(anyhow::anyhow!("unsupported shell kind"));
|
||||
}
|
||||
ShellKind::PowerShell => {
|
||||
let output = crate::command::new_smol_command(shell_path)
|
||||
.args([
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
&format!(
|
||||
"Set-Location '{}'; & '{}' --printenv",
|
||||
directory.display(),
|
||||
zed_path.display()
|
||||
),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"PowerShell command failed with {}. stdout: {:?}, stderr: {:?}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
output
|
||||
}
|
||||
ShellKind::Nushell => {
|
||||
let output = crate::command::new_smol_command(shell_path)
|
||||
.args([
|
||||
"-c",
|
||||
&format!(
|
||||
"cd '{}'; {}{} --printenv",
|
||||
directory.display(),
|
||||
shell_kind
|
||||
.command_prefix()
|
||||
.map(|prefix| prefix.to_string())
|
||||
.unwrap_or_default(),
|
||||
zed_path.display()
|
||||
),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Nushell command failed with {}. stdout: {:?}, stderr: {:?}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
output
|
||||
}
|
||||
ShellKind::Cmd => {
|
||||
let output = crate::command::new_smol_command(shell_path)
|
||||
.args([
|
||||
"/c",
|
||||
&format!(
|
||||
"cd '{}'; {} --printenv",
|
||||
directory.display(),
|
||||
zed_path.display()
|
||||
),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Cmd command failed with {}. stdout: {:?}, stderr: {:?}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
output
|
||||
}
|
||||
};
|
||||
|
||||
let env_output = String::from_utf8_lossy(&env_output.stdout);
|
||||
|
||||
// Parse the JSON output from zed --printenv
|
||||
serde_json::from_str(&env_output)
|
||||
.with_context(|| "Failed to deserialize environment variables from json")
|
||||
}
|
||||
+46
@@ -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");
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
mod assertions;
|
||||
mod marked_text;
|
||||
|
||||
use git2;
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub use assertions::*;
|
||||
pub use marked_text::*;
|
||||
|
||||
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();
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn duration_alt_display(duration: Duration) -> String {
|
||||
if duration < Duration::from_secs(60) {
|
||||
format!("{}s", duration.as_secs())
|
||||
} else {
|
||||
duration_clock_format(duration)
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_clock_format(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}:{minutes:02}:{seconds:02}")
|
||||
} else if minutes > 0 {
|
||||
format!("{minutes}:{seconds:02}")
|
||||
} else {
|
||||
format!("{seconds}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_duration_to_clock_format() {
|
||||
use duration_clock_format as f;
|
||||
assert_eq!("0", f(Duration::from_secs(0)));
|
||||
assert_eq!("59", f(Duration::from_secs(59)));
|
||||
assert_eq!("1:00", f(Duration::from_secs(60)));
|
||||
assert_eq!("10:00", f(Duration::from_secs(600)));
|
||||
assert_eq!("1:00:00", f(Duration::from_secs(3600)));
|
||||
assert_eq!("3:02:01", f(Duration::from_secs(3600 * 3 + 60 * 2 + 1)));
|
||||
assert_eq!("23:59:59", f(Duration::from_secs(3600 * 24 - 1)));
|
||||
assert_eq!("100:00:00", f(Duration::from_secs(3600 * 100)));
|
||||
}
|
||||
}
|
||||
+1310
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user