1
0
mirror of https://github.com/Llewellynvdm/starship.git synced 2024-05-28 22:20:53 +00:00

refactor(directory): Introduce logical-path argument which allows a shell to explicitly specify both a logical and physical filesystem path (#2104)

* refactor(directory): Introduce `logical-path` argument which allows a shell to explicitly specify both a logical and physical filesystem path

Fix `directory::module` to consume both path and logical-path (if provided).  The "logical" path is preferred when rendering the "display path", while the "physical" path is used to resolve the "read only" flag. Repo- and home-directory contraction behavior is maintained, based on the logical path if it is set, or the physical path if it is not.

The custom "get_current_dir" logic has been removed entirely, and the `directory` module now relies on `context.current_dir` / `context.logical_dir` entirely.

Changes have been made to `init/starship.ps1` to work with this new flag:
- Calculate and pass "physical" and "logical" paths explicitly (as other shells do not pass `--logical-path` that they fall back to rendering the physical path)
- Moved the "powershell provider prefix" cleanup code to the PowerShell script - this code _should_ now support any kind of powershell path prefix.

* fix(powershell): Fix an issue with trailing backslashes on file paths causing command line parsing issues.

This is a bit of a footgun!
The work-around chosen is to append a trailing space when a path string ends with a backslash, and then trim any extra whitespace away in the Context constructor.
Other alternatives considered and rejected:
1. Always trim trailing backslashes as the filesystem generally doesn't need them.
2. Escape trailing backslashes with another backslash. This proved complex as PS only quotes string args when the string includes some whitespace, and other backslashes within the string apparently don't need to be escaped.

* fix(powershell): Use Invoke-Native pattern for safely invoking native executables with strings which may contain characters which need to be escaped carefully.

* fix(context): Remove superfluous argument trims

These were in place to clean up extra whitespace sometimes injected by starship.ps1::prompt, and are no longer required with the new Invoke-Native helper in place.

* refactor(directory): Clean up the semantics of `logical_dir` defaulting it to `current_dir` but overridable by the `--logical-dir` flag.

- Restore `use_logical_path` config flag.
- Always attempt to contract repo paths from the `current_dir`.

* fix(directory) :Use logical_dir for contracting the home directory

This keeps the two calls to contract_path in sync.

* fix(directory): Remove test script

* refactor(directory): Convert current_dir to canonical filesystem path when use_logical_path = false

- This requires some clean-up to remove the extended-path prefix on Windows
- The configured logical_dir is ignored entirely in this mode - we calculate a new logical_dir by cleaning up the physical_dir path for display.
- Test coverage

* fix(directory): Use AsRef style for passing Path arguments

* fix(directory): Strip the windows extended-path prefix from the display string later in the render process

* fix(docs): Update docs/config/README.md for use_logical_path

* refactor(context): Populate `current_dir` from `--path` or `std::env::current_dir`, populate `logical_dir` from `--logical-path` or the `PWD` env var

- `current_dir` is always canonicalized
- On Windows, `current_dir` will have an extended-path prefix
- `logical_dir` is now always set
- `directory::module` now just selects between `current_dir` and `logical_dir` when picking which path to render
- Test coverage

* fix(directory): Fix path comparison operations in directory to ignore differences between path prefixes

- Added PathExt extension trait which adds `normalised_equals`, `normalised_starts_with` and `without_prefix`

* fix(path): Add test coverage for PathExt on *nix

* fix(directory): Test coverage for `contract_repo_path`, `contract_path` with variations of verbatim and non-verbatim paths

* fix(directory): Update path-slash to latest

This fixes the issue with the trailing character of some Windows paths being truncated, e.g. `\\server\share` and `C:`

* fix(powershell): Improve UTF8 output handling, argument encoding

- Use `ProcessStartInfo` to launch native executable, replacing manual UTF8 output encoding handling
- If we detect we're on PWSH6+ use the new `System.Diagnostics.ProcessStartInfo.ArgumentList` parameter, otherwise manually escape the argument string
- Move `Get-Cwd` and `Invoke-Native` into the prompt function scope so that they don't leak into the user's shell scope

* fix(path): Make PathExt methods no-ops on *nix

* fix(path): Cargo fmt

* fix(powershell): Remove typo ';'. Fix variable assignment lint.
This commit is contained in:
Benjamin Fox 2021-02-09 03:14:59 +13:00 committed by GitHub
parent 30bd02c9cf
commit 20d845f9b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 785 additions and 167 deletions

View File

@ -666,11 +666,11 @@ it would have been `nixpkgs/pkgs`.
<details>
<summary>This module has a few advanced configuration options that control how the directory is displayed.</summary>
| Advanced Option | Default | Description |
| --------------------------- | ------- | ---------------------------------------------------------------------------------------- |
| `substitutions` | | A table of substitutions to be made to the path. |
| `fish_style_pwd_dir_length` | `0` | The number of characters to use when applying fish shell pwd path logic. |
| `use_logical_path` | `true` | Displays the logical path provided by the shell (`PWD`) instead of the path from the OS. |
| Advanced Option | Default | Description |
| --------------------------- | ------- | ------------------------------------------------------------------------- |
| `substitutions` | | A table of substitutions to be made to the path. |
| `fish_style_pwd_dir_length` | `0` | The number of characters to use when applying fish shell pwd path logic. |
| `use_logical_path` | `true` | If `true` render the logical path sourced from the shell via `PWD` or `--logical-path`. If `false` instead render the physical filesystem path with symlinks resolved. |
`substitutions` allows you to define arbitrary replacements for literal strings that occur in the path, for example long network
prefixes or development directories (i.e. Java). Note that this will disable the fish style PWD.

View File

@ -25,8 +25,8 @@ impl<'a> RootModuleConfig<'a> for DirectoryConfig<'a> {
truncation_length: 3,
truncate_to_repo: true,
fish_style_pwd_dir_length: 0,
substitutions: IndexMap::new(),
use_logical_path: true,
substitutions: IndexMap::new(),
format: "[$path]($style)[$read_only]($read_only_style) ",
style: "cyan bold",
disabled: false,

View File

@ -24,6 +24,11 @@ pub struct Context<'a> {
/// The current working directory that starship is being called in.
pub current_dir: PathBuf,
/// A logical directory path which should represent the same directory as current_dir,
/// though may appear different.
/// E.g. when navigating to a PSDrive in PowerShell, or a path without symlinks resolved.
pub logical_dir: PathBuf,
/// A struct containing directory contents in a lookup-optimised format.
dir_contents: OnceCell<DirContents>,
@ -42,27 +47,41 @@ pub struct Context<'a> {
impl<'a> Context<'a> {
/// Identify the current working directory and create an instance of Context
/// for it.
/// for it. "logical-path" is used when a shell allows the "current working directory"
/// to be something other than a file system path (like powershell provider specific paths).
pub fn new(arguments: ArgMatches) -> Context {
// Retrieve the "path" flag. If unavailable, use the current directory instead.
let shell = Context::get_shell();
// Retrieve the "current directory".
// If the path argument is not set fall back to the OS current directory.
let path = arguments
.value_of("path")
.map(From::from)
.map(PathBuf::from)
.unwrap_or_else(|| env::current_dir().expect("Unable to identify current directory"));
// Retrive the "logical directory".
// If the path argument is not set fall back to the PWD env variable set by many shells
// or to the other path.
let logical_path = arguments
.value_of("logical_path")
.map(PathBuf::from)
.unwrap_or_else(|| {
env::var("PWD").map(PathBuf::from).unwrap_or_else(|err| {
log::debug!("Unable to get path from $PWD: {}", err);
env::current_dir().expect("Unable to identify current directory. Error")
path.clone()
})
});
Context::new_with_dir(arguments, path)
Context::new_with_shell_and_path(arguments, shell, path, logical_path)
}
/// Create a new instance of Context for the provided directory
pub fn new_with_dir<T>(arguments: ArgMatches, dir: T) -> Context
where
T: Into<PathBuf>,
{
pub fn new_with_shell_and_path(
arguments: ArgMatches,
shell: Shell,
path: PathBuf,
logical_path: PathBuf,
) -> Context {
let config = StarshipConfig::initialize();
// Unwrap the clap arguments into a simple hashtable
@ -75,15 +94,17 @@ impl<'a> Context<'a> {
.map(|(a, b)| (*a, b.vals.first().cloned().unwrap().into_string().unwrap()))
.collect();
// TODO: Currently gets the physical directory. Get the logical directory.
let current_dir = Context::expand_tilde(dir.into());
let shell = Context::get_shell();
// Canonicalize the current path to resolve symlinks, etc.
// NOTE: On Windows this converts the path to extended-path syntax.
let current_dir = Context::expand_tilde(path);
let current_dir = current_dir.canonicalize().unwrap_or(current_dir);
let logical_dir = logical_path;
Context {
config,
properties,
current_dir,
logical_dir,
dir_contents: OnceCell::new(),
repo: OnceCell::new(),
shell,
@ -434,6 +455,7 @@ pub enum Shell {
#[cfg(test)]
mod tests {
use super::*;
use std::io;
fn testdir(paths: &[&str]) -> Result<tempfile::TempDir, std::io::Error> {
let dir = tempfile::tempdir()?;
@ -508,4 +530,83 @@ mod tests {
Ok(())
}
#[test]
fn context_constructor_should_canonicalize_current_dir() -> io::Result<()> {
#[cfg(not(windows))]
use std::os::unix::fs::symlink as symlink_dir;
#[cfg(windows)]
use std::os::windows::fs::symlink_dir;
let tmp_dir = tempfile::TempDir::new()?;
let path = tmp_dir.path().join("a/xxx/yyy");
fs::create_dir_all(&path)?;
// Set up a mock symlink
let path_actual = tmp_dir.path().join("a/xxx");
let path_symlink = tmp_dir.path().join("a/symlink");
symlink_dir(&path_actual, &path_symlink).expect("create symlink");
// Mock navigation into the symlink path
let test_path = path_symlink.join("yyy");
let context = Context::new_with_shell_and_path(
ArgMatches::default(),
Shell::Unknown,
test_path.clone(),
test_path.clone(),
);
assert_ne!(context.current_dir, context.logical_dir);
let expected_current_dir = path_actual
.join("yyy")
.canonicalize()
.expect("canonicalize");
assert_eq!(expected_current_dir, context.current_dir);
let expected_logical_dir = test_path;
assert_eq!(expected_logical_dir, context.logical_dir);
tmp_dir.close()
}
#[test]
fn context_constructor_should_fail_gracefully_when_canonicalization_fails() {
// Mock navigation to a directory which does not exist on disk
let test_path = Path::new("/path_which_does_not_exist").to_path_buf();
let context = Context::new_with_shell_and_path(
ArgMatches::default(),
Shell::Unknown,
test_path.clone(),
test_path.clone(),
);
let expected_current_dir = &test_path;
assert_eq!(expected_current_dir, &context.current_dir);
let expected_logical_dir = &test_path;
assert_eq!(expected_logical_dir, &context.logical_dir);
}
#[test]
fn context_constructor_should_fall_back_to_tilde_replacement_when_canonicalization_fails() {
use dirs_next::home_dir;
// Mock navigation to a directory which does not exist on disk
let test_path = Path::new("~/path_which_does_not_exist").to_path_buf();
let context = Context::new_with_shell_and_path(
ArgMatches::default(),
Shell::Unknown,
test_path.clone(),
test_path.clone(),
);
let expected_current_dir = home_dir()
.expect("home_dir")
.join("path_which_does_not_exist");
assert_eq!(expected_current_dir, context.current_dir);
let expected_logical_dir = test_path;
assert_eq!(expected_logical_dir, context.logical_dir);
}
}

View File

@ -1,44 +1,90 @@
#!/usr/bin/env pwsh
function global:prompt {
function Get-Cwd {
$cwd = Get-Location
$provider_prefix = "$($cwd.Provider.ModuleName)\$($cwd.Provider.Name)::"
return @{
# Resolve the actual/physical path
# NOTE: ProviderPath is only a physical filesystem path for the "FileSystem" provider
# E.g. `Dev:\` -> `C:\Users\Joe Bloggs\Dev\`
Path = $cwd.ProviderPath;
# Resolve the provider-logical path
# NOTE: Attempt to trim any "provider prefix" from the path string.
# E.g. `Microsoft.PowerShell.Core\FileSystem::Dev:\` -> `Dev:\`
LogicalPath =
if ($cwd.Path.StartsWith($provider_prefix)) {
$cwd.Path.Substring($provider_prefix.Length)
} else {
$cwd.Path
};
}
}
function Invoke-Native {
param($Executable, $Arguments)
$startInfo = [System.Diagnostics.ProcessStartInfo]::new($Executable);
$startInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8;
$startInfo.RedirectStandardOutput = $true;
$startInfo.CreateNoWindow = $true;
$startInfo.UseShellExecute = $false;
if ($startInfo.ArgumentList.Add) {
# PowerShell 6+ uses .NET 5+ and supports the ArgumentList property
# which bypasses the need for manually escaping the argument list into
# a command string.
foreach ($arg in $Arguments) {
$startInfo.ArgumentList.Add($arg);
}
}
else {
# Build an arguments string which follows the C++ command-line argument quoting rules
# See: https://docs.microsoft.com/en-us/previous-versions//17w5ykft(v=vs.85)?redirectedfrom=MSDN
$escaped = $Arguments | ForEach-Object {
$s = $_ -Replace '(\\+)"','$1$1"'; # Escape backslash chains immediately preceeding quote marks.
$s = $s -Replace '(\\+)$','$1$1'; # Escape backslash chains immediately preceeding the end of the string.
$s = $s -Replace '"','\"'; # Escape quote marks.
"`"$s`"" # Quote the argument.
}
$startInfo.Arguments = $escaped -Join ' ';
}
[System.Diagnostics.Process]::Start($startInfo).StandardOutput.ReadToEnd();
}
$origDollarQuestion = $global:?
$origLastExitCode = $global:LASTEXITCODE
$out = $null
# @ makes sure the result is an array even if single or no values are returned
$jobs = @(Get-Job | Where-Object { $_.State -eq 'Running' }).Count
$env:PWD = $PWD
$current_directory = (Convert-Path -LiteralPath $PWD)
$cwd = Get-Cwd
$arguments = @(
"prompt"
"--path=$($cwd.Path)",
"--logical-path=$($cwd.LogicalPath)",
"--jobs=$($jobs)"
)
# Whe start from the premise that the command executed correctly, which covers also the fresh console.
$lastExitCodeForPrompt = 0
# Save old output encoding and set it to UTF-8
$origOutputEncoding = [Console]::OutputEncoding
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
if ($lastCmd = Get-History -Count 1) {
# In case we have a False on the Dollar hook, we know there's an error.
if (-not $origDollarQuestion) {
# We retrieve the InvocationInfo from the most recent error.
$lastCmdletError = try { Get-Error | Where-Object { $_ -ne $null } | Select-Object -expand InvocationInfo } catch { $null }
# We check if the las command executed matches the line that caused the last error , in which case we know
# We check if the last command executed matches the line that caused the last error, in which case we know
# it was an internal Powershell command, otherwise, there MUST be an error code.
$lastExitCodeForPrompt = if ($null -ne $lastCmdletError -and $lastCmd.CommandLine -eq $lastCmdletError.Line) { 1 } else { $origLastExitCode }
}
$duration = [math]::Round(($lastCmd.EndExecutionTime - $lastCmd.StartExecutionTime).TotalMilliseconds)
# & ensures the path is interpreted as something to execute
$out = @(&::STARSHIP:: prompt "--path=$current_directory" --status=$lastExitCodeForPrompt --jobs=$jobs --cmd-duration=$duration)
} else {
$out = @(&::STARSHIP:: prompt "--path=$current_directory" --status=$lastExitCodeForPrompt --jobs=$jobs)
$arguments += "--cmd-duration=$($duration)"
}
# Restore old output encoding
[Console]::OutputEncoding = $origOutputEncoding
# Convert stdout (array of lines) to expected return type string
# `n is an escaped newline
$out -join "`n"
$arguments += "--status=$($lastExitCodeForPrompt)"
# Invoke Starship
Invoke-Native -Executable ::STARSHIP:: -Arguments $arguments
# Propagate the original $LASTEXITCODE from before the prompt function was invoked.
$global:LASTEXITCODE = $origLastExitCode
@ -61,6 +107,7 @@ function global:prompt {
Write-Error '' -ErrorAction 'Ignore'
}
}
}
# Disable virtualenv prompt, it breaks starship

View File

@ -25,7 +25,17 @@ fn main() {
.short("p")
.long("path")
.value_name("PATH")
.help("The path that the prompt should render for")
.help("The path that the prompt should render for.")
.takes_value(true);
let logical_path_arg = Arg::with_name("logical_path")
.short("P")
.long("logical-path")
.value_name("LOGICAL_PATH")
.help(concat!(
"The logical path that the prompt should render for. ",
"This path should be a virtual/logical representation of the PATH argument."
))
.takes_value(true);
let shell_arg = Arg::with_name("shell")
@ -82,6 +92,7 @@ fn main() {
.about("Prints the full starship prompt")
.arg(&status_code_arg)
.arg(&path_arg)
.arg(&logical_path_arg)
.arg(&cmd_duration_arg)
.arg(&keymap_arg)
.arg(&jobs_arg),
@ -103,6 +114,7 @@ fn main() {
)
.arg(&status_code_arg)
.arg(&path_arg)
.arg(&logical_path_arg)
.arg(&cmd_duration_arg)
.arg(&keymap_arg)
.arg(&jobs_arg),

View File

@ -2,6 +2,7 @@
use super::utils::directory_nix as directory_utils;
#[cfg(target_os = "windows")]
use super::utils::directory_win as directory_utils;
use super::utils::path::PathExt as SPathExt;
use indexmap::IndexMap;
use path_slash::PathExt;
use std::iter::FromIterator;
@ -13,15 +14,13 @@ use super::{Context, Module};
use super::utils::directory::truncate;
use crate::config::RootModuleConfig;
use crate::configs::directory::DirectoryConfig;
use crate::context::Shell;
use crate::formatter::StringFormatter;
/// Creates a module with the current directory
/// Creates a module with the current logical or physical directory
///
/// Will perform path contraction, substitution, and truncation.
///
/// **Contraction**
///
/// - Paths beginning with the home directory or with a git repo right inside
/// the home directory will be contracted to `~`, or the set HOME_SYMBOL
/// - Paths containing a git repo will contract to begin at the repo root
@ -35,41 +34,59 @@ pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
let mut module = context.new_module("directory");
let config: DirectoryConfig = DirectoryConfig::try_load(module.config);
let current_dir = &get_current_dir(&context, &config);
let home_dir = context.get_home().unwrap();
let home_symbol = String::from(config.home_symbol);
log::debug!("Current directory: {:?}", current_dir);
let repo = &context.get_repo().ok()?;
let dir_string = match &repo.root {
Some(repo_root) if config.truncate_to_repo && (repo_root != &home_dir) => {
log::debug!("Repo root: {:?}", repo_root);
// Contract the path to the git repo root
contract_repo_path(current_dir, repo_root)
.unwrap_or_else(|| contract_path(current_dir, &home_dir, &home_symbol))
}
// Contract the path to the home directory
_ => contract_path(current_dir, &home_dir, &home_symbol),
let home_dir = context
.get_home()
.expect("Unable to determine HOME_DIR for user");
let physical_dir = &context.current_dir;
let display_dir = if config.use_logical_path {
&context.logical_dir
} else {
&context.current_dir
};
log::debug!("Dir string: {}", dir_string);
let substituted_dir = substitute_path(dir_string, &config.substitutions);
log::debug!("Home dir: {:?}", &home_dir);
log::debug!("Physical dir: {:?}", &physical_dir);
log::debug!("Display dir: {:?}", &display_dir);
// Attempt repository path contraction (if we are in a git repository)
let repo = if config.truncate_to_repo {
context.get_repo().ok()
} else {
None
};
let dir_string = repo
.and_then(|r| r.root.as_ref())
.filter(|root| *root != &home_dir)
// NOTE: Always attempt to contract repo paths from the physical dir as
// the logical dir _may_ not be be a valid physical disk
// path and may be impossible to contract.
.and_then(|root| contract_repo_path(&physical_dir, root));
// Otherwise use the logical path, automatically contracting
// the home directory if required.
let dir_string =
dir_string.unwrap_or_else(|| contract_path(&display_dir, &home_dir, &home_symbol));
#[cfg(windows)]
let dir_string = remove_extended_path_prefix(dir_string);
// Apply path substitutions
let dir_string = substitute_path(dir_string, &config.substitutions);
// Truncate the dir string to the maximum number of path components
let truncated_dir_string = truncate(substituted_dir, config.truncation_length as usize);
let dir_string = truncate(dir_string, config.truncation_length as usize);
let prefix = if is_truncated(&truncated_dir_string, &home_symbol) {
let prefix = if is_truncated(&dir_string, &home_symbol) {
// Substitutions could have changed the prefix, so don't allow them and
// fish-style path contraction together
if config.fish_style_pwd_dir_length > 0 && config.substitutions.is_empty() {
// If user is using fish style path, we need to add the segment first
let contracted_home_dir = contract_path(&current_dir, &home_dir, &home_symbol);
let contracted_home_dir = contract_path(&display_dir, &home_dir, &home_symbol);
to_fish_style(
config.fish_style_pwd_dir_length as usize,
contracted_home_dir,
&truncated_dir_string,
&dir_string,
)
} else {
String::from(config.truncation_symbol)
@ -78,7 +95,7 @@ pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
String::from("")
};
let displayed_path = prefix + &truncated_dir_string;
let displayed_path = prefix + &dir_string;
let lock_symbol = String::from(config.read_only);
let parsed = StringFormatter::new(config.format).and_then(|formatter| {
@ -91,7 +108,7 @@ pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
.map(|variable| match variable {
"path" => Some(Ok(&displayed_path)),
"read_only" => {
if is_readonly_dir(&context.current_dir) {
if is_readonly_dir(&physical_dir) {
Some(Ok(&lock_symbol))
} else {
None
@ -113,45 +130,30 @@ pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
Some(module)
}
#[cfg(windows)]
fn remove_extended_path_prefix(path: String) -> String {
fn try_trim_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
if !s.starts_with(prefix) {
return None;
}
Some(&s[prefix.len()..])
}
// Trim any Windows extended-path prefix from the display path
if let Some(unc) = try_trim_prefix(&path, r"\\?\UNC\") {
return format!(r"\\{}", unc);
}
if let Some(p) = try_trim_prefix(&path, r"\\?\") {
return p.to_string();
}
path
}
fn is_truncated(path: &str, home_symbol: &str) -> bool {
!(path.starts_with(&home_symbol)
|| PathBuf::from(path).has_root()
|| (cfg!(target_os = "windows") && PathBuf::from(String::from(path) + r"\").has_root()))
}
fn get_current_dir(context: &Context, config: &DirectoryConfig) -> PathBuf {
// Using environment PWD is the standard approach for determining logical path
// If this is None for any reason, we fall back to reading the os-provided path
let physical_current_dir = if config.use_logical_path {
match context.get_env("PWD") {
Some(mut x) => {
// Prevent Powershell from prepending "Microsoft.PowerShell.Core\FileSystem::" to some paths
if cfg!(windows) && context.shell == Shell::PowerShell {
if let Some(no_prefix) =
x.strip_prefix(r"Microsoft.PowerShell.Core\FileSystem::")
{
x = no_prefix.to_string();
}
}
Some(PathBuf::from(x))
}
None => {
log::debug!("Error getting PWD environment variable!");
None
}
}
} else {
match std::env::current_dir() {
Ok(x) => Some(x),
Err(e) => {
log::debug!("Error getting physical current directory: {}", e);
None
}
}
};
physical_current_dir.unwrap_or_else(|| PathBuf::from(&context.current_dir))
}
fn is_readonly_dir(path: &Path) -> bool {
match directory_utils::is_write_allowed(path) {
Ok(res) => !res,
@ -171,23 +173,27 @@ fn is_readonly_dir(path: &Path) -> bool {
/// Replaces the `top_level_path` in a given `full_path` with the provided
/// `top_level_replacement`.
fn contract_path(full_path: &Path, top_level_path: &Path, top_level_replacement: &str) -> String {
if !full_path.starts_with(top_level_path) {
if !full_path.normalised_starts_with(top_level_path) {
return full_path.to_slash().unwrap();
}
if full_path == top_level_path {
if full_path.normalised_equals(top_level_path) {
return top_level_replacement.to_string();
}
// Because we've done a normalised path comparison above
// we can safely ignore the Prefix components when doing this
// strip_prefix operation.
let sub_path = full_path
.without_prefix()
.strip_prefix(top_level_path.without_prefix())
.expect("strip path prefix");
format!(
"{replacement}{separator}{path}",
replacement = top_level_replacement,
separator = "/",
path = full_path
.strip_prefix(top_level_path)
.unwrap()
.to_slash()
.unwrap()
path = sub_path.to_slash().expect("slash path")
)
}
@ -314,22 +320,42 @@ mod tests {
}
#[test]
fn contract_repo_directory() {
let full_path = Path::new("/Users/astronaut/dev/rocket-controls/src");
let repo_root = Path::new("/Users/astronaut/dev/rocket-controls");
fn contract_repo_directory() -> io::Result<()> {
let tmp_dir = TempDir::new_in(home_dir().unwrap().as_path())?;
let repo_dir = tmp_dir.path().join("dev").join("rocket-controls");
let src_dir = repo_dir.join("src");
fs::create_dir_all(&src_dir)?;
init_repo(&repo_dir)?;
let output = contract_path(full_path, repo_root, "rocket-controls");
assert_eq!(output, "rocket-controls/src");
let src_variations = [src_dir.clone(), src_dir.canonicalize().unwrap()];
let repo_variations = [repo_dir.clone(), repo_dir.canonicalize().unwrap()];
for src_dir in &src_variations {
for repo_dir in &repo_variations {
let output = contract_repo_path(&src_dir, &repo_dir);
assert_eq!(output, Some("rocket-controls/src".to_string()));
}
}
tmp_dir.close()
}
#[test]
#[cfg(target_os = "windows")]
#[cfg(windows)]
fn contract_windows_style_home_directory() {
let full_path = Path::new("C:\\Users\\astronaut\\schematics\\rocket");
let home = Path::new("C:\\Users\\astronaut");
let path_variations = [
r"\\?\C:\Users\astronaut\schematics\rocket",
r"C:\Users\astronaut\schematics\rocket",
];
let home_path_variations = [r"\\?\C:\Users\astronaut", r"C:\Users\astronaut"];
for path in &path_variations {
for home_path in &home_path_variations {
let path = Path::new(path);
let home_path = Path::new(home_path);
let output = contract_path(full_path, home, "~");
assert_eq!(output, "~/schematics/rocket");
let output = contract_path(path, home_path, "~");
assert_eq!(output, "~/schematics/rocket");
}
}
}
#[test]
@ -437,52 +463,6 @@ mod tests {
Ok((dir, path))
}
#[test]
fn windows_strip_prefix() {
let with_prefix = r"Microsoft.PowerShell.Core\FileSystem::/path";
let without_prefix = r"/path";
let actual = ModuleRenderer::new("directory")
// use a different physical path here as a sentinel value
.path("/")
.env("PWD", with_prefix)
.shell(Shell::PowerShell)
.config(toml::toml! {
[directory]
format = "$path"
truncation_length = 100
})
.collect()
.unwrap();
let expected = if cfg!(windows) {
without_prefix
} else {
with_prefix
};
let expected = Path::new(expected).to_slash().unwrap();
assert_eq!(actual, expected);
}
#[test]
fn windows_strip_prefix_no_pwsh() {
let with_prefix = r"Microsoft.PowerShell.Core\FileSystem::/path";
let actual = ModuleRenderer::new("directory")
// use a different physical path here as a sentinel value
.path("/")
.env("PWD", with_prefix)
.shell(Shell::Bash)
.config(toml::toml! {
[directory]
format = "$path"
truncation_length = 100
})
.collect()
.unwrap();
let expected = Path::new(with_prefix).to_slash().unwrap();
assert_eq!(actual, expected);
}
#[cfg(not(target_os = "windows"))]
mod linux {
use super::*;
@ -1430,4 +1410,106 @@ mod tests {
assert_eq!(expected, actual);
Ok(())
}
#[test]
fn use_logical_path_true_should_render_logical_dir_path() -> io::Result<()> {
let tmp_dir = TempDir::new()?;
let path = tmp_dir.path().join("src/meters/fuel-gauge");
fs::create_dir_all(&path)?;
let logical_path = "Logical:/fuel-gauge";
let expected = Some(format!(
"{} ",
Color::Cyan.bold().paint("Logical:/fuel-gauge")
));
let actual = ModuleRenderer::new("directory")
.config(toml::toml! {
[directory]
use_logical_path = true
truncation_length = 3
})
.path(path)
.logical_path(logical_path)
.collect();
assert_eq!(expected, actual);
tmp_dir.close()
}
#[test]
fn use_logical_path_false_should_render_current_dir_path() -> io::Result<()> {
let tmp_dir = TempDir::new()?;
let path = tmp_dir.path().join("src/meters/fuel-gauge");
fs::create_dir_all(&path)?;
let logical_path = "Logical:/fuel-gauge";
let expected = Some(format!(
"{} ",
Color::Cyan.bold().paint("src/meters/fuel-gauge")
));
let actual = ModuleRenderer::new("directory")
.config(toml::toml! {
[directory]
use_logical_path = false
truncation_length = 3
})
.path(path)
.logical_path(logical_path) // logical_path should be ignored
.collect();
assert_eq!(expected, actual);
tmp_dir.close()
}
#[test]
#[cfg(windows)]
fn windows_trims_extended_path_prefix() {
// Under Windows, path canonicalization returns the paths using extended-path prefixes `\\?\`
// We expect this prefix to be trimmed before being rendered.
let sys32_path = Path::new(r"\\?\C:\Windows\System32");
let expected = Some(format!(
"{} ",
Color::Cyan.bold().paint("C:/Windows/System32")
));
let actual = ModuleRenderer::new("directory")
.config(toml::toml! {
[directory]
use_logical_path = false
truncation_length = 0
})
.path(sys32_path)
.collect();
assert_eq!(expected, actual);
}
#[test]
#[cfg(windows)]
fn windows_trims_extended_unc_path_prefix() {
// Under Windows, path canonicalization returns UNC paths using extended-path prefixes `\\?\UNC\`
// We expect this prefix to be trimmed before being rendered.
let unc_path = Path::new(r"\\?\UNC\server\share\a\b\c");
// NOTE: path-slash doesn't convert slashes which are part of path prefixes under Windows,
// which is why the first part of this string still includes backslashes
let expected = Some(format!(
"{} ",
Color::Cyan.bold().paint(r"\\server\share/a/b/c")
));
let actual = ModuleRenderer::new("directory")
.config(toml::toml! {
[directory]
use_logical_path = false
truncation_length = 0
})
.path(unc_path)
.collect();
assert_eq!(expected, actual);
}
}

View File

@ -222,6 +222,7 @@ enum RustupRunRustcVersionOutcome {
#[cfg(test)]
mod tests {
use crate::context::Shell;
use once_cell::sync::Lazy;
use std::io;
use std::process::{ExitStatus, Output};
@ -339,7 +340,12 @@ mod tests {
let dir = tempfile::tempdir()?;
fs::write(dir.path().join("rust-toolchain"), "1.34.0")?;
let context = Context::new_with_dir(Default::default(), dir.path());
let context = Context::new_with_shell_and_path(
Default::default(),
Shell::Unknown,
dir.path().into(),
dir.path().into(),
);
assert_eq!(
find_rust_toolchain_file(&context),
@ -353,7 +359,12 @@ mod tests {
"[toolchain]\nchannel = \"1.34.0\"",
)?;
let context = Context::new_with_dir(Default::default(), dir.path());
let context = Context::new_with_shell_and_path(
Default::default(),
Shell::Unknown,
dir.path().into(),
dir.path().into(),
);
assert_eq!(
find_rust_toolchain_file(&context),
@ -367,7 +378,12 @@ mod tests {
"\n\n[toolchain]\n\n\nchannel = \"1.34.0\"",
)?;
let context = Context::new_with_dir(Default::default(), dir.path());
let context = Context::new_with_shell_and_path(
Default::default(),
Shell::Unknown,
dir.path().into(),
dir.path().into(),
);
assert_eq!(
find_rust_toolchain_file(&context),

View File

@ -5,3 +5,5 @@ pub mod directory_win;
#[cfg(not(target_os = "windows"))]
pub mod directory_nix;
pub mod path;

345
src/modules/utils/path.rs Normal file
View File

@ -0,0 +1,345 @@
use std::path::Path;
pub trait PathExt {
/// Compare this path with another path, ignoring
/// the differences between Verbatim and Non-Verbatim paths.
fn normalised_equals(&self, other: &Path) -> bool;
/// Determine if this path starts wit with another path fragment, ignoring
/// the differences between Verbatim and Non-Verbatim paths.
fn normalised_starts_with(&self, other: &Path) -> bool;
/// Strips the path Prefix component from the Path, if there is one
/// E.g. `\\?\path\foo` => `\foo`
/// E.g. `\\?\C:\foo` => `\foo`
/// E.g. `\\?\UNC\server\share\foo` => `\foo`
/// E.g. `/foo/bar` => `/foo/bar`
fn without_prefix(&self) -> &Path;
}
#[cfg(windows)]
mod normalize {
use std::ffi::OsStr;
use std::path::{Component, Path, Prefix};
#[derive(Debug, PartialEq, Eq)]
pub enum NormalizedPrefix<'a> {
// No prefix, e.g. `\cat_pics` or `/cat_pics`
None,
/// Simple verbatim prefix, e.g. `\\?\cat_pics`.
Verbatim(&'a OsStr),
/// Device namespace prefix, e.g. `\\.\COM42`.
DeviceNS(&'a OsStr),
/// Prefix using Windows' _**U**niform **N**aming **C**onvention_, e.g. `\\server\share` or `\\?\UNC\server\share`
UNC(&'a OsStr, &'a OsStr),
/// Windows disk/drive prefix e.g. `C:` or `\\?\C:`
Disk(u8),
}
/// Normalise Verbatim and Non-Verbatim path prefixes into a comparable structure.
/// NOTE: "Verbatim" paths are the rust std library's name for Windows extended-path prefixed paths.
#[cfg(windows)]
fn normalize_prefix(prefix: Prefix) -> NormalizedPrefix {
match prefix {
Prefix::Verbatim(segment) => NormalizedPrefix::Verbatim(segment),
Prefix::VerbatimUNC(server, share) => NormalizedPrefix::UNC(server, share),
Prefix::VerbatimDisk(disk) => NormalizedPrefix::Disk(disk),
Prefix::DeviceNS(device) => NormalizedPrefix::DeviceNS(device),
Prefix::UNC(server, share) => NormalizedPrefix::UNC(server, share),
Prefix::Disk(disk) => NormalizedPrefix::Disk(disk),
}
}
#[cfg(windows)]
pub fn normalize_path(path: &Path) -> (NormalizedPrefix, &Path) {
let mut components = path.components();
if let Some(Component::Prefix(prefix)) = components.next() {
return (normalize_prefix(prefix.kind()), &components.as_path());
}
(NormalizedPrefix::None, path)
}
}
#[cfg(windows)]
impl PathExt for Path {
fn normalised_starts_with(&self, other: &Path) -> bool {
// Do a structured comparison of two paths (normalising differences between path prefixes)
let (a_prefix, a_path) = normalize::normalize_path(self);
let (b_prefix, b_path) = normalize::normalize_path(other);
a_prefix == b_prefix && a_path.starts_with(b_path)
}
fn normalised_equals(&self, other: &Path) -> bool {
// Do a structured comparison of two paths (normalising differences between path prefixes)
let (a_prefix, a_path) = normalize::normalize_path(self);
let (b_prefix, b_path) = normalize::normalize_path(other);
a_prefix == b_prefix && a_path == b_path
}
fn without_prefix(&self) -> &Path {
let (_, path) = normalize::normalize_path(self);
&path
}
}
// NOTE: Windows path prefixes are only parsed on Windows.
// On other platforms, we can fall back to the non-normalized versions of these routines.
#[cfg(not(windows))]
impl PathExt for Path {
#[inline]
fn normalised_starts_with(&self, other: &Path) -> bool {
self.starts_with(other)
}
#[inline]
fn normalised_equals(&self, other: &Path) -> bool {
self == other
}
#[inline]
fn without_prefix(&self) -> &Path {
self
}
}
#[cfg(test)]
#[cfg(windows)]
mod windows {
use super::*;
#[test]
fn normalised_equals() {
fn test_equals(a: &Path, b: &Path) {
assert!(a.normalised_equals(&b));
assert!(b.normalised_equals(&a));
}
// UNC paths
let verbatim_unc = Path::new(r"\\?\UNC\server\share\sub\path");
let unc = Path::new(r"\\server\share\sub\path");
test_equals(&verbatim_unc, &verbatim_unc);
test_equals(&verbatim_unc, &unc);
test_equals(&unc, &unc);
test_equals(&unc, &verbatim_unc);
// Disk paths
let verbatim_disk = Path::new(r"\\?\C:\test\path");
let disk = Path::new(r"C:\test\path");
test_equals(&verbatim_disk, &verbatim_disk);
test_equals(&verbatim_disk, &disk);
test_equals(&disk, &disk);
test_equals(&disk, &verbatim_disk);
// Other paths
let verbatim = Path::new(r"\\?\cat_pics");
let no_prefix = Path::new(r"\cat_pics");
let device_ns = Path::new(r"\\.\COM42");
test_equals(&verbatim, &verbatim);
test_equals(&no_prefix, &no_prefix);
test_equals(&device_ns, &device_ns);
}
#[test]
fn normalised_equals_differing_prefixes() {
fn test_not_equals(a: &Path, b: &Path) {
assert!(!a.normalised_equals(&b));
assert!(!b.normalised_equals(&a));
}
let verbatim_unc = Path::new(r"\\?\UNC\server\share\sub\path");
let unc = Path::new(r"\\server\share\sub\path");
let verbatim_disk = Path::new(r"\\?\C:\test\path");
let disk = Path::new(r"C:\test\path");
let verbatim = Path::new(r"\\?\cat_pics");
let no_prefix = Path::new(r"\cat_pics");
let device_ns = Path::new(r"\\.\COM42");
test_not_equals(&verbatim_unc, &verbatim_disk);
test_not_equals(&unc, &disk);
test_not_equals(&disk, &device_ns);
test_not_equals(&device_ns, &verbatim_disk);
test_not_equals(&no_prefix, &unc);
test_not_equals(&no_prefix, &verbatim);
}
#[test]
fn normalised_starts_with() {
fn test_starts_with(a: &Path, b: &Path) {
assert!(a.normalised_starts_with(&b));
assert!(!b.normalised_starts_with(&a));
}
// UNC paths
let verbatim_unc_a = Path::new(r"\\?\UNC\server\share\a\b\c\d");
let verbatim_unc_b = Path::new(r"\\?\UNC\server\share\a\b");
let unc_a = Path::new(r"\\server\share\a\b\c\d");
let unc_b = Path::new(r"\\server\share\a\b");
test_starts_with(&verbatim_unc_a, &verbatim_unc_b);
test_starts_with(&unc_a, &unc_b);
test_starts_with(&verbatim_unc_a, &unc_b);
test_starts_with(&unc_a, &verbatim_unc_b);
// Disk paths
let verbatim_disk_a = Path::new(r"\\?\C:\a\b\c\d");
let verbatim_disk_b = Path::new(r"\\?\C:\a\b");
let disk_a = Path::new(r"C:\a\b\c\d");
let disk_b = Path::new(r"C:\a\b");
test_starts_with(&verbatim_disk_a, &verbatim_disk_b);
test_starts_with(&disk_a, &disk_b);
test_starts_with(&disk_a, &verbatim_disk_b);
test_starts_with(&verbatim_disk_a, &disk_b);
// Other paths
let verbatim_a = Path::new(r"\\?\cat_pics\a\b\c\d");
let verbatim_b = Path::new(r"\\?\cat_pics\a\b");
let device_ns_a = Path::new(r"\\.\COM43\a\b\c\d");
let device_ns_b = Path::new(r"\\.\COM43\a\b");
let no_prefix_a = Path::new(r"\a\b\c\d");
let no_prefix_b = Path::new(r"\a\b");
test_starts_with(&verbatim_a, &verbatim_b);
test_starts_with(&device_ns_a, &device_ns_b);
test_starts_with(&no_prefix_a, &no_prefix_b);
}
#[test]
fn normalised_starts_with_differing_prefixes() {
fn test_not_starts_with(a: &Path, b: &Path) {
assert!(!a.normalised_starts_with(&b));
assert!(!b.normalised_starts_with(&a));
}
let verbatim_unc = Path::new(r"\\?\UNC\server\share\a\b\c\d");
let unc = Path::new(r"\\server\share\a\b\c\d");
let verbatim_disk = Path::new(r"\\?\C:\a\b\c\d");
let disk = Path::new(r"C:\a\b\c\d");
let verbatim = Path::new(r"\\?\cat_pics\a\b\c\d");
let device_ns = Path::new(r"\\.\COM43\a\b\c\d");
let no_prefix = Path::new(r"\a\b\c\d");
test_not_starts_with(&verbatim_unc, &device_ns);
test_not_starts_with(&unc, &device_ns);
test_not_starts_with(&verbatim_disk, &verbatim);
test_not_starts_with(&disk, &verbatim);
test_not_starts_with(&disk, &unc);
test_not_starts_with(&verbatim_disk, &no_prefix);
}
#[test]
fn without_prefix() {
// UNC paths
assert_eq!(
Path::new(r"\\?\UNC\server\share\sub\path").without_prefix(),
Path::new(r"\sub\path")
);
assert_eq!(
Path::new(r"\\server\share\sub\path").without_prefix(),
Path::new(r"\sub\path")
);
// Disk paths
assert_eq!(
Path::new(r"\\?\C:\sub\path").without_prefix(),
Path::new(r"\sub\path")
);
assert_eq!(
Path::new(r"C:\sub\path").without_prefix(),
Path::new(r"\sub\path")
);
// Other paths
assert_eq!(
Path::new(r"\\?\cat_pics\sub\path").without_prefix(),
Path::new(r"\sub\path")
);
assert_eq!(
Path::new(r"\\.\COM42\sub\path").without_prefix(),
Path::new(r"\sub\path")
);
// No prefix
assert_eq!(
Path::new(r"\cat_pics\sub\path").without_prefix(),
Path::new(r"\cat_pics\sub\path")
);
}
}
#[cfg(test)]
#[cfg(not(windows))]
mod nix {
use super::*;
#[test]
fn normalised_equals() {
let path_a = Path::new("/a/b/c/d");
let path_b = Path::new("/a/b/c/d");
assert!(path_a.normalised_equals(&path_b));
assert!(path_b.normalised_equals(&path_a));
let path_c = Path::new("/a/b");
assert!(!path_a.normalised_equals(&path_c));
}
#[test]
fn normalised_equals_differing_prefixes() {
// Windows path prefixes are not parsed on *nix
let path_a = Path::new(r"\\?\UNC\server\share\a\b\c\d");
let path_b = Path::new(r"\\server\share\a\b\c\d");
assert!(!path_a.normalised_equals(&path_b));
assert!(!path_b.normalised_equals(&path_a));
assert!(path_a.normalised_equals(&path_a));
}
#[test]
fn normalised_starts_with() {
let path_a = Path::new("/a/b/c/d");
let path_b = Path::new("/a/b");
assert!(path_a.normalised_starts_with(&path_b));
assert!(!path_b.normalised_starts_with(&path_a));
}
#[test]
fn normalised_starts_with_differing_prefixes() {
// Windows path prefixes are not parsed on *nix
let path_a = Path::new(r"\\?\UNC\server\share\a\b\c\d");
let path_b = Path::new(r"\\server\share\a\b");
assert!(!path_a.normalised_starts_with(&path_b));
assert!(!path_b.normalised_starts_with(&path_a));
assert!(path_a.normalised_starts_with(&path_a));
}
#[test]
fn without_prefix() {
// Windows path prefixes are not parsed on *nix
// UNC paths
assert_eq!(
Path::new(r"\\?\UNC\server\share\sub\path").without_prefix(),
Path::new(r"\\?\UNC\server\share\sub\path")
);
assert_eq!(
Path::new(r"\\server\share\sub\path").without_prefix(),
Path::new(r"\\server\share\sub\path")
);
// Disk paths
assert_eq!(
Path::new(r"\\?\C:\sub\path").without_prefix(),
Path::new(r"\\?\C:\sub\path")
);
assert_eq!(
Path::new(r"C:\sub\path").without_prefix(),
Path::new(r"C:\sub\path")
);
// Other paths
assert_eq!(
Path::new(r"\\?\cat_pics\sub\path").without_prefix(),
Path::new(r"\\?\cat_pics\sub\path")
);
assert_eq!(
Path::new(r"\\.\COM42\sub\path").without_prefix(),
Path::new(r"\\.\COM42\sub\path")
);
// No prefix
assert_eq!(
Path::new(r"\cat_pics\sub\path").without_prefix(),
Path::new(r"\cat_pics\sub\path")
);
}
}

View File

@ -41,8 +41,12 @@ impl<'a> ModuleRenderer<'a> {
// Start logger
Lazy::force(&LOGGER);
let mut context = Context::new_with_dir(clap::ArgMatches::default(), PathBuf::new());
context.shell = Shell::Unknown;
let mut context = Context::new_with_shell_and_path(
clap::ArgMatches::default(),
Shell::Unknown,
PathBuf::new(),
PathBuf::new(),
);
context.config = StarshipConfig { config: None };
Self { name, context }
@ -53,6 +57,15 @@ impl<'a> ModuleRenderer<'a> {
T: Into<PathBuf>,
{
self.context.current_dir = path.into();
self.context.logical_dir = self.context.current_dir.clone();
self
}
pub fn logical_path<T>(mut self, path: T) -> Self
where
T: Into<PathBuf>,
{
self.context.logical_dir = path.into();
self
}