Repository: JanDeDobbeleer/oh-my-posh
Pull request #976 (2021-09-18 18:55:30)

feat(path): flexible matching for mapped-locations
### Prerequisites

- [x] I have read and understood the `CONTRIBUTING` guide
- [x] The commit message follows the [conventional commits][cc] guidelines
- [x] Tests for the changes have been added (for bug fixes/features)
- [x] Docs have been added / updated (for bug fixes/features)

### Description

I'm not sure how you'll feel about this one. The goal was to make `mapped_locations` more portable. 

My use case is that I've got two machines with similar directory structures, except that I'm `Aaron` on one and `ASherber` on the other. I'd like to be able to put `"~\\Documents\\Projects"` in `mapped_locations` and have it work on both machines (i.e., match both `C:\Users\Aaron\Documents\Projects` and `C:\Users\ASherber\Documents\Projects`). 

While I'm at it, because I hate having to escape backslashes, and because Windows doesn't care which path separator I use, I'd like to be able to use `"~/Documents/Projects"`. And then if I make `mapped_locations` case-insensitive on Windows and Mac, I can actually use `"~/documents/projects"` and use the same theme file on all OSes.

If you agree with this approach, I've got similar changes queued up for `include_folders` and `exclude_folders` which I could either add to this PR or put into a new one.

<!---

Tips:

