fix(bash): avoid unexpected expansions in Bash/Zsh

resolves #5468
This commit is contained in:
Jan De Dobbeleer 2024-08-19 05:59:45 +00:00
parent c07f5b2488
commit db0cd9519b
10 changed files with 35 additions and 54 deletions

View file

@ -2,6 +2,7 @@ package prompt
import ( import (
"fmt" "fmt"
"strings"
"github.com/jandedobbeleer/oh-my-posh/src/color" "github.com/jandedobbeleer/oh-my-posh/src/color"
"github.com/jandedobbeleer/oh-my-posh/src/config" "github.com/jandedobbeleer/oh-my-posh/src/config"
@ -100,10 +101,11 @@ func (e *Engine) ExtraPrompt(promptType ExtraPromptType) string {
switch e.Env.Shell() { switch e.Env.Shell() {
case shell.ZSH: case shell.ZSH:
// escape double quotes contained in the prompt
if promptType == Transient { if promptType == Transient {
prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(str)) prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(str, "\"", "\"\""))
// empty RPROMPT // empty RPROMPT
prompt += "\nRPROMPT=''" prompt += "\nRPROMPT=\"\""
return prompt return prompt
} }
return str return str

View file

@ -23,12 +23,14 @@ func (e *Engine) Primary() string {
// Warp doesn't support RPROMPT so we need to write it manually // Warp doesn't support RPROMPT so we need to write it manually
if e.isWarp() { if e.isWarp() {
e.writePrimaryRightPrompt() e.writePrimaryRightPrompt()
prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string())) // escape double quotes contained in the prompt
prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`))
return prompt return prompt
} }
prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string())) // escape double quotes contained in the prompt
prompt += fmt.Sprintf("\nRPROMPT=%s", shell.QuotePosixStr(e.rprompt)) prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`))
prompt += fmt.Sprintf("\nRPROMPT=\"%s\"", e.rprompt)
return prompt return prompt
default: default:

View file

@ -32,7 +32,7 @@ func (f Feature) Bash() Code {
} }
} }
func QuotePosixStr(str string) string { func quotePosixStr(str string) string {
if len(str) == 0 { if len(str) == 0 {
return "''" return "''"
} }

View file

@ -51,7 +51,8 @@ func GetFormats(shell string) *Formats {
ITermCurrentDir: "\\[\x1b]1337;CurrentDir=%s\x07\\]", ITermCurrentDir: "\\[\x1b]1337;CurrentDir=%s\x07\\]",
ITermRemoteHost: "\\[\x1b]1337;RemoteHost=%s@%s\x07\\]", ITermRemoteHost: "\\[\x1b]1337;RemoteHost=%s@%s\x07\\]",
EscapeSequences: map[rune]rune{ EscapeSequences: map[rune]rune{
'\\': '\\', 96: 92, // backtick
92: 92, // backslash
}, },
} }
case ZSH, TCSH: case ZSH, TCSH:
@ -100,7 +101,8 @@ func GetFormats(shell string) *Formats {
if shell == ZSH { if shell == ZSH {
formats.EscapeSequences = map[rune]rune{ formats.EscapeSequences = map[rune]rune{
'%': '%', 96: 92, // backtick
37: 37, // %
} }
} }

View file

@ -92,12 +92,12 @@ func PrintInit(env runtime.Environment, features Features, startTime *time.Time)
configFile = quotePwshStr(configFile) configFile = quotePwshStr(configFile)
script = pwshInit script = pwshInit
case ZSH: case ZSH:
executable = QuotePosixStr(executable) executable = quotePosixStr(executable)
configFile = QuotePosixStr(configFile) configFile = quotePosixStr(configFile)
script = zshInit script = zshInit
case BASH: case BASH:
executable = QuotePosixStr(executable) executable = quotePosixStr(executable)
configFile = QuotePosixStr(configFile) configFile = quotePosixStr(configFile)
script = bashInit script = bashInit
case FISH: case FISH:
executable = quoteFishStr(executable) executable = quoteFishStr(executable)
@ -112,8 +112,8 @@ func PrintInit(env runtime.Environment, features Features, startTime *time.Time)
configFile = quoteNuStr(configFile) configFile = quoteNuStr(configFile)
script = nuInit script = nuInit
case TCSH: case TCSH:
executable = QuotePosixStr(executable) executable = quotePosixStr(executable)
configFile = QuotePosixStr(configFile) configFile = quotePosixStr(configFile)
script = tcshInit script = tcshInit
case ELVISH: case ELVISH:
script = elvishInit script = elvishInit

View file

@ -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'`}, {str: `C:/tmp/omp's dir/oh-my-posh.exe`, expected: `$'C:/tmp/omp\'s dir/oh-my-posh.exe'`},
} }
for _, tc := range tests { 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))
} }
} }

View file

@ -20,9 +20,8 @@ _omp_ftcs_marks=0
# start timer on command start # start timer on command start
PS0='${_omp_start_time:0:$((_omp_start_time="$(_omp_start_timer)",0))}$(_omp_ftcs_command_start)' PS0='${_omp_start_time:0:$((_omp_start_time="$(_omp_start_timer)",0))}$(_omp_ftcs_command_start)'
# set secondary prompt # set secondary prompt
_omp_secondary_prompt=$("$_omp_executable" print secondary --shell=bash --shell-version="$BASH_VERSION") PS2="$("$_omp_executable" print secondary --shell=bash --shell-version="$BASH_VERSION")"
function _omp_set_cursor_position() { function _omp_set_cursor_position() {
# not supported in Midnight Commander # not supported in Midnight Commander
@ -83,17 +82,7 @@ function _omp_hook() {
set_poshcontext set_poshcontext
_omp_set_cursor_position _omp_set_cursor_position
# We do this to avoid unexpected expansions in a prompt string. 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')"
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 return $_omp_status_cache
} }

View file

@ -13,7 +13,7 @@ _omp_cursor_positioning=0
_omp_ftcs_marks=0 _omp_ftcs_marks=0
# set secondary prompt # set secondary prompt
_omp_secondary_prompt=$($_omp_executable print secondary --shell=zsh) PS2="$($_omp_executable print secondary --shell=zsh)"
function _omp_set_cursor_position() { function _omp_set_cursor_position() {
# not supported in Midnight Commander # not supported in Midnight Commander
@ -73,15 +73,6 @@ function _omp_precmd() {
set_poshcontext set_poshcontext
_omp_set_cursor_position _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")" 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 unset _omp_start_time
} }

View file

@ -58,6 +58,8 @@ var (
isInvisible bool isInvisible bool
isHyperlink bool isHyperlink bool
lastRune rune
Shell string Shell string
Program string Program string
@ -194,9 +196,9 @@ func FormatTitle(title string) string {
// we have to do this to prevent bash/zsh from misidentifying escape sequences // we have to do this to prevent bash/zsh from misidentifying escape sequences
switch Shell { switch Shell {
case shell.BASH: case shell.BASH:
title = strings.ReplaceAll(title, `\`, `\\`) title = strings.NewReplacer("`", "\\`", `\`, `\\`).Replace(title)
case shell.ZSH: case shell.ZSH:
title = strings.ReplaceAll(title, "%", "%%") title = strings.NewReplacer("`", "\\`", `%`, `%%`).Replace(title)
case shell.ELVISH, shell.XONSH: case shell.ELVISH, shell.XONSH:
// these shells don't support setting the title // these shells don't support setting the title
return "" return ""
@ -390,15 +392,17 @@ func write(s rune) {
return return
} }
if !Interactive && !Plain { if !Interactive {
escapeChar, shouldEscape := formats.EscapeSequences[s] for special, escape := range formats.EscapeSequences {
if shouldEscape { if s == special && lastRune != escape {
builder.WriteRune(escapeChar) builder.WriteRune(escape)
}
} }
} }
// length += utf8.RuneCountInString(string(s)) // length += utf8.RuneCountInString(string(s))
length += runewidth.RuneWidth(s) length += runewidth.RuneWidth(s)
lastRune = s
builder.WriteRune(s) builder.WriteRune(s)
} }

View file

@ -50,20 +50,11 @@ 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` | `[]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> | | `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 | | `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 in Bash/Zsh - defaults to `false` | | `interactive` | `boolean` | when is true, the segment text is not escaped to allow the use of interactive prompt escape sequences - defaults to `false` |
| `alias` | `string` | for use with [cross segment template properties][cstp] | | `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) | | `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) | | `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
Style defines how a prompt is rendered. Looking at the most prompt Style defines how a prompt is rendered. Looking at the most prompt