Skip to content

Commit

Permalink
refactor(directory): Introduce logical-path argument which allows a…
Browse files Browse the repository at this point in the history
… shell to explicitly specify both a logical and physical filesystem path (starship#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.
  • Loading branch information
deadalusai authored Feb 8, 2021
1 parent 30bd02c commit 20d845f
Show file tree
Hide file tree
Showing 10 changed files with 784 additions and 166 deletions.
10 changes: 5 additions & 5 deletions docs/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/configs/directory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
127 changes: 114 additions & 13 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,

Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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);
}
}
85 changes: 66 additions & 19 deletions src/init/starship.ps1
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -61,6 +107,7 @@ function global:prompt {
Write-Error '' -ErrorAction 'Ignore'
}
}

}

# Disable virtualenv prompt, it breaks starship
Expand Down
14 changes: 13 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down
Loading

0 comments on commit 20d845f

Please sign in to comment.