fix(hyperlink): use built-in format to avoid collisions

resolves #4569
This commit is contained in:
Jan De Dobbeleer 2024-01-01 12:14:12 +01:00 committed by Jan De Dobbeleer
parent 0c304f0806
commit bff6353b28
8 changed files with 142 additions and 168 deletions

View file

@ -0,0 +1,21 @@
# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2
properties:
resources:
- resource: Microsoft.WinGet.DSC/WinGetPackage
id: ohmyposh
directives:
description: Install Oh My Posh
allowPrerelease: true
settings:
id: JanDeDobbeleer.OhMyPosh
source: winget
# - resource: PSDesiredStateConfiguration/Script
# dependsOn:
# - ohmyposh
# id: fonts
# directives:
# description: Install Nerd Font
# settings:
# TestScript: return $false
# GetScript: oh-my-posh font install
configurationVersion: 0.2.0

View file

@ -66,12 +66,14 @@ const (
OSC7 = "osc7"
OSC51 = "osc51"
LINK = "link"
TEXT = "text"
OTHER = "other"
ANCHOR = "ANCHOR"
BG = "BG"
FG = "FG"
hyperLinkStart = "<LINK>"
hyperLinkEnd = "</LINK>"
hyperLinkText = "<TEXT>"
hyperLinkTextEnd = "</TEXT>"
)
// Writer writes colorized ANSI strings
@ -92,6 +94,7 @@ type Writer struct {
runes []rune
transparent bool
invisible bool
hyperlink bool
shell string
format string
@ -104,21 +107,16 @@ type Writer struct {
restoreCursorPosition string
escapeLeft string
escapeRight string
hyperlink string
hyperlinkRegex string
osc99 string
osc7 string
osc51 string
// hyperlink
hasHyperlink bool
hyperlinkBuilder strings.Builder
bracketIndex, roundCount int
hyperlinkState string
hyperlinkStart string
hyperlinkCenter string
hyperlinkEnd string
}
func (w *Writer) Init(shellName string) {
w.hyperlinkState = OTHER
w.shell = shellName
w.format = "%s"
switch w.shell {
@ -133,8 +131,9 @@ func (w *Writer) Init(shellName string) {
w.title = "\\[\x1b]0;%s\007\\]"
w.escapeLeft = "\\["
w.escapeRight = "\\]"
w.hyperlink = "\\[\x1b]8;;%s\x1b\\\\\\]%s\\[\x1b]8;;\x1b\\\\\\]"
w.hyperlinkRegex = `(?P<STR>\\\[\x1b\]8;;(.+)\x1b\\\\\\\](?P<TEXT>.+)\\\[\x1b\]8;;\x1b\\\\\\\])`
w.hyperlinkStart = "\\[\x1b]8;;"
w.hyperlinkCenter = "\x1b\\\\\\]"
w.hyperlinkEnd = "\\[\x1b]8;;\x1b\\\\\\]"
w.osc99 = "\\[\x1b]9;9;%s\x1b\\\\\\]"
w.osc7 = "\\[\x1b]7;file://%s/%s\x1b\\\\\\]"
w.osc51 = "\\[\x1b]51;A;%s@%s:%s\x1b\\\\\\]"
@ -149,8 +148,9 @@ func (w *Writer) Init(shellName string) {
w.title = "%%{\x1b]0;%s\007%%}"
w.escapeLeft = "%{"
w.escapeRight = "%}"
w.hyperlink = "%%{\x1b]8;;%s\x1b\\%%}%s%%{\x1b]8;;\x1b\\%%}"
w.hyperlinkRegex = `(?P<STR>%{\x1b]8;;(.+)\x1b\\%}(?P<TEXT>.+)%{\x1b]8;;\x1b\\%})`
w.hyperlinkStart = "%{\x1b]8;;"
w.hyperlinkCenter = "\x1b\\%}"
w.hyperlinkEnd = "%{\x1b]8;;\x1b\\%}"
w.osc99 = "%%{\x1b]9;9;%s\x1b\\%%}"
w.osc7 = "%%{\x1b]7;file://%s/%s\x1b\\%%}"
w.osc51 = "%%{\x1b]51;A%s@%s:%s\x1b\\%%}"
@ -165,8 +165,9 @@ func (w *Writer) Init(shellName string) {
// when in fish on Linux, it seems hyperlinks ending with \\ print a \
// unlike on macOS. However, this is a fish bug, so do not try to fix it here:
// https://github.com/JanDeDobbeleer/oh-my-posh/pull/3288#issuecomment-1369137068
w.hyperlink = "\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\"
w.hyperlinkRegex = "(?P<STR>\x1b]8;;(.+)\x1b\\\\\\\\?(?P<TEXT>.+)\x1b]8;;\x1b\\\\)"
w.hyperlinkStart = "\x1b]8;;"
w.hyperlinkCenter = "\x1b\\"
w.hyperlinkEnd = "\x1b]8;;\x1b\\"
w.osc99 = "\x1b]9;9;%s\x1b\\"
w.osc7 = "\x1b]7;file://%s/%s\x1b\\"
w.osc51 = "\x1b]51;A%s@%s:%s\x1b\\"
@ -292,7 +293,7 @@ func (w *Writer) Write(background, foreground, text string) {
}
// validate if we start with a color override
match := regex.FindNamedRegexMatch(AnchorRegex, text)
if len(match) != 0 {
if len(match) != 0 && match[ANCHOR] != hyperLinkStart {
colorOverride := true
for _, style := range knownStyles {
if match[ANCHOR] != style.AnchorStart {
@ -301,24 +302,29 @@ func (w *Writer) Write(background, foreground, text string) {
w.writeEscapedAnsiString(style.Start)
colorOverride = false
}
if colorOverride {
w.current.Add(w.asAnsiColors(match[BG], match[FG]))
// w.current.Background(), w.current.Foreground() = w.asAnsiColors(match[BG], match[FG])
}
}
w.writeSegmentColors()
// print the hyperlink part AFTER the coloring
if match[ANCHOR] == hyperLinkStart {
w.hyperlink = true
w.builder.WriteString(w.hyperlinkStart)
}
text = text[len(match[ANCHOR]):]
w.runes = []rune(text)
// only run hyperlink logic when we have to
w.hasHyperlink = strings.Count(text, "«")+strings.Count(text, "»")+strings.Count(text, "(")+strings.Count(text, ")") >= 4
hyperlinkTextPosition := 0
for i := 0; i < len(w.runes); i++ {
s := w.runes[i]
// ignore everything which isn't overriding
if s != '<' {
w.write(i, s)
w.write(s)
continue
}
@ -326,19 +332,41 @@ func (w *Writer) Write(background, foreground, text string) {
text = string(w.runes[i:])
match = regex.FindNamedRegexMatch(AnchorRegex, text)
if len(match) > 0 {
i = w.writeColorOverrides(match, background, i)
// check for hyperlinks first
switch match[ANCHOR] {
case hyperLinkStart:
w.hyperlink = true
i += len([]rune(match[ANCHOR])) - 1
w.builder.WriteString(w.hyperlinkStart)
continue
case hyperLinkText:
w.hyperlink = false
i += len([]rune(match[ANCHOR])) - 1
hyperlinkTextPosition = i
w.builder.WriteString(w.hyperlinkCenter)
continue
case hyperLinkTextEnd:
// this implies there's no text in the hyperlink
if hyperlinkTextPosition+1 == i {
w.builder.WriteString("link")
w.length += 4
}
i += len([]rune(match[ANCHOR])) - 1
continue
case hyperLinkEnd:
i += len([]rune(match[ANCHOR])) - 1
w.builder.WriteString(w.hyperlinkEnd)
continue
}
i = w.writeArchorOverride(match, background, i)
continue
}
w.length += runewidth.RuneWidth(s)
w.write(i, s)
w.write(s)
}
// append remnant hyperlink
w.builder.WriteString(w.hyperlinkBuilder.String())
w.hyperlinkBuilder.Reset()
w.hyperlinkState = OTHER
// reset colors
w.writeEscapedAnsiString(resetStyle.End)
@ -359,20 +387,25 @@ func (w *Writer) writeEscapedAnsiString(text string) {
if w.Plain {
return
}
if len(w.format) != 0 {
text = fmt.Sprintf(w.format, text)
}
if w.hyperlinkState == OTHER {
w.builder.WriteString(text)
return
}
w.hyperlinkBuilder.WriteString(text)
w.builder.WriteString(text)
}
func (w *Writer) getAnsiFromColorString(colorString string, isBackground bool) Color {
return w.AnsiColors.ToColor(colorString, isBackground, w.TrueColor)
}
func (w *Writer) write(s rune) {
if !w.hyperlink {
w.length += runewidth.RuneWidth(s)
}
w.builder.WriteRune(s)
}
func (w *Writer) writeSegmentColors() {
// use correct starting colors
bg := w.background
@ -410,7 +443,7 @@ func (w *Writer) writeSegmentColors() {
w.current.Add(bg, fg)
}
func (w *Writer) writeColorOverrides(match map[string]string, background string, i int) int {
func (w *Writer) writeArchorOverride(match map[string]string, background string, i int) int {
position := i
// check color reset first
if match[ANCHOR] == resetStyle.AnchorEnd {

View file

@ -1,91 +0,0 @@
package ansi
import (
"fmt"
"strings"
"github.com/jandedobbeleer/oh-my-posh/src/regex"
"github.com/jandedobbeleer/oh-my-posh/src/shell"
"github.com/mattn/go-runewidth"
)
func (w *Writer) write(i int, s rune) {
// ignore processing when invisible (<transparent,transparent>)
if w.invisible {
return
}
// ignore the logic when there is no hyperlink or things arent't visible
if !w.hasHyperlink {
w.length += runewidth.RuneWidth(s)
w.builder.WriteRune(s)
return
}
if s == '«' && w.hyperlinkState == OTHER {
w.hyperlinkState = TEXT
w.hyperlinkBuilder.WriteRune(s)
return
}
if w.hyperlinkState == OTHER {
w.length += runewidth.RuneWidth(s)
w.builder.WriteRune(s)
return
}
w.hyperlinkBuilder.WriteRune(s)
switch s {
case '»':
// potential end of text part of hyperlink
w.bracketIndex = i
case '(':
// split into link part
if w.bracketIndex == i-1 {
w.hyperlinkState = LINK
}
if w.hyperlinkState == LINK {
w.roundCount++
}
case ')':
if w.hyperlinkState != LINK {
return
}
w.roundCount--
if w.roundCount != 0 {
return
}
// end of link part
w.builder.WriteString(w.replaceHyperlink(w.hyperlinkBuilder.String()))
w.hyperlinkBuilder.Reset()
w.hyperlinkState = OTHER
}
}
func (w *Writer) replaceHyperlink(text string) string {
// hyperlink matching
results := regex.FindNamedRegexMatch("(?P<ALL>(?:«(?P<TEXT>.+)»)(?:\\((?P<URL>.*)\\)))", text)
if len(results) != 3 {
return text
}
linkText := results["TEXT"]
// this isn't supported for elvish and xonsh
if w.shell == shell.ELVISH || w.shell == shell.XONSH {
return strings.Replace(text, results["ALL"], linkText, 1)
}
// we only care about the length of the actual text part
characters := w.trimAnsi(linkText)
w.length += runewidth.StringWidth(characters)
if w.Plain {
return linkText
}
// build hyperlink ansi
hyperlink := fmt.Sprintf(w.hyperlink, results["URL"], linkText)
// replace original text by the new ones
return strings.Replace(text, results["ALL"], hyperlink, 1)
}

View file

@ -37,7 +37,7 @@ func TestGenerateHyperlinkWithUrl(t *testing.T) {
Expected string
}{
{
Text: "«google»(http://www.google.be) «maps (2/2)»(http://maps.google.be)",
Text: "<LINK>http://www.google.be<TEXT>google</TEXT></LINK> <LINK>http://maps.google.be<TEXT>maps (2/2)</TEXT></LINK>",
ShellName: shell.FISH,
Expected: "\x1b[47m\x1b[30m\x1b]8;;http://www.google.be\x1b\\google\x1b]8;;\x1b\\ \x1b]8;;http://maps.google.be\x1b\\maps (2/2)\x1b]8;;\x1b\\\x1b[0m",
},
@ -46,44 +46,51 @@ func TestGenerateHyperlinkWithUrl(t *testing.T) {
ShellName: shell.PWSH,
Expected: "\x1b[47m\x1b[30min \x1b[49m\x1b[1mpwsh \x1b[22m\x1b[47m \x1b[0m",
},
{Text: "«google»(http://www.google.be)", ShellName: shell.ZSH, Expected: "%{\x1b[47m%}%{\x1b[30m%}%{\x1b]8;;http://www.google.be\x1b\\%}google%{\x1b]8;;\x1b\\%}%{\x1b[0m%}"},
{Text: "«google»(http://www.google.be)", ShellName: shell.PWSH, Expected: "\x1b[47m\x1b[30m\x1b]8;;http://www.google.be\x1b\\google\x1b]8;;\x1b\\\x1b[0m"},
{
Text: "«google»(http://www.google.be)",
Text: "<LINK>http://www.google.be<TEXT>google</TEXT></LINK>",
ShellName: shell.ZSH,
Expected: "%{\x1b[47m%}%{\x1b[30m%}%{\x1b]8;;http://www.google.be\x1b\\%}google%{\x1b]8;;\x1b\\%}%{\x1b[0m%}",
},
{
Text: "<LINK>http://www.google.be<TEXT>google</TEXT></LINK>",
ShellName: shell.PWSH,
Expected: "\x1b[47m\x1b[30m\x1b]8;;http://www.google.be\x1b\\google\x1b]8;;\x1b\\\x1b[0m",
},
{
Text: "<LINK>http://www.google.be<TEXT>google</TEXT></LINK>",
ShellName: shell.BASH,
Expected: "\\[\x1b[47m\\]\\[\x1b[30m\\]\\[\x1b]8;;http://www.google.be\x1b\\\\\\]google\\[\x1b]8;;\x1b\\\\\\]\\[\x1b[0m\\]",
},
{
Text: "«google»(http://www.google.be) «maps»(http://maps.google.be)",
Text: "<LINK>http://www.google.be<TEXT>google</TEXT></LINK> <LINK>http://maps.google.be<TEXT>maps</TEXT></LINK>",
ShellName: shell.FISH,
Expected: "\x1b[47m\x1b[30m\x1b]8;;http://www.google.be\x1b\\google\x1b]8;;\x1b\\ \x1b]8;;http://maps.google.be\x1b\\maps\x1b]8;;\x1b\\\x1b[0m",
},
{
Text: "[]«google»(http://www.google.be)[]",
Text: "[]<LINK>http://www.google.be<TEXT>google</TEXT></LINK>[]",
ShellName: shell.FISH,
Expected: "\x1b[47m\x1b[30m[]\x1b]8;;http://www.google.be\x1b\\google\x1b]8;;\x1b\\[]\x1b[0m",
},
}
for _, tc := range cases {
a := Writer{
AnsiColors: &DefaultColors{},
}
a.Init(tc.ShellName)
a.Write("white", "black", tc.Text)
hyperlinkText, _ := a.String()
assert.Equal(t, tc.Expected, hyperlinkText)
}
}
func TestGenerateHyperlinkWithUrlNoName(t *testing.T) {
cases := []struct {
Text string
ShellName string
Expected string
}{
{Text: "«»(http://www.google.be)", ShellName: shell.ZSH, Expected: "%{\x1b[47m%}%{\x1b[30m%}«»(http://www.google.be)%{\x1b[0m%}"},
{Text: "«»(http://www.google.be)", ShellName: shell.PWSH, Expected: "\x1b[47m\x1b[30m«»(http://www.google.be)\x1b[0m"},
{Text: "«»(http://www.google.be)", ShellName: shell.BASH, Expected: "\\[\x1b[47m\\]\\[\x1b[30m\\]«»(http://www.google.be)\\[\x1b[0m\\]"},
{
Text: "<LINK>http://www.google.be<TEXT><blue>google</></TEXT></LINK>",
ShellName: shell.FISH,
Expected: "\x1b[47m\x1b[30m\x1b]8;;http://www.google.be\x1b\\\x1b[49m\x1b[34mgoogle\x1b[47m\x1b[30m\x1b]8;;\x1b\\\x1b[0m",
},
{
Text: "<LINK>http://www.google.be<TEXT></TEXT></LINK>",
ShellName: shell.ZSH,
Expected: "%{\x1b[47m%}%{\x1b[30m%}%{\x1b]8;;http://www.google.be\x1b\\%}link%{\x1b]8;;\x1b\\%}%{\x1b[0m%}",
},
{
Text: "<LINK>http://www.google.be<TEXT></TEXT></LINK>",
ShellName: shell.PWSH,
Expected: "\x1b[47m\x1b[30m\x1b]8;;http://www.google.be\x1b\\link\x1b]8;;\x1b\\\x1b[0m",
},
{
Text: "<LINK>http://www.google.be<TEXT></TEXT></LINK>",
ShellName: shell.BASH,
Expected: "\\[\x1b[47m\\]\\[\x1b[30m\\]\\[\x1b]8;;http://www.google.be\x1b\\\\\\]link\\[\x1b]8;;\x1b\\\\\\]\\[\x1b[0m\\]",
},
}
for _, tc := range cases {
a := Writer{
@ -102,10 +109,10 @@ func TestGenerateFileLink(t *testing.T) {
Expected string
}{
{
Text: `«Posh»(file:C:/Program Files (x86)/Common Files/Microsoft Shared/Posh)`,
Text: `<LINK>file:C:/Program Files (x86)/Common Files/Microsoft Shared/Posh<TEXT>Posh</TEXT></LINK>`,
Expected: "\x1b[47m\x1b[30m\x1b]8;;file:C:/Program Files (x86)/Common Files/Microsoft Shared/Posh\x1b\\Posh\x1b]8;;\x1b\\\x1b[0m",
},
{Text: `«Windows»(file:C:/Windows)`, Expected: "\x1b[47m\x1b[30m\x1b]8;;file:C:/Windows\x1b\\Windows\x1b]8;;\x1b\\\x1b[0m"},
{Text: `<LINK>file:C:/Windows<TEXT>Windows</TEXT></LINK>`, Expected: "\x1b[47m\x1b[30m\x1b]8;;file:C:/Windows\x1b\\Windows\x1b]8;;\x1b\\\x1b[0m"},
}
for _, tc := range cases {
a := Writer{

View file

@ -244,16 +244,22 @@ func TestWriteLength(t *testing.T) {
},
{
Case: "Bold with color override and link",
Input: "<b><#ffffff>test</></b> «url»(https://example.com)",
Input: "<b><#ffffff>test</></b> <LINK>https://example.com<TEXT>url</TEXT></LINK>",
Expected: 8,
Colors: &Colors{Foreground: "black", Background: ParentBackground},
},
{
Case: "Bold with color override and link and leading/trailing spaces",
Input: " <b><#ffffff>test</></b> «url»(https://example.com) ",
Input: " <b><#ffffff>test</></b> <LINK>https://example.com<TEXT>url</TEXT></LINK> ",
Expected: 10,
Colors: &Colors{Foreground: "black", Background: ParentBackground},
},
{
Case: "Bold with color override and link without text and leading/trailing spaces",
Input: " <b><#ffffff>test</></b> <LINK>https://example.com<TEXT></TEXT></LINK> ",
Expected: 11,
Colors: &Colors{Foreground: "black", Background: ParentBackground},
},
}
for _, tc := range cases {

View file

@ -7,8 +7,6 @@ import (
"github.com/stretchr/testify/assert"
)
const icon = "\ue63a"
func TestBazel(t *testing.T) {
cases := []struct {
Case string
@ -16,9 +14,9 @@ func TestBazel(t *testing.T) {
Version string
Template string
}{
{Case: "bazel 6.4.0", ExpectedString: fmt.Sprintf("«%s»(https://bazel.build/versions/6.4.0) 6.4.0", icon), Version: "bazel 6.4.0", Template: ""},
{Case: "bazel 10.11.12", ExpectedString: fmt.Sprintf("«%s»(https://docs.bazel.build/versions/3.7.0) 3.7.0", icon), Version: "bazel 3.7.0"},
{Case: "", ExpectedString: fmt.Sprintf("%s err parsing info from bazel with", icon), Version: ""},
{Case: "bazel 6.4.0", ExpectedString: "<LINK>https://bazel.build/versions/6.4.0<TEXT>\ue63a</TEXT></LINK> 6.4.0", Version: "bazel 6.4.0", Template: ""},
{Case: "bazel 10.11.12", ExpectedString: "<LINK>https://docs.bazel.build/versions/3.7.0<TEXT>\ue63a</TEXT></LINK> 3.7.0", Version: "bazel 3.7.0"},
{Case: "", ExpectedString: "\ue63a err parsing info from bazel with", Version: ""},
}
for _, tc := range cases {
params := &mockedLanguageParams{
@ -28,7 +26,7 @@ func TestBazel(t *testing.T) {
extension: "*.bazel",
}
env, props := getMockedLanguageEnv(params)
props[Icon] = icon
props[Icon] = "\ue63a"
b := &Bazel{}
b.Init(props, env)
failMsg := fmt.Sprintf("Failed in case: %s", tc.Case)

View file

@ -14,9 +14,9 @@ func url(text, url string) (string, error) {
if err != nil {
return "", err
}
return fmt.Sprintf("«%s»(%s)", text, url), nil
return fmt.Sprintf("<LINK>%s<TEXT>%s</TEXT></LINK>", url, text), nil
}
func path(text, path string) (string, error) {
return fmt.Sprintf("«%s»(file:%s)", text, path), nil
return fmt.Sprintf("<LINK>file:%s<TEXT>%s</TEXT></LINK>", path, text), nil
}

View file

@ -17,7 +17,7 @@ func TestUrl(t *testing.T) {
Template string
ShouldError bool
}{
{Case: "valid url", Expected: "«link»(https://ohmyposh.dev)", Template: `{{ url "link" "https://ohmyposh.dev" }}`},
{Case: "valid url", Expected: "<LINK>https://ohmyposh.dev<TEXT>link</TEXT></LINK>", Template: `{{ url "link" "https://ohmyposh.dev" }}`},
{Case: "invalid url", Expected: "", Template: `{{ url "link" "Foo" }}`, ShouldError: true},
}
@ -49,7 +49,7 @@ func TestPath(t *testing.T) {
Expected string
Template string
}{
{Case: "valid path", Expected: "«link»(file:/test/test)", Template: `{{ path "link" "/test/test" }}`},
{Case: "valid path", Expected: "<LINK>file:/test/test<TEXT>link</TEXT></LINK>", Template: `{{ path "link" "/test/test" }}`},
}
env := &mock.MockedEnvironment{}