feat(directory): add support for regexes in substitutions (#7145)

---------

Co-authored-by: David Knaack <davidkna@users.noreply.github.com>
This commit is contained in:
Christophe Henry
2026-01-25 22:09:09 +01:00
committed by GitHub
parent 70b0f73554
commit 2e8f26e448
4 changed files with 214 additions and 31 deletions
+46 -13
View File
@@ -367,7 +367,7 @@
"default": { "default": {
"truncation_length": 3, "truncation_length": 3,
"truncate_to_repo": true, "truncate_to_repo": true,
"substitutions": {}, "substitutions": [],
"fish_style_pwd_dir_length": 0, "fish_style_pwd_dir_length": 0,
"use_logical_path": true, "use_logical_path": true,
"format": "[$path]($style)[$read_only]($read_only_style) ", "format": "[$path]($style)[$read_only]($read_only_style) ",
@@ -2668,11 +2668,8 @@
"default": true "default": true
}, },
"substitutions": { "substitutions": {
"type": "object", "$ref": "#/$defs/Either",
"additionalProperties": { "default": []
"type": "string"
},
"default": {}
}, },
"fish_style_pwd_dir_length": { "fish_style_pwd_dir_length": {
"type": "integer", "type": "integer",
@@ -2736,6 +2733,42 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"Either": {
"anyOf": [
{
"type": "array",
"items": {
"$ref": "#/$defs/SubstitutionConfig"
}
},
{
"type": "object",
"additionalProperties": {
"type": "string"
}
}
]
},
"SubstitutionConfig": {
"type": "object",
"properties": {
"from": {
"type": "string"
},
"to": {
"type": "string"
},
"regex": {
"type": "boolean",
"default": false
}
},
"additionalProperties": false,
"required": [
"from",
"to"
]
},
"DirenvConfig": { "DirenvConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -5413,7 +5446,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"pixi_binary": { "pixi_binary": {
"$ref": "#/$defs/Either", "$ref": "#/$defs/Either2",
"default": [ "default": [
"pixi" "pixi"
] ]
@@ -5469,7 +5502,7 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"Either": { "Either2": {
"anyOf": [ "anyOf": [
{ {
"type": "string" "type": "string"
@@ -5577,7 +5610,7 @@
"default": "pyenv " "default": "pyenv "
}, },
"python_binary": { "python_binary": {
"$ref": "#/$defs/Either", "$ref": "#/$defs/Either2",
"default": [ "default": [
[ [
"python" "python"
@@ -6187,7 +6220,7 @@
"default": "S " "default": "S "
}, },
"compiler": { "compiler": {
"$ref": "#/$defs/Either", "$ref": "#/$defs/Either2",
"default": [ "default": [
"solc" "solc"
] ]
@@ -6893,7 +6926,7 @@
"default": "" "default": ""
}, },
"when": { "when": {
"$ref": "#/$defs/Either2", "$ref": "#/$defs/Either3",
"default": false "default": false
}, },
"require_repo": { "require_repo": {
@@ -6901,7 +6934,7 @@
"default": false "default": false
}, },
"shell": { "shell": {
"$ref": "#/$defs/Either", "$ref": "#/$defs/Either2",
"default": [] "default": []
}, },
"description": { "description": {
@@ -6960,7 +6993,7 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"Either2": { "Either3": {
"anyOf": [ "anyOf": [
{ {
"type": "boolean" "type": "boolean"
+24 -2
View File
@@ -1206,12 +1206,34 @@ it would have been `nixpkgs/pkgs`.
| Advanced Option | Default | Description | | Advanced Option | Default | Description |
| --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `substitutions` | | A table of substitutions to be made to the path. | | `substitutions` | | An Array or 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. | | `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. | | `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 `substitutions` allows you to define arbitrary replacements for literal strings that occur in the path, for example long network
prefixes or development directories of Java. Note that this will disable the fish style PWD. prefixes or development directories of Java. Note that this will disable the fish style PWD. It takes an array of the following
key/value pairs:
| Value | Type | Description |
| ------- | ------- | ---------------------------------------- |
| `from` | String | The value to substitute |
| `to` | String | The replacement for that value, if found |
| `regex` | Boolean | (Optional) Whether `from` is a regex |
By using `regex = true`, you can use [Rust's regular expressions](https://docs.rs/regex/latest/regex/#syntax) in `from`.
For instance you can replace every slash except the first with the following:
```toml
substitutions = [
{ from = "^/", to = "<root>/", regex = true },
{ from = "/", to = " | " },
{ from = "^<root>", to = "/", regex = true },
]
```
This will replace `/var/log` to `/ | var | log`.
The old syntax still works, although it doesn't support regular expressions:
```toml ```toml
[directory.substitutions] [directory.substitutions]
+25 -3
View File
@@ -1,7 +1,20 @@
use crate::config::Either;
use indexmap::IndexMap; use indexmap::IndexMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Deserialize, Serialize)]
#[cfg_attr(
feature = "config-schema",
derive(schemars::JsonSchema),
schemars(deny_unknown_fields)
)]
pub struct SubstitutionConfig<'a> {
pub from: String,
pub to: &'a str,
#[serde(default)]
pub regex: bool,
}
#[derive(Clone, Deserialize, Serialize)] #[derive(Clone, Deserialize, Serialize)]
#[cfg_attr( #[cfg_attr(
feature = "config-schema", feature = "config-schema",
@@ -12,7 +25,7 @@ use serde::{Deserialize, Serialize};
pub struct DirectoryConfig<'a> { pub struct DirectoryConfig<'a> {
pub truncation_length: i64, pub truncation_length: i64,
pub truncate_to_repo: bool, pub truncate_to_repo: bool,
pub substitutions: IndexMap<String, &'a str>, pub substitutions: Either<Vec<SubstitutionConfig<'a>>, IndexMap<String, &'a str>>,
pub fish_style_pwd_dir_length: i64, pub fish_style_pwd_dir_length: i64,
pub use_logical_path: bool, pub use_logical_path: bool,
pub format: &'a str, pub format: &'a str,
@@ -28,6 +41,15 @@ pub struct DirectoryConfig<'a> {
pub use_os_path_sep: bool, pub use_os_path_sep: bool,
} }
impl<'a> DirectoryConfig<'a> {
pub fn substitutions_empty(&self) -> bool {
match &self.substitutions {
Either::First(vec) => vec.is_empty(),
Either::Second(table) => table.is_empty(),
}
}
}
impl Default for DirectoryConfig<'_> { impl Default for DirectoryConfig<'_> {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -35,7 +57,7 @@ impl Default for DirectoryConfig<'_> {
truncate_to_repo: true, truncate_to_repo: true,
fish_style_pwd_dir_length: 0, fish_style_pwd_dir_length: 0,
use_logical_path: true, use_logical_path: true,
substitutions: IndexMap::new(), substitutions: Either::First(vec![]),
format: "[$path]($style)[$read_only]($read_only_style) ", format: "[$path]($style)[$read_only]($read_only_style) ",
repo_root_format: "[$before_root_path]($before_repo_root_style)[$repo_root]($repo_root_style)[$path]($style)[$read_only]($read_only_style) ", repo_root_format: "[$before_root_path]($before_repo_root_style)[$repo_root]($repo_root_style)[$path]($style)[$read_only]($read_only_style) ",
style: "cyan bold", style: "cyan bold",
+119 -13
View File
@@ -5,6 +5,7 @@ use super::utils::directory_win as directory_utils;
use super::utils::path::PathExt as SPathExt; use super::utils::path::PathExt as SPathExt;
use indexmap::IndexMap; use indexmap::IndexMap;
use path_slash::{PathBufExt, PathExt}; use path_slash::{PathBufExt, PathExt};
use regex::{Error, Regex};
use std::borrow::Cow; use std::borrow::Cow;
use std::iter::FromIterator; use std::iter::FromIterator;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -13,8 +14,8 @@ use unicode_segmentation::UnicodeSegmentation;
use super::{Context, Module}; use super::{Context, Module};
use super::utils::directory::truncate; use super::utils::directory::truncate;
use crate::config::ModuleConfig; use crate::config::{Either, ModuleConfig};
use crate::configs::directory::DirectoryConfig; use crate::configs::directory::{DirectoryConfig, SubstitutionConfig};
use crate::formatter::StringFormatter; use crate::formatter::StringFormatter;
/// Creates a module with the current logical or physical directory /// Creates a module with the current logical or physical directory
@@ -74,7 +75,13 @@ pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
let dir_string = remove_extended_path_prefix(dir_string); let dir_string = remove_extended_path_prefix(dir_string);
// Apply path substitutions // Apply path substitutions
let dir_string = substitute_path(dir_string, &config.substitutions); let dir_string = match substitute_path(dir_string.clone(), &config.substitutions) {
Ok(result) => result,
Err(err) => {
log::warn!("Invalid regex in directory substitutions: {err}");
dir_string
}
};
// Truncate the dir string to the maximum number of path components // Truncate the dir string to the maximum number of path components
let dir_string = let dir_string =
@@ -88,7 +95,7 @@ pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
let prefix = if is_truncated { let prefix = if is_truncated {
// Substitutions could have changed the prefix, so don't allow them and // Substitutions could have changed the prefix, so don't allow them and
// fish-style path contraction together // fish-style path contraction together
if config.fish_style_pwd_dir_length > 0 && config.substitutions.is_empty() { if config.fish_style_pwd_dir_length > 0 && config.substitutions_empty() {
// If user is using fish style path, we need to add the segment first // If user is using fish style path, we need to add the segment first
let contracted_home_dir = contract_path(display_dir, &home_dir, config.home_symbol); let contracted_home_dir = contract_path(display_dir, &home_dir, config.home_symbol);
to_fish_style( to_fish_style(
@@ -291,12 +298,35 @@ fn real_path<P: AsRef<Path>>(path: P) -> PathBuf {
/// ///
/// Given a list of (from, to) pairs, this will perform the string /// Given a list of (from, to) pairs, this will perform the string
/// substitutions, in order, on the path. Any non-pair of strings is ignored. /// substitutions, in order, on the path. Any non-pair of strings is ignored.
fn substitute_path(dir_string: String, substitutions: &IndexMap<String, &str>) -> String { fn substitute_path(
dir_string: String,
substitutions: &Either<Vec<SubstitutionConfig>, IndexMap<String, &str>>,
) -> Result<String, Error> {
let substitutions: &Vec<SubstitutionConfig> = match substitutions {
Either::First(vec) => vec,
Either::Second(table) => &table
.iter()
.map(|(from, to)| SubstitutionConfig {
from: String::from(from),
to,
regex: false,
})
.collect(),
};
let mut substituted_dir = dir_string; let mut substituted_dir = dir_string;
for substitution_pair in substitutions {
substituted_dir = substituted_dir.replace(substitution_pair.0, substitution_pair.1); for substitution in substitutions {
if substitution.regex {
let re = Regex::new(substitution.from.as_str())?;
substituted_dir = re
.replace(substituted_dir.as_str(), substitution.to)
.to_string()
} else {
substituted_dir = substituted_dir.replace(substitution.from.as_str(), substitution.to)
}
} }
substituted_dir Ok(substituted_dir)
} }
/// Takes part before contracted path and replaces it with fish style path /// Takes part before contracted path and replaces it with fish style path
@@ -350,6 +380,7 @@ fn before_root_dir<'a>(path: &'a str, repo: &'a str) -> &'a str {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::Either::First;
use crate::test::ModuleRenderer; use crate::test::ModuleRenderer;
use crate::utils::create_command; use crate::utils::create_command;
use crate::utils::home_dir; use crate::utils::home_dir;
@@ -443,11 +474,19 @@ mod tests {
#[test] #[test]
fn substitute_prefix_and_middle() { fn substitute_prefix_and_middle() {
let full_path = "/absolute/path/foo/bar/baz"; let full_path = "/absolute/path/foo/bar/baz";
let mut substitutions = IndexMap::new(); let substitutions = First(vec![
substitutions.insert("/absolute/path".to_string(), ""); SubstitutionConfig {
substitutions.insert("/bar/".to_string(), "/"); from: "/absolute/path".to_string(),
to: "",
let output = substitute_path(full_path.to_string(), &substitutions); regex: false,
},
SubstitutionConfig {
from: "/bar/".to_string(),
to: "/",
regex: false,
},
]);
let output = substitute_path(full_path.to_string(), &substitutions).unwrap();
assert_eq!(output, "/foo/baz"); assert_eq!(output, "/foo/baz");
} }
@@ -691,6 +730,73 @@ mod tests {
assert_eq!(expected, actual); assert_eq!(expected, actual);
} }
#[test]
fn regex_substitution() {
let actual = ModuleRenderer::new("directory")
.path("/var/log")
.config(toml::toml! {
[directory]
format = "[$path]($style)"
substitutions = [
{ from = "~/Documents", to = "docs"},
{ from = "^/", to = "<root>/", regex = true},
{ from = "/", to = " | "},
{ from = "^<root>", to = "/", regex = true},
]
})
.collect();
let expected = Some(format!(
"{}",
Color::Cyan.bold().paint(convert_path_sep("/ | var | log"))
));
assert_eq!(expected, actual);
let actual = ModuleRenderer::new("directory")
.path("~/Documents/var/log")
.config(toml::toml! {
[directory]
format = "[$path]($style)"
substitutions = [
{ from = "~/Documents", to = "docs"},
{ from = "^/", to = "<root>/", regex = true},
{ from = "/", to = " | "},
{ from = "^<root>", to = "/", regex = true},
]
})
.collect();
let expected = Some(format!(
"{}",
Color::Cyan
.bold()
.paint(convert_path_sep("docs | var | log"))
));
assert_eq!(expected, actual);
}
#[test]
fn bad_regex_substitution_leaves_path_untouched() {
let path = "/var/log";
let actual = ModuleRenderer::new("directory")
.path(path)
.config(toml::toml! {
[directory]
format = "[$path]($style)"
substitutions = [
{ from = "~/Documents", to = "docs"},
{ from = "(^/", to = "<home>/", regex = true},
]
})
.collect();
let expected = Some(format!(
"{}",
Color::Cyan.bold().paint(convert_path_sep(path))
));
assert_eq!(expected, actual);
}
#[test] #[test]
fn directory_in_home() -> io::Result<()> { fn directory_in_home() -> io::Result<()> {
let (tmp_dir, name) = make_known_tempdir(home_dir().unwrap().as_path())?; let (tmp_dir, name) = make_known_tempdir(home_dir().unwrap().as_path())?;