Repository: JanDeDobbeleer/oh-my-posh
Pull request #5456 (2024-08-15 16:44:23)

fix(shell): avoid unexpected expansions in Bash/Zsh
### Prerequisites

- [x] I have read and understood the [contributing guide][CONTRIBUTING.md].
- [x] The commit message follows the [conventional commits][cc] guidelines.
- [ ] Tests for the changes have been added (for bug fixes / features).
- [x] Docs have been added/updated (for bug fixes / features).

### Description

<details><summary>For Bash:</summary>

According to [descriptions about prompt controlling][bash-manual-prompt-controlling] in *Bash Reference Manual*:

> After the string is decoded, it is expanded via parameter expansion, command substitution, arithmetic expansion, and quote removal, subject to the value of the `promptvars` shell option (see [The Shopt Builtin][bash-manual-shopt-builtin]). This can have unwanted side effects if escaped portions of the string appear within command substitution or contain characters special to word expansion.

To get rid of unexpected expansions, we have to disable the `promptvars` shell option. Also, we have to disable OMP prompts if we're in [POSIX mode][bash-manual-posix-mode].

</details>

<details><summary>For Zsh:</summary>

According to [descriptions about expansion of prompt sequences][zsh-manual-expansion-of-prompt-sequences] in *Zsh Manual*:

> If the PROMPT_SUBST option is set, the prompt string is first subjected to *parameter expansion*, *command substitution* and *arithmetic expansion*. See [Expansion][zsh-manual-expansion].
>
> Certain escape sequences may be recognised in the prompt string.
>
> If the PROMPT_BANG option is set, a ‘!’ in the prompt is replaced by the current history event number. A literal ‘!’ may then be represented as ‘!!’.
>
> If the PROMPT_PERCENT option is set, certain escape sequences that start with ‘%’ are expanded. Many escapes are followed by a single character, although some of these take an optional integer argument that should appear between the ‘%’ and the next character of the sequence. More complicated escape sequences are available to provide conditional expansion.

To get rid of unexpected expansions, we have to disable `PROMPT_SUBST` and `PROMPT_BANG` shell options. Also, we have to ensure the `PROMPT_PERCENT` shell option for escape sequences to work in a prompt string.

</details>

An unsolvable problem mentioned in #5455 has been included into the documentation.

[CONTRIBUTING.md]: https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/CONTRIBUTING.md
[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary
[bash-manual-prompt-controlling]: https://www.gnu.org/software/bash/manual/bash.html#Controlling-the-Prompt
[bash-manual-shopt-builtin]: https://www.gnu.org/software/bash/manual/bash.html#The-Shopt-Builtin
[bash-manual-posix-mode]: https://www.gnu.org/software/bash/manual/bash.html#Bash-POSIX-Mode
[zsh-manual-expansion-of-prompt-sequences]: https://zsh-manual.netlify.app/prompt-expansion#131-expansion-of-prompt-sequences
[zsh-manual-expansion]: https://zsh-manual.netlify.app/expansion#Expansion


Patch:
diff --git a/src/prompt/extra.go b/src/prompt/extra.go
index 7b8f6c67..0bd69dc3 100644
--- a/src/prompt/extra.go
+++ b/src/prompt/extra.go
@@ -2,7 +2,6 @@ package prompt
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/jandedobbeleer/oh-my-posh/src/color"
 	"github.com/jandedobbeleer/oh-my-posh/src/config"
@@ -101,11 +100,10 @@ func (e *Engine) ExtraPrompt(promptType ExtraPromptType) string {
 
 	switch e.Env.Shell() {
 	case shell.ZSH:
-		// escape double quotes contained in the prompt
 		if promptType == Transient {
-			prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(str, "\"", "\"\""))
+			prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(str))
 			// empty RPROMPT
-			prompt += "\nRPROMPT=\"\""
+			prompt += "\nRPROMPT=''"
 			return prompt
 		}
 		return str
diff --git a/src/prompt/primary.go b/src/prompt/primary.go
index 98498c2f..edefd305 100644
--- a/src/prompt/primary.go
+++ b/src/prompt/primary.go
@@ -23,14 +23,12 @@ func (e *Engine) Primary() string {
 		// Warp doesn't support RPROMPT so we need to write it manually
 		if e.isWarp() {
 			e.writePrimaryRightPrompt()
-			// escape double quotes contained in the prompt
-			prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`))
+			prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string()))
 			return prompt
 		}
 
-		// escape double quotes contained in the prompt
-		prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`))
-		prompt += fmt.Sprintf("\nRPROMPT=\"%s\"", e.rprompt)
+		prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string()))
+		prompt += fmt.Sprintf("\nRPROMPT=%s", shell.QuotePosixStr(e.rprompt))
 
 		return prompt
 	default:
