Skip to content

Commit

Permalink
text: add correct handling of hyperlink escape sequences (#256)
Browse files Browse the repository at this point in the history
Ref.: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda

Co-authored-by: vova <vladimir.semitchev@hpe.com>
  • Loading branch information
vsemichev and vova-hpe committed Feb 25, 2023
1 parent 5d02e14 commit e2e2386
Show file tree
Hide file tree
Showing 2 changed files with 88 additions and 43 deletions.
116 changes: 73 additions & 43 deletions text/string.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import (
const (
EscapeReset = EscapeStart + "0" + EscapeStop
EscapeStart = "\x1b["
CSIStartRune = rune(91) // [
CSIStopRune = 'm'
OSIStartRune = rune(93) // ]
OSIStopRune = '\\'
EscapeStartRune = rune(27) // \x1b
EscapeStop = "m"
EscapeStopRune = 'm'
Expand All @@ -21,6 +25,41 @@ var (
rwCondition = runewidth.NewCondition()
)

type escKind int

const (
Unknown escKind = iota
CSI
OSI
)

type escSeq struct {
isIn bool
content strings.Builder
kind escKind
}

func (e *escSeq) InspectRune(r rune) {
if !e.isIn && r == EscapeStartRune {
e.isIn = true
e.kind = Unknown
e.content.Reset()
e.content.WriteRune(r)
} else if e.isIn {
switch {
case e.kind == Unknown && r == CSIStartRune:
e.kind = CSI
case e.kind == Unknown && r == OSIStartRune:
e.kind = OSI
case e.kind == CSI && r == CSIStopRune || e.kind == OSI && r == OSIStopRune:
e.isIn = false
e.kind = Unknown
}
e.content.WriteRune(r)
}
return
}

// InsertEveryN inserts the rune every N characters in the string. For ex.:
// InsertEveryN("Ghost", '-', 1) == "G-h-o-s-t"
// InsertEveryN("Ghost", '-', 2) == "Gh-os-t"
Expand All @@ -35,23 +74,21 @@ func InsertEveryN(str string, runeToInsert rune, n int) string {
sLen := RuneWidthWithoutEscSequences(str)
var out strings.Builder
out.Grow(sLen + (sLen / n))
outLen, isEscSeq := 0, false
outLen, eSeq := 0, escSeq{}
for idx, c := range str {
if c == EscapeStartRune {
isEscSeq = true
if eSeq.isIn {
eSeq.InspectRune(c)
out.WriteRune(c)
continue
}

if !isEscSeq && outLen > 0 && (outLen%n) == 0 && idx != sLen {
eSeq.InspectRune(c)
if !eSeq.isIn && outLen > 0 && (outLen%n) == 0 && idx != sLen {
out.WriteRune(runeToInsert)
}
out.WriteRune(c)
if !isEscSeq {
if !eSeq.isIn {
outLen += RuneWidth(c)
}

if isEscSeq && c == EscapeStopRune {
isEscSeq = false
}
}
return out.String()
}
Expand All @@ -60,21 +97,19 @@ func InsertEveryN(str string, runeToInsert rune, n int) string {
// argument string. For ex.:
// LongestLineLen("Ghost!\nCome back here!\nRight now!") == 15
func LongestLineLen(str string) int {
maxLength, currLength, isEscSeq := 0, 0, false
maxLength, currLength, eSeq := 0, 0, escSeq{}
for _, c := range str {
if c == EscapeStartRune {
isEscSeq = true
} else if isEscSeq && c == EscapeStopRune {
isEscSeq = false
if eSeq.isIn {
eSeq.InspectRune(c)
continue
}

eSeq.InspectRune(c)
if c == '\n' {
if currLength > maxLength {
maxLength = currLength
}
currLength = 0
} else if !isEscSeq {
} else if !eSeq.isIn {
currLength += RuneWidth(c)
}
}
Expand Down Expand Up @@ -164,15 +199,14 @@ func RuneWidth(r rune) int {
// RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m") == 5
// RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0") == 5
func RuneWidthWithoutEscSequences(str string) int {
count, isEscSeq := 0, false
count, eSeq := 0, escSeq{}
for _, c := range str {
if c == EscapeStartRune {
isEscSeq = true
} else if isEscSeq {
if c == EscapeStopRune {
isEscSeq = false
}
} else {
if eSeq.isIn {
eSeq.InspectRune(c)
continue
}
eSeq.InspectRune(c)
if !eSeq.isIn {
count += RuneWidth(c)
}
}
Expand Down Expand Up @@ -211,27 +245,23 @@ func Trim(str string, maxLen int) string {
var out strings.Builder
out.Grow(maxLen)

outLen, isEscSeq, lastEscSeq := 0, false, strings.Builder{}
outLen, eSeq := 0, escSeq{}
for _, sChr := range str {
out.WriteRune(sChr)
if sChr == EscapeStartRune {
isEscSeq = true
lastEscSeq.Reset()
lastEscSeq.WriteRune(sChr)
} else if isEscSeq {
lastEscSeq.WriteRune(sChr)
if sChr == EscapeStopRune {
isEscSeq = false
}
} else {
if eSeq.isIn {
eSeq.InspectRune(sChr)
out.WriteRune(sChr)
continue
}
eSeq.InspectRune(sChr)
if eSeq.isIn {
out.WriteRune(sChr)
continue
}
if outLen < maxLen {
outLen++
if outLen == maxLen {
break
}
out.WriteRune(sChr)
continue
}
}
if lastEscSeq.Len() > 0 && lastEscSeq.String() != EscapeReset {
out.WriteString(EscapeReset)
}
return out.String()
}
15 changes: 15 additions & 0 deletions text/string_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ func TestInsertEveryN(t *testing.T) {
assert.Equal(t, "\x1b[33mGho-st\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 3))
assert.Equal(t, "\x1b[33mGhos-t\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 4))
assert.Equal(t, "\x1b[33mGhost\x1b[0m", InsertEveryN("\x1b[33mGhost\x1b[0m", '-', 5))
assert.Equal(t, "G\x1b]8;;http://example.com\x1b\\-h-o-s-t\x1b]8;;\x1b\\", InsertEveryN("G\x1b]8;;http://example.com\x1b\\host\x1b]8;;\x1b\\", '-', 1))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\G-h-o-s-t\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", '-', 1))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\G-h-o-s\x1b]8;;\x1b\\-t", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghos\x1b]8;;\x1b\\t", '-', 1))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gツhツoツsツt\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 'ツ', 1))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghツosツt\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 'ツ', 2))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", InsertEveryN("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", '-', 5))
}

func ExampleLongestLineLen() {
Expand Down Expand Up @@ -78,6 +84,7 @@ func TestLongestLineLen(t *testing.T) {
assert.Equal(t, 6, LongestLineLen("Winter\nIs\nComing"))
assert.Equal(t, 7, LongestLineLen("Mother\nOf\nDragons"))
assert.Equal(t, 7, LongestLineLen("\x1b[33mMother\x1b[0m\nOf\nDragons"))
assert.Equal(t, 7, LongestLineLen("Mother\nOf\n\x1b]8;;http://example.com\x1b\\Dragons\x1b]8;;\x1b\\"))
}

func TestOverrideRuneWidthEastAsianWidth(t *testing.T) {
Expand Down Expand Up @@ -123,6 +130,8 @@ func TestPad(t *testing.T) {
assert.Equal(t, "Ghost.....", Pad("Ghost", 10, '.'))
assert.Equal(t, "\x1b[33mGhost\x1b[0 ", Pad("\x1b[33mGhost\x1b[0", 7, ' '))
assert.Equal(t, "\x1b[33mGhost\x1b[0.....", Pad("\x1b[33mGhost\x1b[0", 10, '.'))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\ ", Pad("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 7, ' '))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\.....", Pad("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 10, '.'))
}

func ExampleRepeatAndTrim() {
Expand Down Expand Up @@ -171,6 +180,7 @@ func TestRuneCount(t *testing.T) {
assert.Equal(t, 7, RuneCount("Ghostツ"))
assert.Equal(t, 5, RuneCount("\x1b[33mGhost\x1b[0m"))
assert.Equal(t, 5, RuneCount("\x1b[33mGhost\x1b[0"))
assert.Equal(t, 5, RuneCount("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\"))
}

func ExampleRuneWidth() {
Expand Down Expand Up @@ -215,6 +225,7 @@ func TestRuneWidthWithoutEscSequences(t *testing.T) {
assert.Equal(t, 7, RuneWidthWithoutEscSequences("Ghostツ"))
assert.Equal(t, 5, RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m"))
assert.Equal(t, 5, RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0"))
assert.Equal(t, 5, RuneWidthWithoutEscSequences("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\"))
}

func ExampleSnip() {
Expand All @@ -240,6 +251,9 @@ func TestSnip(t *testing.T) {
assert.Equal(t, "Ghost", Snip("Ghost", 5, "~"))
assert.Equal(t, "Ghost", Snip("Ghost", 7, "~"))
assert.Equal(t, "\x1b[33mGhost\x1b[0m", Snip("\x1b[33mGhost\x1b[0m", 7, "~"))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", Snip("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 7, "~"))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gh\x1b]8;;\x1b\\~", Snip("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 3, "~"))
assert.Equal(t, "\x1b[33m\x1b]8;;http://example.com\x1b\\Gh\x1b]8;;\x1b\\\x1b[0m~", Snip("\x1b[33m\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\\x1b[0m", 3, "~"))
}

func ExampleTrim() {
Expand All @@ -264,4 +278,5 @@ func TestTrim(t *testing.T) {
assert.Equal(t, "Ghost", Trim("Ghost", 6))
assert.Equal(t, "\x1b[33mGho\x1b[0m", Trim("\x1b[33mGhost\x1b[0m", 3))
assert.Equal(t, "\x1b[33mGhost\x1b[0m", Trim("\x1b[33mGhost\x1b[0m", 6))
assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gho\x1b]8;;\x1b\\", Trim("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 3))
}

0 comments on commit e2e2386

Please sign in to comment.