Repository: JanDeDobbeleer/oh-my-posh
Pull request #2025 (2022-04-03 11:05:13)

fix: correct escape sequence replacement
### 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

<!--TemplateBody-->

<!---

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



Patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 7b02e1c7..323da08e 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -11,7 +11,7 @@
         "prompt",
         "print",
         "primary",
-        "--config=${workspaceRoot}/themes/cinnamon.omp.json",
+        "--config==${workspaceRoot}/themes/cinnamon.omp.json",
         "--shell=pwsh",
         "--terminal-width=200",
       ]
diff --git a/src/cli/config_export_image.go b/src/cli/config_export_image.go
index ea5b699d..9ab8081f 100644
--- a/src/cli/config_export_image.go
+++ b/src/cli/config_export_image.go
@@ -52,7 +52,7 @@ You can tweak the output by using additional flags:
 		defer env.Close()
 		cfg := engine.LoadConfig(env)
 		ansi := &color.Ansi{}
-		ansi.Init(env.Shell())
+		ansi.InitPlain(shell.PLAIN)
 		writerColors := cfg.MakeColors(env)
 		writer := &color.AnsiWriter{
 			Ansi:               ansi,
diff --git a/src/cli/debug.go b/src/cli/debug.go
index 93064cfd..ef8d10fd 100644
--- a/src/cli/debug.go
+++ b/src/cli/debug.go
@@ -31,7 +31,7 @@ var debugCmd = &cobra.Command{
 		defer env.Close()
 		cfg := engine.LoadConfig(env)
 		ansi := &color.Ansi{}
-		ansi.Init("shell")
+		ansi.InitPlain(shell.PLAIN)
 		writerColors := cfg.MakeColors(env)
 		writer := &color.AnsiWriter{
 			Ansi:               ansi,
diff --git a/src/cli/print.go b/src/cli/print.go
index 0e896866..dfc0052a 100644
--- a/src/cli/print.go
+++ b/src/cli/print.go
@@ -70,7 +70,10 @@ var printCmd = &cobra.Command{
 		ansi.Init(env.Shell())
 		var writer color.Writer
 		if plain {
-			writer = &color.PlainWriter{}
+			ansi.InitPlain(env.Shell())
+			writer = &color.PlainWriter{
+				Ansi: ansi,
+			}
 		} else {
 			writerColors := cfg.MakeColors(env)
 			writer = &color.AnsiWriter{
diff --git a/src/color/ansi.go b/src/color/ansi.go
index 309b806d..29fb7a05 100644
--- a/src/color/ansi.go
+++ b/src/color/ansi.go
@@ -28,6 +28,7 @@ type Ansi struct {
 	escapeLeft            string
 	escapeRight           string
 	hyperlink             string
+	hyperlinkRegex        string
 	osc99                 string
 	bold                  string
 	italic                string
@@ -37,16 +38,17 @@ type Ansi struct {
 	reverse               string
 	dimmed                string
 	format                string
-	shellReservedKeywords []shellKeyWordReplacement
+	reservedSequences     []sequenceReplacement
 }
 
-type shellKeyWordReplacement struct {
+type sequenceReplacement struct {
 	text        string
 	replacement string
 }
 
 func (a *Ansi) Init(shellName string) {
 	a.shell = shellName
+	a.initEscapeSequences(shellName)
 	switch shellName {
 	case shell.ZSH:
 		a.format = "%%{%s%%}"
@@ -65,6 +67,7 @@ func (a *Ansi) Init(shellName string) {
 		a.escapeLeft = "%{"
 		a.escapeRight = "%}"
 		a.hyperlink = "%%{\x1b]8;;%s\x1b\\\\%%}%s%%{\x1b]8;;\x1b\\\\%%}"
+		a.hyperlinkRegex = `(?P<STR>%{\x1b]8;;(.+)\x1b\\\\%}(?P<TEXT>.+)%{\x1b]8;;\x1b\\\\%})`
 		a.osc99 = "%%{\x1b]9;9;\"%s\"\x1b\\%%}"
 		a.bold = "%%{\x1b[1m%%}%s%%{\x1b[22m%%}"
 		a.italic = "%%{\x1b[3m%%}%s%%{\x1b[23m%%}"
@@ -73,8 +76,6 @@ func (a *Ansi) Init(shellName string) {
 		a.reverse = "%%{\x1b[7m%%}%s%%{\x1b[27m%%}"
 		a.dimmed = "%%{\x1b[2m%%}%s%%{\x1b[22m%%}"
 		a.strikethrough = "%%{\x1b[9m%%}%s%%{\x1b[29m%%}"
-		// escape double quotes and variable expansion
-		a.shellReservedKeywords = append(a.shellReservedKeywords, shellKeyWordReplacement{"\\", "\\\\"}, shellKeyWordReplacement{"%", "%%"})
 	case shell.BASH:
 		a.format = "\\[%s\\]"
 		a.linechange = "\\[\x1b[%d%s\\]"
@@ -92,6 +93,7 @@ func (a *Ansi) Init(shellName string) {
 		a.escapeLeft = "\\["
 		a.escapeRight = "\\]"
 		a.hyperlink = "\\[\x1b]8;;%s\x1b\\\\\\]%s\\[\x1b]8;;\x1b\\\\\\]"
+		a.hyperlinkRegex = `(?P<STR>\\\[\x1b\]8;;(.+)\x1b\\\\\\\](?P<TEXT>.+)\\\[\x1b\]8;;\x1b\\\\\\\])`
 		a.osc99 = "\\[\x1b]9;9;\"%s\"\x1b\\\\\\]"
 		a.bold = "\\[\x1b[1m\\]%s\\[\x1b[22m\\]"
 		a.italic = "\\[\x1b[3m\\]%s\\[\x1b[23m\\]"
@@ -100,9 +102,6 @@ func (a *Ansi) Init(shellName string) {
 		a.reverse = "\\[\x1b[7m\\]%s\\[\x1b[27m\\]"
 		a.dimmed = "\\[\x1b[2m\\]%s\\[\x1b[22m\\]"
 		a.strikethrough = "\\[\x1b[9m\\]%s\\[\x1b[29m\\]"
-		// escape backslashes to avoid replacements
-		// https://tldp.org/HOWTO/Bash-Prompt-HOWTO/bash-prompt-escape-sequences.html
-		a.shellReservedKeywords = append(a.shellReservedKeywords, shellKeyWordReplacement{"\\", "\\\\"})
 	default:
 		a.format = "%s"
 		a.linechange = "\x1b[%d%s"
@@ -120,6 +119,9 @@ func (a *Ansi) Init(shellName string) {
 		a.escapeLeft = ""
 		a.escapeRight = ""
 		a.hyperlink = "\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\"
+		// a.hyperlinkRegex = "(?P<STR>\x1b]8;;(.+)\x1b\\(?P<TEXT>.+)\x1b]8;;\x1b\\)"
+		// \x1b]8;;https://ohmyposh.dev\x1b\\link\x1b]8;;\x1b\\
+		a.hyperlinkRegex = "(?P<STR>\x1b]8;;(.+)\x1b\\\\\\\\?(?P<TEXT>.+)\x1b]8;;\x1b\\\\)"
 		a.osc99 = "\x1b]9;9;\"%s\"\x1b\\"
 		a.bold = "\x1b[1m%s\x1b[22m"
 		a.italic = "\x1b[3m%s\x1b[23m"
@@ -132,11 +134,99 @@ func (a *Ansi) Init(shellName string) {
 	if shellName == shell.FISH {
 		a.hyperlink = "\x1b]8;;%s\x1b\\\\%s\x1b]8;;\x1b\\\\"
 	}
-	// common replacement for all shells
-	a.shellReservedKeywords = append(a.shellReservedKeywords, shellKeyWordReplacement{"`", "'"})
 }
 
-func (a *Ansi) generateHyperlink(text string) string {
+func (a *Ansi) InitPlain(shellName string) {
+	a.Init(shell.PLAIN)
+	a.initEscapeSequences(shellName)
+}
+
+func (a *Ansi) initEscapeSequences(shellName string) {
+	switch shellName {
+	case shell.ZSH:
+		// escape double quotes and variable expansion
+		a.reservedSequences = []sequenceReplacement{
+			{text: "`", replacement: "'"},
+			// {text: `\`, replacement: `\\`},
+			{text: `%l`, replacement: `%%l`},
+			{text: `%M`, replacement: `%%M`},
+			{text: `%m`, replacement: `%%m`},
+			{text: `%n`, replacement: `%%n`},
+			{text: `%y`, replacement: `%%y`},
+			{text: `%#`, replacement: `%%#`},
+			{text: `%?`, replacement: `%%?`},
+			{text: `%_`, replacement: `%%_`},
+			{text: `%^`, replacement: `%%^`},
+			{text: `%d`, replacement: `%%d`},
+			{text: `%/`, replacement: `%%/`},
+			{text: `%~`, replacement: `%%~`},
+			{text: `%e`, replacement: `%%e`},
+			{text: `%h`, replacement: `%%h`},
+			{text: `%!`, replacement: `%%!`},
+			{text: `%i`, replacement: `%%i`},
+			{text: `%I`, replacement: `%%I`},
+			{text: `%j`, replacement: `%%j`},
+			{text: `%L`, replacement: `%%L`},
+			{text: `%N`, replacement: `%%N`},
+			{text: `%x`, replacement: `%%x`},
+			{text: `%c`, replacement: `%%c`},
+			{text: `%.`, replacement: `%%.`},
+			{text: `%C`, replacement: `%%C`},
+			{text: `%D`, replacement: `%%D`},
+			{text: `%T`, replacement: `%%T`},
+			{text: `%t`, replacement: `%%t`},
+			{text: `%@`, replacement: `%%@`},
+			{text: `%*`, replacement: `%%*`},
+			{text: `%w`, replacement: `%%w`},
+			{text: `%W`, replacement: `%%W`},
+			{text: `%D`, replacement: `%%D`},
+			{text: `%B`, replacement: `%%B`},
+			{text: `%b`, replacement: `%%b`},
+			{text: `%E`, replacement: `%%E`},
+			{text: `%U`, replacement: `%%U`},
+			{text: `%S`, replacement: `%%S`},
+			{text: `%F`, replacement: `%%F`},
+			{text: `%K`, replacement: `%%K`},
+			{text: `%G`, replacement: `%%G`},
+			{text: `%v`, replacement: `%%v`},
+			{text: `%(`, replacement: `%%(`},
+		}
+	case shell.BASH:
+		a.reservedSequences = []sequenceReplacement{
+			{text: "`", replacement: "'"},
+			{text: `\a`, replacement: `\\a`},
+			{text: `\d`, replacement: `\\d`},
+			{text: `\D`, replacement: `\\D`},
+			{text: `\e`, replacement: `\\e`},
+			{text: `\h`, replacement: `\\h`},
+			{text: `\H`, replacement: `\\H`},
+			{text: `\j`, replacement: `\\j`},
+			{text: `\l`, replacement: `\\l`},
+			{text: `\n`, replacement: `\\n`},
+			{text: `\r`, replacement: `\\r`},
+			{text: `\s`, replacement: `\\s`},
+			{text: `\t`, replacement: `\\t`},
+			{text: `\T`, replacement: `\\T`},
+			{text: `\@`, replacement: `\\@`},
+			{text: `\A`, replacement: `\\A`},
+			{text: `\u`, replacement: `\\u`},
+			{text: `\v`, replacement: `\\v`},
+			{text: `\V`, replacement: `\\V`},
+			{text: `\w`, replacement: `\\w`},
+			{text: `\W`, replacement: `\\W`},
+			{text: `\!`, replacement: `\\!`},
+			{text: `\#`, replacement: `\\#`},
+			{text: `\$`, replacement: `\\$`},
+			{text: `\nnn`, replacement: `\\nnn`},
+		}
+	default:
+		a.reservedSequences = []sequenceReplacement{
+			{text: "`", replacement: "'"},
+		}
+	}
+}
+
+func (a *Ansi) GenerateHyperlink(text string) string {
 	// hyperlink matching
 	results := regex.FindNamedRegexMatch("(?P<all>(?:\\[(?P<name>.+)\\])(?:\\((?P<url>.*)\\)))", text)
 	if len(results) != 3 {
@@ -209,7 +299,8 @@ func (a *Ansi) ClearAfter() string {
 
 func (a *Ansi) EscapeText(text string) string {
 	// what to escape/replace is different per shell
-	for _, s := range a.shellReservedKeywords {
+
+	for _, s := range a.reservedSequences {
 		text = strings.ReplaceAll(text, s.text, s.replacement)
 	}
 	return text
diff --git a/src/color/plain_writer.go b/src/color/plain_writer.go
index 8e9e7230..6718eca0 100644
--- a/src/color/plain_writer.go
+++ b/src/color/plain_writer.go
@@ -7,6 +7,8 @@ import (
 
 // PlainWriter writes a plain string
 type PlainWriter struct {
+	Ansi *Ansi
+
 	builder strings.Builder
 	length  int
 }
@@ -20,7 +22,7 @@ func (a *PlainWriter) Write(background, foreground, text string) {
 		return
 	}
 	writeAndRemoveText := func(text, textToRemove, parentText string) string {
-		a.length += measureText(text)
+		a.length += a.Ansi.MeasureText(text)
 		a.builder.WriteString(text)
 		return strings.Replace(parentText, textToRemove, "", 1)
 	}
@@ -32,7 +34,7 @@ func (a *PlainWriter) Write(background, foreground, text string) {
 		text = writeAndRemoveText(textBeforeColorOverride, textBeforeColorOverride, text)
 		text = writeAndRemoveText(innerText, escapedTextSegment, text)
 	}
-	a.length += measureText(text)
+	a.length += a.Ansi.MeasureText(text)
 	a.builder.WriteString(text)
 }
 
diff --git a/src/color/text.go b/src/color/text.go
index 5e796631..b1dc2a71 100644
--- a/src/color/text.go
+++ b/src/color/text.go
@@ -6,24 +6,35 @@ import (
 	"unicode/utf8"
 )
 
-func measureText(text string) int {
+func (ansi *Ansi) MeasureText(text string) int {
 	// skip strings with ANSI
 	if !strings.Contains(text, "\x1b") {
+		text = ansi.TrimEscapeSequences(text)
 		return utf8.RuneCountInString(text)
 	}
 	if strings.Contains(text, "\x1b]8;;") {
-		matches := regex.FindAllNamedRegexMatch(regex.LINK, text)
+		matches := regex.FindAllNamedRegexMatch(ansi.hyperlinkRegex, text)
 		for _, match := range matches {
 			text = strings.ReplaceAll(text, match["STR"], match["TEXT"])
 		}
 	}
-	text = TrimAnsi(text)
+	text = ansi.TrimAnsi(text)
+	text = ansi.TrimEscapeSequences(text)
 	return utf8.RuneCountInString(text)
 }
 
-func TrimAnsi(text string) string {
+func (ansi *Ansi) TrimAnsi(text string) string {
 	if len(text) == 0 || !strings.Contains(text, "\x1b") {
 		return text
 	}
 	return regex.ReplaceAllString(AnsiRegex, text, "")
 }
+
+func (ansi *Ansi) TrimEscapeSequences(text string) string {
+	if len(text) == 0 {
+		return text
+	}
+	text = strings.ReplaceAll(text, ansi.escapeLeft, "")
+	text = strings.ReplaceAll(text, ansi.escapeRight, "")
+	return text
+}
diff --git a/src/color/writer.go b/src/color/writer.go
index ae5db80e..87809bd6 100644
--- a/src/color/writer.go
+++ b/src/color/writer.go
@@ -105,7 +105,7 @@ func (a *AnsiWriter) writeColoredText(background, foreground AnsiColor, text str
 	if text == "" || (foreground.IsTransparent() && background.IsTransparent()) {
 		return
 	}
-	a.length += measureText(text)
+	a.length += a.Ansi.MeasureText(text)
 	// default to white fg if empty, empty backgrond is supported
 	if foreground.IsEmpty() {
 		foreground = a.getAnsiFromColorString("white", false)
@@ -140,9 +140,9 @@ func (a *AnsiWriter) Write(background, foreground, text string) {
 	}
 
 	bgAnsi, fgAnsi := a.asAnsiColors(background, foreground)
-	text = a.Ansi.EscapeText(text)
 	text = a.Ansi.formatText(text)
-	text = a.Ansi.generateHyperlink(text)
+	text = a.Ansi.GenerateHyperlink(text)
+	text = a.Ansi.EscapeText(text)
 
 	// first we match for any potentially valid colors enclosed in <>
 	// i.e., find color overrides
diff --git a/src/console/title.go b/src/console/title.go
index 59fad25f..8dea749d 100644
--- a/src/console/title.go
+++ b/src/console/title.go
@@ -14,7 +14,7 @@ type Title struct {
 
 func (t *Title) GetTitle() string {
 	title := t.getTitleTemplateText()
-	title = color.TrimAnsi(title)
+	title = t.Ansi.TrimAnsi(title)
 	title = t.Ansi.EscapeText(title)
 	return t.Ansi.Title(title)
 }
diff --git a/src/engine/block.go b/src/engine/block.go
index cb081d8d..0f9d5d57 100644
--- a/src/engine/block.go
+++ b/src/engine/block.go
@@ -52,7 +52,7 @@ func (b *Block) init(env environment.Environment, writer color.Writer, ansi *col
 
 func (b *Block) initPlain(env environment.Environment, config *Config) {
 	b.ansi = &color.Ansi{}
-	b.ansi.Init(shell.PLAIN)
+	b.ansi.InitPlain(env.Shell())
 	b.writer = &color.AnsiWriter{
 		Ansi:               b.ansi,
 		TerminalBackground: shell.ConsoleBackgroundColor(env, config.TerminalBackground),


Test patch:
diff --git a/src/color/ansi_test.go b/src/color/ansi_test.go
index 4ff03af2..92afad2e 100644
--- a/src/color/ansi_test.go
+++ b/src/color/ansi_test.go
@@ -20,7 +20,7 @@ func TestGenerateHyperlinkNoUrl(t *testing.T) {
 	for _, tc := range cases {
 		a := Ansi{}
 		a.Init(tc.ShellName)
-		hyperlinkText := a.generateHyperlink(tc.Text)
+		hyperlinkText := a.GenerateHyperlink(tc.Text)
 		assert.Equal(t, tc.Expected, hyperlinkText)
 	}
 }
@@ -38,7 +38,7 @@ func TestGenerateHyperlinkWithUrl(t *testing.T) {
 	for _, tc := range cases {
 		a := Ansi{}
 		a.Init(tc.ShellName)
-		hyperlinkText := a.generateHyperlink(tc.Text)
+		hyperlinkText := a.GenerateHyperlink(tc.Text)
 		assert.Equal(t, tc.Expected, hyperlinkText)
 	}
 }
@@ -56,7 +56,7 @@ func TestGenerateHyperlinkWithUrlNoName(t *testing.T) {
 	for _, tc := range cases {
 		a := Ansi{}
 		a.Init(tc.ShellName)
-		hyperlinkText := a.generateHyperlink(tc.Text)
+		hyperlinkText := a.GenerateHyperlink(tc.Text)
 		assert.Equal(t, tc.Expected, hyperlinkText)
 	}
 }
@@ -79,7 +79,7 @@ func TestFormatText(t *testing.T) {
 	}
 	for _, tc := range cases {
 		a := Ansi{}
-		a.Init("")
+		a.InitPlain(shell.PLAIN)
 		formattedText := a.formatText(tc.Text)
 		assert.Equal(t, tc.Expected, formattedText, tc.Case)
 	}
diff --git a/src/color/text_test.go b/src/color/text_test.go
new file mode 100644
index 00000000..92123779
--- /dev/null
+++ b/src/color/text_test.go
@@ -0,0 +1,50 @@
+package color
+
+import (
+	"fmt"
+	"oh-my-posh/environment"
+	"oh-my-posh/mock"
+	"oh-my-posh/shell"
+	"oh-my-posh/template"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMeasureText(t *testing.T) {
+	cases := []struct {
+		Case     string
+		Template string
+		Expected int
+	}{
+		{
+			Case:     "Simple text",
+			Template: "src",
+			Expected: 3,
+		},
+		{
+			Case:     "Hyperlink",
+			Template: `{{ url "link" "https://ohmyposh.dev" }}`,
+			Expected: 4,
+		},
+	}
+	env := new(mock.MockedEnvironment)
+	env.On("TemplateCache").Return(&environment.TemplateCache{
+		Env: make(map[string]string),
+	})
+	shells := []string{shell.BASH, shell.ZSH, shell.PLAIN}
+	for _, shell := range shells {
+		for _, tc := range cases {
+			ansi := &Ansi{}
+			ansi.Init(shell)
+			tmpl := &template.Text{
+				Template: tc.Template,
+				Env:      env,
+			}
+			text, _ := tmpl.Render()
+			text = ansi.GenerateHyperlink(text)
+			got := ansi.MeasureText(text)
+			assert.Equal(t, tc.Expected, got, fmt.Sprintf("%s: %s", shell, tc.Case))
+		}
+	}
+}
diff --git a/src/color/writer_test.go b/src/color/writer_test.go
index 3da40a87..11f3a37c 100644
--- a/src/color/writer_test.go
+++ b/src/color/writer_test.go
@@ -1,6 +1,7 @@
 package color
 
 import (
+	"oh-my-posh/shell"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -172,7 +173,7 @@ func TestWriteANSIColors(t *testing.T) {
 
 	for _, tc := range cases {
 		ansi := &Ansi{}
-		ansi.Init("pwsh")
+		ansi.Init(shell.PWSH)
 		renderer := &AnsiWriter{
 			Ansi:               ansi,
 			ParentColors:       []*Color{tc.Parent},
diff --git a/src/console/title_test.go b/src/console/title_test.go
index 0f233266..5df2de4c 100644
--- a/src/console/title_test.go
+++ b/src/console/title_test.go
@@ -61,7 +61,7 @@ func TestGetTitle(t *testing.T) {
 			Folder:   "vagrant",
 		})
 		ansi := &color.Ansi{}
-		ansi.Init(tc.ShellName)
+		ansi.InitPlain(tc.ShellName)
 		ct := &Title{
 			Env:      env,
 			Ansi:     ansi,
@@ -116,7 +116,7 @@ func TestGetConsoleTitleIfGethostnameReturnsError(t *testing.T) {
 			HostName: "",
 		})
 		ansi := &color.Ansi{}
-		ansi.Init(tc.ShellName)
+		ansi.InitPlain(tc.ShellName)
 		ct := &Title{
 			Env:      env,
 			Ansi:     ansi,
diff --git a/src/engine/engine_test.go b/src/engine/engine_test.go
index 6bc86e3d..04a2ff36 100644
--- a/src/engine/engine_test.go
+++ b/src/engine/engine_test.go
@@ -58,7 +58,7 @@ func engineRender() {
 	defer testClearDefaultConfig()
 
 	ansi := &color.Ansi{}
-	ansi.Init(env.Shell())
+	ansi.InitPlain(env.Shell())
 	writerColors := cfg.MakeColors(env)
 	writer := &color.AnsiWriter{
 		Ansi:               ansi,
diff --git a/src/engine/image_test.go b/src/engine/image_test.go
index 95136082..904676b7 100644
--- a/src/engine/image_test.go
+++ b/src/engine/image_test.go
@@ -18,7 +18,7 @@ func runImageTest(content string) error {
 	}
 	defer os.Remove(file.Name())
 	ansi := &color.Ansi{}
-	ansi.Init(shell.PLAIN)
+	ansi.InitPlain(shell.PLAIN)
 	image := &ImageRenderer{
 		AnsiString: content,
 		Ansi:       ansi,