diff --git a/src/shell/bash.go b/src/shell/bash.go
index d4bd44f6..cc9014c1 100644
--- a/src/shell/bash.go
+++ b/src/shell/bash.go
@@ -32,7 +32,7 @@ func (f Feature) Bash() Code {
 	}
 }
 
-func quotePosixStr(str string) string {
+func QuotePosixStr(str string) string {
 	if len(str) == 0 {
 		return "''"
 	}
diff --git a/src/shell/formats.go b/src/shell/formats.go
index 734c73fb..51f34e0a 100644
--- a/src/shell/formats.go
+++ b/src/shell/formats.go
@@ -51,8 +51,7 @@ func GetFormats(shell string) *Formats {
 			ITermCurrentDir:       "\\[\x1b]1337;CurrentDir=%s\x07\\]",
 			ITermRemoteHost:       "\\[\x1b]1337;RemoteHost=%s@%s\x07\\]",
 			EscapeSequences: map[rune]rune{
-				96: 92, // backtick
-				92: 92, // backslash
+				'\\': '\\',
 			},
 		}
 	case ZSH, TCSH:
@@ -101,8 +100,7 @@ func GetFormats(shell string) *Formats {
 
 	if shell == ZSH {
 		formats.EscapeSequences = map[rune]rune{
-			96: 92, // backtick
-			37: 37, // %
+			'%': '%',
 		}
 	}
 
diff --git a/src/shell/init.go b/src/shell/init.go
index 566f616a..cc60fbc1 100644
--- a/src/shell/init.go
+++ b/src/shell/init.go
@@ -92,12 +92,12 @@ func PrintInit(env runtime.Environment, features Features, startTime *time.Time)
 		configFile = quotePwshStr(configFile)
 		script = pwshInit
 	case ZSH:
-		executable = quotePosixStr(executable)
-		configFile = quotePosixStr(configFile)
+		executable = QuotePosixStr(executable)
+		configFile = QuotePosixStr(configFile)
 		script = zshInit
 	case BASH:
-		executable = quotePosixStr(executable)
-		configFile = quotePosixStr(configFile)
+		executable = QuotePosixStr(executable)
+		configFile = QuotePosixStr(configFile)
 		script = bashInit
 	case FISH:
 		executable = quoteFishStr(executable)
@@ -112,8 +112,8 @@ func PrintInit(env runtime.Environment, features Features, startTime *time.Time)
 		configFile = quoteNuStr(configFile)
 		script = nuInit
 	case TCSH:
-		executable = quotePosixStr(executable)
-		configFile = quotePosixStr(configFile)
+		executable = QuotePosixStr(executable)
+		configFile = QuotePosixStr(configFile)
 		script = tcshInit
 	case ELVISH:
 		script = elvishInit
diff --git a/src/shell/scripts/omp.bash b/src/shell/scripts/omp.bash
index b793ba68..b0a64284 100644
--- a/src/shell/scripts/omp.bash
+++ b/src/shell/scripts/omp.bash
@@ -20,8 +20,9 @@ _omp_ftcs_marks=0
 
 # start timer on command start
 PS0='${_omp_start_time:0:$((_omp_start_time="$(_omp_start_timer)",0))}$(_omp_ftcs_command_start)'
+
 # set secondary prompt
-PS2="$("$_omp_executable" print secondary --shell=bash --shell-version="$BASH_VERSION")"
+_omp_secondary_prompt=$("$_omp_executable" print secondary --shell=bash --shell-version="$BASH_VERSION")
 
 function _omp_set_cursor_position() {
     # not supported in Midnight Commander
@@ -35,7 +36,7 @@ function _omp_set_cursor_position() {
 
     local COL
     local ROW
-    IFS=';' read -sdR -p $'\E[6n' ROW COL
+    IFS=';' read -rsdR -p $'\E[6n' ROW COL
 
     stty "$oldstty"
 
@@ -60,10 +61,10 @@ function set_poshcontext() {
 
 # regular prompt
 function _omp_hook() {
-    _omp_status_cache=$? _omp_pipestatus_cache=(${PIPESTATUS[@]})
+    _omp_status_cache=$? _omp_pipestatus_cache=("${PIPESTATUS[@]}")
 
     if [[ ${#BP_PIPESTATUS[@]} -ge ${#_omp_pipestatus_cache[@]} ]]; then
-        _omp_pipestatus_cache=(${BP_PIPESTATUS[@]})
+        _omp_pipestatus_cache=("${BP_PIPESTATUS[@]}")
     fi
 
     _omp_stack_count=$((${#DIRSTACK[@]} - 1))
@@ -82,11 +83,29 @@ function _omp_hook() {
     set_poshcontext
     _omp_set_cursor_position
 
-    PS1="$("$_omp_executable" print primary --shell=bash --shell-version="$BASH_VERSION" --status="$_omp_status_cache" --pipestatus="${_omp_pipestatus_cache[*]}" --execution-time="$_omp_elapsed" --stack-count="$_omp_stack_count" --no-status="$_omp_no_exit_code" --terminal-width="${COLUMNS-0}" | tr -d '\0')"
+    # We do this to avoid unexpected expansions in a prompt string.
+    shopt -u promptvars
+
+    if shopt -oq posix; then
+        # Disable in POSIX mode.
+        PS1='[NOTICE: Oh My Posh prompt is not supported in POSIX mode]\n\u@\h:\w\$ '
+        PS2='> '
+    else
+        PS1=$("$_omp_executable" print primary --shell=bash --shell-version="$BASH_VERSION" --status="$_omp_status_cache" --pipestatus="${_omp_pipestatus_cache[*]}" --execution-time="$_omp_elapsed" --stack-count="$_omp_stack_count" --no-status="$_omp_no_exit_code" --terminal-width="${COLUMNS-0}" | tr -d '\0')
+        PS2=$_omp_secondary_prompt
+    fi
 
     return $_omp_status_cache
 }
 
-if [[ $TERM != linux ]] && ! [[ $PROMPT_COMMAND =~ _omp_hook ]]; then
-    PROMPT_COMMAND="_omp_hook; $PROMPT_COMMAND"
-fi
+function _omp_install_hook() {
+    local cmd
+    for cmd in "${PROMPT_COMMAND[@]}"; do
+        if [[ $cmd = "_omp_hook" ]]; then
+            return
+        fi
+    done
+    PROMPT_COMMAND=(_omp_hook "${PROMPT_COMMAND[@]}")
+}
+
+[[ $TERM != linux ]] && _omp_install_hook
diff --git a/src/shell/scripts/omp.zsh b/src/shell/scripts/omp.zsh
index 59ebaac7..7967a784 100644
--- a/src/shell/scripts/omp.zsh
+++ b/src/shell/scripts/omp.zsh
@@ -13,7 +13,7 @@ _omp_cursor_positioning=0
 _omp_ftcs_marks=0
 
 # set secondary prompt
-PS2="$($_omp_executable print secondary --shell=zsh)"
+_omp_secondary_prompt=$($_omp_executable print secondary --shell=zsh)
 
 function _omp_set_cursor_position() {
   # not supported in Midnight Commander
@@ -73,6 +73,15 @@ function _omp_precmd() {
   set_poshcontext
   _omp_set_cursor_position
 
+  # We do this to avoid unexpected expansions in a prompt string.
+  unsetopt PROMPT_SUBST
+  unsetopt PROMPT_BANG
+
+  # Ensure that escape sequences work in a prompt string.
+  setopt PROMPT_PERCENT
+
+  PS2=$_omp_secondary_prompt
+
   eval "$($_omp_executable print primary --status="$_omp_status_cache" --pipestatus="${_omp_pipestatus_cache[*]}" --execution-time="$_omp_elapsed" --stack-count="$_omp_stack_count" --eval --shell=zsh --shell-version="$ZSH_VERSION" --no-status="$_omp_no_exit_code")"
   unset _omp_start_time
 }
diff --git a/src/terminal/writer.go b/src/terminal/writer.go
index 977a34ff..76679c14 100644
--- a/src/terminal/writer.go
+++ b/src/terminal/writer.go
@@ -58,8 +58,6 @@ var (
 	isInvisible   bool
 	isHyperlink   bool
 
-	lastRune rune
-
 	Shell   string
 	Program string
 
@@ -196,9 +194,9 @@ func FormatTitle(title string) string {
 	// we have to do this to prevent bash/zsh from misidentifying escape sequences
 	switch Shell {
 	case shell.BASH:
-		title = strings.NewReplacer("`", "\\`", `\`, `\\`).Replace(title)
+		title = strings.ReplaceAll(title, `\`, `\\`)
 	case shell.ZSH:
-		title = strings.NewReplacer("`", "\\`", `%`, `%%`).Replace(title)
+		title = strings.ReplaceAll(title, "%", "%%")
 	case shell.ELVISH, shell.XONSH:
 		// these shells don't support setting the title
 		return ""
@@ -392,17 +390,15 @@ func write(s rune) {
 		return
 	}
 
-	if !Interactive {
-		for special, escape := range formats.EscapeSequences {
-			if s == special && lastRune != escape {
-				builder.WriteRune(escape)
-			}
+	if !Interactive && !Plain {
+		escapeChar, shouldEscape := formats.EscapeSequences[s]
+		if shouldEscape {
+			builder.WriteRune(escapeChar)
 		}
 	}
 
 	// length += utf8.RuneCountInString(string(s))
 	length += runewidth.RuneWidth(s)
-	lastRune = s
 	builder.WriteRune(s)
 }
 
diff --git a/website/docs/configuration/segment.mdx b/website/docs/configuration/segment.mdx
index 1ee55e4c..fd0cd20e 100644
--- a/website/docs/configuration/segment.mdx
+++ b/website/docs/configuration/segment.mdx
@@ -50,11 +50,20 @@ understand how to configure a segment.
 | `templates`                | `[]Template` | in some cases having a single [template][templates] string is a bit cumbersome. Templates allows you to span the segment's [template][templates] string multiple lines where every [template][templates] is evaluated and depending on what you aim to achieve, there are two possible outcomes based on `templates_logic` |
 | `templates_logic`          | `string`     | <ul><li>`first_match`: return the first non-whitespace string and skip everything else</li><li>`join`:evaluate all templates and join all non-whitespace strings (**default**)</li></ul>                                                                                                                                   |
 | `properties`               | `[]Property` | see [Properties][properties] below                                                                                                                                                                                                                                                                                         |
-| `interactive`              | `boolean`    | when is true, the segment text is not escaped to allow the use of interactive prompt escape sequences - defaults to `false`                                                                                                                                                                                                |
+| `interactive`              | `boolean`    | when is true, the segment text is not escaped to allow the use of interactive prompt escape sequences in Bash/Zsh - defaults to `false`                                                                                                                                                                                    |
 | `alias`                    | `string`     | for use with [cross segment template properties][cstp]                                                                                                                                                                                                                                                                     |
 | `min_width`                | `int`        | if the terminal width is smaller than this value, the segment will be hidden. For your terminal width, see `oh-my-posh get width`. Defaults to `0` (disable)                                                                                                                                                               |
 | `max_width`                | `int`        | if the terminal width exceeds this value, the segment will be hidden. For your terminal width, see `oh-my-posh get width`. Defaults to `0` (disable)                                                                                                                                                                       |
 
+:::warning
+In Bash/Zsh, when the property `interactive` is `true` for a segment, the prompt length calculation can be wrong
+because of possible string expansions (e.g., `\w` in Bash and `%d` in Zsh which both expand to the current working
+directory), thus a right-aligned block is not being properly right-aligned.
+
+Unfortunately, it's not possible for Oh My Posh to know the final prompt length since the string expansion is done
+by your shell, so use this at your own risk.
+:::
+
 ## Style
 
 Style defines how a prompt is rendered. Looking at the most prompt


Test patch:
diff --git a/src/shell/init_test.go b/src/shell/init_test.go
index 141456a5..f16b16c7 100644
--- a/src/shell/init_test.go
+++ b/src/shell/init_test.go
@@ -35,7 +35,7 @@ func TestQuotePosixStr(t *testing.T) {
 		{str: `C:/tmp/omp's dir/oh-my-posh.exe`, expected: `$'C:/tmp/omp\'s dir/oh-my-posh.exe'`},
 	}
 	for _, tc := range tests {
-		assert.Equal(t, tc.expected, quotePosixStr(tc.str), fmt.Sprintf("quotePosixStr: %s", tc.str))
+		assert.Equal(t, tc.expected, QuotePosixStr(tc.str), fmt.Sprintf("quotePosixStr: %s", tc.str))
 	}
 }