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

This commit is contained in:
L. Yeung 2024-08-24 12:17:04 +08:00 committed by Jan De Dobbeleer
parent c289a038ac
commit e2626c8668
11 changed files with 103 additions and 53 deletions

View file

@ -2,7 +2,6 @@ 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"
@ -101,17 +100,15 @@ 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\"", strings.ReplaceAll(str, "\"", "\"\"")) prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(str))
// empty RPROMPT // empty RPROMPT
prompt += "\nRPROMPT=\"\"" prompt += "\nRPROMPT=''"
return prompt return prompt
} }
return str return str
case shell.PWSH, shell.PWSH5: case shell.PWSH, shell.PWSH5:
if promptType == Transient { if promptType == Transient {
// Return the string and empty our buffer
// clear the line afterwards to prevent text from being written on the same line // clear the line afterwards to prevent text from being written on the same line
// see https://github.com/JanDeDobbeleer/oh-my-posh/issues/3628 // see https://github.com/JanDeDobbeleer/oh-my-posh/issues/3628
return str + terminal.ClearAfter() return str + terminal.ClearAfter()
@ -119,7 +116,6 @@ func (e *Engine) ExtraPrompt(promptType ExtraPromptType) string {
return str return str
case shell.CMD, shell.BASH, shell.FISH, shell.NU, shell.GENERIC: case shell.CMD, shell.BASH, shell.FISH, shell.NU, shell.GENERIC:
// Return the string and empty our buffer
return str return str
} }

View file

@ -23,14 +23,12 @@ 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()
// escape double quotes contained in the prompt prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string()))
prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`))
return prompt return prompt
} }
// escape double quotes contained in the prompt prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string()))
prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`)) prompt += fmt.Sprintf("\nRPROMPT=%s", shell.QuotePosixStr(e.rprompt))
prompt += fmt.Sprintf("\nRPROMPT=\"%s\"", e.rprompt)
return prompt return prompt
default: default:

View file

@ -27,7 +27,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

@ -16,7 +16,7 @@ type Formats struct {
Osc7 string Osc7 string
Osc51 string Osc51 string
EscapeSequences map[rune]rune EscapeSequences map[rune]string
HyperlinkStart string HyperlinkStart string
HyperlinkCenter string HyperlinkCenter string
@ -50,9 +50,8 @@ func GetFormats(shell string) *Formats {
ITermPromptMark: "\\[$(iterm2_prompt_mark)\\]", ITermPromptMark: "\\[$(iterm2_prompt_mark)\\]",
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]string{
96: 92, // backtick '\\': `\\`,
92: 92, // backslash
}, },
} }
case ZSH, TCSH: case ZSH, TCSH:
@ -100,9 +99,8 @@ func GetFormats(shell string) *Formats {
} }
if shell == ZSH { if shell == ZSH {
formats.EscapeSequences = map[rune]rune{ formats.EscapeSequences = map[rune]string{
96: 92, // backtick '%': "%%",
37: 37, // %
} }
} }

View file

@ -93,12 +93,12 @@ func PrintInit(env runtime.Environment, features Features, startTime *time.Time)
configFile = quotePwshOrElvishStr(configFile) configFile = quotePwshOrElvishStr(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)
@ -113,8 +113,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:
executable = quotePwshOrElvishStr(executable) executable = quotePwshOrElvishStr(executable)

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,8 +20,9 @@ _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
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() { function _omp_set_cursor_position() {
# not supported in Midnight Commander # not supported in Midnight Commander
@ -58,7 +59,38 @@ function set_poshcontext() {
return return
} }
# regular prompt function _omp_print_primary() {
# Avoid unexpected expansions.
shopt -u promptvars
local prompt
if shopt -oq posix; then
# Disable in POSIX mode.
prompt='[NOTICE: Oh My Posh prompt is not supported in POSIX mode]\n\u@\h:\w\$ '
else
prompt=$("$_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')
fi
echo "${prompt@P}"
# Allow command substitution in PS0.
shopt -s promptvars
}
function _omp_print_secondary() {
# Avoid unexpected expansions.
shopt -u promptvars
if shopt -oq posix; then
# Disable in POSIX mode.
echo '> '
else
echo "${_omp_secondary_prompt@P}"
fi
# Allow command substitution in PS0.
shopt -s promptvars
}
function _omp_hook() { function _omp_hook() {
_omp_status_cache=$? _omp_pipestatus_cache=(${PIPESTATUS[@]}) _omp_status_cache=$? _omp_pipestatus_cache=(${PIPESTATUS[@]})
@ -82,7 +114,8 @@ function _omp_hook() {
set_poshcontext set_poshcontext
_omp_set_cursor_position _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')" PS1='$(_omp_print_primary)'
PS2='$(_omp_print_secondary)'
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
PS2="$($_omp_executable print secondary --shell=zsh)" _omp_secondary_prompt=$($_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,6 +73,15 @@ 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,8 +58,6 @@ var (
isInvisible bool isInvisible bool
isHyperlink bool isHyperlink bool
lastRune rune
Shell string Shell string
Program string Program string
@ -187,24 +185,34 @@ func ClearAfter() string {
} }
func FormatTitle(title string) string { func FormatTitle(title string) string {
// These shells don't support setting the console title.
if Shell == shell.ELVISH || Shell == shell.XONSH {
return ""
}
title = trimAnsi(title) title = trimAnsi(title)
if Plain { if Plain {
return title return title
} }
// we have to do this to prevent bash/zsh from misidentifying escape sequences if Shell != shell.BASH && Shell != shell.ZSH {
switch Shell { return fmt.Sprintf(formats.Title, title)
case shell.BASH:
title = strings.NewReplacer("`", "\\`", `\`, `\\`).Replace(title)
case shell.ZSH:
title = strings.NewReplacer("`", "\\`", `%`, `%%`).Replace(title)
case shell.ELVISH, shell.XONSH:
// these shells don't support setting the title
return ""
} }
return fmt.Sprintf(formats.Title, title) // We have to do this to prevent Bash/Zsh from misidentifying escape sequences.
s := new(strings.Builder)
for _, char := range title {
escaped, shouldEscape := formats.EscapeSequences[char]
if shouldEscape {
s.WriteString(escaped)
continue
}
s.WriteRune(char)
}
return fmt.Sprintf(formats.Title, s.String())
} }
func EscapeText(text string) string { func EscapeText(text string) string {
@ -392,17 +400,18 @@ func write(s rune) {
return return
} }
if !Interactive { // UNSOLVABLE: When "Interactive" is true, the prompt length calculation in Bash/Zsh can be wrong, since the final string expansion is done by shells.
for special, escape := range formats.EscapeSequences { length += runewidth.RuneWidth(s)
if s == special && lastRune != escape { // length += utf8.RuneCountInString(string(s))
builder.WriteRune(escape)
} if !Interactive && !Plain {
escaped, shouldEscape := formats.EscapeSequences[s]
if shouldEscape {
builder.WriteString(escaped)
return
} }
} }
// length += utf8.RuneCountInString(string(s))
length += runewidth.RuneWidth(s)
lastRune = s
builder.WriteRune(s) builder.WriteRune(s)
} }

View file

@ -287,8 +287,6 @@ func TestWriteLength(t *testing.T) {
CurrentColors = tc.Colors CurrentColors = tc.Colors
Colors = &color.Defaults{} Colors = &color.Defaults{}
Init(shell.GENERIC)
Write(tc.Colors.Background, tc.Colors.Foreground, tc.Input) Write(tc.Colors.Background, tc.Colors.Foreground, tc.Input)
_, got := String() _, got := String()

View file

@ -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` | `[]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 - 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] | | `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