If you're not comfortable with working with Git, we're working a guide (https://ohmyposh.dev/docs/contributing_git) to help you out.
Oh My Posh advises GitKraken (https://www.gitkraken.com/invite/nQmDPR9D) as your preferred cross platform Git GUI power tool.

-->

[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary


JanDeDobbeleer: Agreed with doing this on macOS and Windows given their nature. Actually like this, amazing flexibility and a real world use-case.

Patch:
diff --git a/docs/docs/segment-path.md b/docs/docs/segment-path.md
index 97d2f81c..3c96b31f 100644
--- a/docs/docs/segment-path.md
+++ b/docs/docs/segment-path.md
@@ -43,12 +43,12 @@ Display the current path.
 ## Mapped Locations
 
 Allows you to override a location with an icon. It validates if the current path **starts with** the value and replaces
-it with the icon if there's a match. To avoid issues with nested overrides, Oh my posh will sort the list of mapped
+it with the icon if there's a match. To avoid issues with nested overrides, Oh My Posh will sort the list of mapped
 locations before doing a replacement.
 
 - mapped_locations_enabled: `boolean` - replace known locations in the path with the replacements before applying the
-style. defaults to `true`
-- mapped_locations: `map[string]string` - custom glyph/text for specific paths. Works regardless of the `mapped_locations_enabled`
+style - defaults to `true`
+- mapped_locations: `object` - custom glyph/text for specific paths. Works regardless of the `mapped_locations_enabled`
 setting.
 
 For example, to swap out `C:\Users\Leet\GitHub` with a GitHub icon, you can do the following:
@@ -59,6 +59,16 @@ For example, to swap out `C:\Users\Leet\GitHub` with a GitHub icon, you can do t
 }
 ```
 
+### Notes
+
+- Oh My Posh will accept both `/` and `\` as path separators for a mapped location and will match regardless of which
+is used by the current operating system.
+- The character `~` at the start of a mapped location will match the user's home directory.
+- The match is case-insensitive on Windows and macOS, but case-sensitive on other operating systems.
+
+This means that for user Bill, who has a user account `Bill` on Windows and `bill` on Linux,  `~/Foo` might match
+`C:\Users\Bill\Foo` or `C:\Users\Bill\foo` on Windows but only `/home/bill/Foo` on Linux.
+
 ## Style
 
 Style sets the way the path is displayed. Based on previous experience and popular themes, there are 5 flavors.
diff --git a/src/segment_path.go b/src/segment_path.go
index ffafeb77..7c1aa480 100644
--- a/src/segment_path.go
+++ b/src/segment_path.go
@@ -220,6 +220,19 @@ func (pt *path) getPwd() string {
 	return pwd
 }
 
+func (pt *path) normalize(inputPath string) string {
+	normalized := inputPath
+	if strings.HasPrefix(inputPath, "~") {
+		normalized = pt.env.homeDir() + normalized[1:]
+	}
+	normalized = strings.ReplaceAll(normalized, "\\", "/")
+	goos := pt.env.getRuntimeGOOS()
+	if goos == windowsPlatform || goos == darwinPlatform {
+		normalized = strings.ToLower(normalized)
+	}
+	return normalized
+}
+
 func (pt *path) replaceMappedLocations(pwd string) string {
 	if strings.HasPrefix(pwd, "Microsoft.PowerShell.Core\\FileSystem::") {
 		pwd = strings.Replace(pwd, "Microsoft.PowerShell.Core\\FileSystem::", "", 1)
@@ -229,14 +242,14 @@ func (pt *path) replaceMappedLocations(pwd string) string {
 	if pt.props.getBool(MappedLocationsEnabled, true) {
 		mappedLocations["HKCU:"] = pt.props.getString(WindowsRegistryIcon, "\uF013")
 		mappedLocations["HKLM:"] = pt.props.getString(WindowsRegistryIcon, "\uF013")
-		mappedLocations[pt.env.homeDir()] = pt.props.getString(HomeIcon, "~")
+		mappedLocations[pt.normalize(pt.env.homeDir())] = pt.props.getString(HomeIcon, "~")
 	}
 
 	// merge custom locations with mapped locations
 	// mapped locations can override predefined locations
 	keyValues := pt.props.getKeyValueMap(MappedLocations, make(map[string]string))
 	for key, val := range keyValues {
-		mappedLocations[key] = val
+		mappedLocations[pt.normalize(key)] = val
 	}
 
 	// sort map keys in reverse order
@@ -250,9 +263,11 @@ func (pt *path) replaceMappedLocations(pwd string) string {
 	}
 	sort.Sort(sort.Reverse(sort.StringSlice(keys)))
 
-	for _, value := range keys {
-		if strings.HasPrefix(pwd, value) {
-			return strings.Replace(pwd, value, mappedLocations[value], 1)
+	normalizedPwd := pt.normalize(pwd)
+	for _, key := range keys {
+		if strings.HasPrefix(normalizedPwd, key) {
+			value := mappedLocations[key]
+			return value + pwd[len(key):]
 		}
 	}
 	return pwd


Test patch:
diff --git a/src/segment_path_test.go b/src/segment_path_test.go
index 085688ee..a4016c93 100644
--- a/src/segment_path_test.go
+++ b/src/segment_path_test.go
@@ -220,6 +220,7 @@ func TestRootLocationHome(t *testing.T) {
 		}
 		env.On("getArgs", nil).Return(args)
 		env.On("getPathSeperator", nil).Return(tc.PathSeperator)
+		env.On("getRuntimeGOOS", nil).Return("")
 		path := &path{
 			env:   env,
 			props: props,
@@ -247,6 +248,7 @@ func TestPathDepthMultipleLevelsDeep(t *testing.T) {
 	}
 	env := new(MockedEnvironment)
 	env.On("getPathSeperator", nil).Return("/")
+	env.On("getRunteGOOS", nil).Return("")
 	path := &path{
 		env: env,
 	}
@@ -500,12 +502,82 @@ func TestGetFullPath(t *testing.T) {
 	}
 }
 
+func TestGetFullPathCustomMappedLocations(t *testing.T) {
+	cases := []struct {
+		Pwd             string
+		MappedLocations map[string]string
+		Expected        string
+	}{
+		{Pwd: "/a/b/c/d", MappedLocations: map[string]string{"/a/b/c/d": "#"}, Expected: "#"},
+		{Pwd: "/a/b/c/d", MappedLocations: map[string]string{"\\a\\b": "#"}, Expected: "#/c/d"},
+		{Pwd: "\\a\\b\\c\\d", MappedLocations: map[string]string{"\\a\\b": "#"}, Expected: "#\\c\\d"},
+		{Pwd: "/a/b/c/d", MappedLocations: map[string]string{"/a/b": "#"}, Expected: "#/c/d"},
+		{Pwd: "/a/b/c/d", MappedLocations: map[string]string{"/a/b": "/e/f"}, Expected: "/e/f/c/d"},
+		{Pwd: "/usr/home/a/b/c/d", MappedLocations: map[string]string{"~\\a\\b": "#"}, Expected: "#/c/d"},
+		{Pwd: "/usr/home/a/b/c/d", MappedLocations: map[string]string{"~/a/b": "#"}, Expected: "#/c/d"},
+		{Pwd: "/a/usr/home/b/c/d", MappedLocations: map[string]string{"/a~": "#"}, Expected: "/a/usr/home/b/c/d"},
+		{Pwd: "/usr/home/a/b/c/d", MappedLocations: map[string]string{"/a/b": "#"}, Expected: "/usr/home/a/b/c/d"},
+	}
+
+	for _, tc := range cases {
+		env := new(MockedEnvironment)
+		env.On("getPathSeperator", nil).Return("/")
+		env.On("homeDir", nil).Return("/usr/home")
+		env.On("getcwd", nil).Return(tc.Pwd)
+		env.On("getRuntimeGOOS", nil).Return("")
+		args := &args{
+			PSWD: &tc.Pwd,
+		}
+		env.On("getArgs", nil).Return(args)
+		path := &path{
+			env: env,
+			props: &properties{
+				values: map[Property]interface{}{
+					MappedLocationsEnabled: false,
+					MappedLocations:        tc.MappedLocations,
+				},
+			},
+		}
+		got := path.getFullPath()
+		assert.Equal(t, tc.Expected, got)
+	}
+}
+
+func TestNormalizePath(t *testing.T) {
+	cases := []struct {
+		Input    string
+		GOOS     string
+		Expected string
+	}{
+		{Input: "C:\\Users\\Bob\\Foo", GOOS: linuxPlatform, Expected: "C:/Users/Bob/Foo"},
+		{Input: "C:\\Users\\Bob\\Foo", GOOS: windowsPlatform, Expected: "c:/users/bob/foo"},
+		{Input: "~\\Bob\\Foo", GOOS: linuxPlatform, Expected: "/usr/home/Bob/Foo"},
+		{Input: "~\\Bob\\Foo", GOOS: windowsPlatform, Expected: "/usr/home/bob/foo"},
+		{Input: "/foo/~/bar", GOOS: linuxPlatform, Expected: "/foo/~/bar"},
+		{Input: "/foo/~/bar", GOOS: windowsPlatform, Expected: "/foo/~/bar"},
+		{Input: "~/baz", GOOS: linuxPlatform, Expected: "/usr/home/baz"},
+		{Input: "~/baz", GOOS: windowsPlatform, Expected: "/usr/home/baz"},
+	}
+
+	for _, tc := range cases {
+		env := new(MockedEnvironment)
+		env.On("homeDir", nil).Return("/usr/home")
+		env.On("getRuntimeGOOS", nil).Return(tc.GOOS)
+		pt := &path{
+			env: env,
+		}
+		got := pt.normalize(tc.Input)
+		assert.Equal(t, tc.Expected, got)
+	}
+}
+
 func TestGetFolderPathCustomMappedLocations(t *testing.T) {
 	pwd := "/a/b/c/d"
 	env := new(MockedEnvironment)
 	env.On("getPathSeperator", nil).Return("/")
 	env.On("homeDir", nil).Return("/usr/home")
 	env.On("getcwd", nil).Return(pwd)
+	env.On("getRuntimeGOOS", nil).Return("")
 	args := &args{
 		PSWD: &pwd,
 	}
@@ -536,6 +608,7 @@ func testWritePathInfo(home, pwd, pathSeparator string) string {
 	env.On("homeDir", nil).Return(home)
 	env.On("getPathSeperator", nil).Return(pathSeparator)
 	env.On("getcwd", nil).Return(pwd)
+	env.On("getRuntimeGOOS", nil).Return("")
 	args := &args{
 		PSWD: &pwd,
 	}
@@ -635,6 +708,7 @@ func TestGetPwd(t *testing.T) {
 		env.On("getPathSeperator", nil).Return("/")
 		env.On("homeDir", nil).Return("/usr/home")
 		env.On("getcwd", nil).Return(tc.Pwd)
+		env.On("getRuntimeGOOS", nil).Return("")
 		args := &args{
 			PSWD: &tc.Pswd,
 		}