Skip to content

Commit

Permalink
text: handle hyperlink embedded text correctly; fixes #329 (#334)
Browse files Browse the repository at this point in the history
  • Loading branch information
jedib0t authored Oct 3, 2024
1 parent c4081bb commit 183dee4
Show file tree
Hide file tree
Showing 8 changed files with 301 additions and 210 deletions.
50 changes: 0 additions & 50 deletions text/escape.go

This file was deleted.

201 changes: 201 additions & 0 deletions text/escape_seq_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package text

import (
"fmt"
"sort"
"strconv"
"strings"
)

// Constants
const (
EscapeReset = EscapeResetCSI
EscapeResetCSI = EscapeStartCSI + "0" + EscapeStopCSI
EscapeResetOSI = EscapeStartOSI + "0" + EscapeStopOSI
EscapeStart = EscapeStartCSI
EscapeStartCSI = "\x1b["
EscapeStartOSI = "\x1b]"
EscapeStartRune = rune(27) // \x1b
EscapeStartRuneCSI = '[' // [
EscapeStartRuneOSI = ']' // ]
EscapeStop = EscapeStopCSI
EscapeStopCSI = "m"
EscapeStopOSI = "\\"
EscapeStopRune = EscapeStopRuneCSI
EscapeStopRuneCSI = 'm'
EscapeStopRuneOSI = '\\'
)

// Deprecated Constants
const (
CSIStartRune = EscapeStartRuneCSI
CSIStopRune = EscapeStopRuneCSI
OSIStartRune = EscapeStartRuneOSI
OSIStopRune = EscapeStopRuneOSI
)

type escSeqKind int

const (
escSeqKindUnknown escSeqKind = iota
escSeqKindCSI
escSeqKindOSI
)

type escSeqParser struct {
codes map[int]bool

// consume specific
inEscSeq bool
escSeqKind escSeqKind
escapeSeq string
}

func (s *escSeqParser) Codes() []int {
codes := make([]int, 0)
for code, val := range s.codes {
if val {
codes = append(codes, code)
}
}
sort.Ints(codes)
return codes
}

func (s *escSeqParser) Consume(char rune) {
if !s.inEscSeq && char == EscapeStartRune {
s.inEscSeq = true
s.escSeqKind = escSeqKindUnknown
s.escapeSeq = ""
} else if s.inEscSeq && s.escSeqKind == escSeqKindUnknown {
if char == EscapeStartRuneCSI {
s.escSeqKind = escSeqKindCSI
} else if char == EscapeStartRuneOSI {
s.escSeqKind = escSeqKindOSI
}
}

if s.inEscSeq {
s.escapeSeq += string(char)

if s.isEscapeStopRune(char) {
s.ParseSeq(s.escapeSeq, s.escSeqKind)
s.Reset()
}
}
}

func (s *escSeqParser) InSequence() bool {
return s.inEscSeq
}

func (s *escSeqParser) IsOpen() bool {
return len(s.codes) > 0
}

func (s *escSeqParser) Reset() {
s.inEscSeq = false
s.escSeqKind = escSeqKindUnknown
s.escapeSeq = ""
}

const (
escCodeResetAll = 0
escCodeResetIntensity = 22
escCodeResetItalic = 23
escCodeResetUnderline = 24
escCodeResetBlink = 25
escCodeResetReverse = 27
escCodeResetCrossedOut = 29
escCodeBold = 1
escCodeDim = 2
escCodeItalic = 3
escCodeUnderline = 4
escCodeBlinkSlow = 5
escCodeBlinkRapid = 6
escCodeReverse = 7
escCodeConceal = 8
escCodeCrossedOut = 9
)

func (s *escSeqParser) ParseSeq(seq string, seqKind escSeqKind) {
if s.codes == nil {
s.codes = make(map[int]bool)
}

if seqKind == escSeqKindOSI {
seq = strings.Replace(seq, EscapeStartOSI, "", 1)
seq = strings.Replace(seq, EscapeStopOSI, "", 1)
} else { // escSeqKindCSI
seq = strings.Replace(seq, EscapeStartCSI, "", 1)
seq = strings.Replace(seq, EscapeStopCSI, "", 1)
}

codes := strings.Split(seq, ";")
for _, code := range codes {
code = strings.TrimSpace(code)
if codeNum, err := strconv.Atoi(code); err == nil {
switch codeNum {
case escCodeResetAll:
s.codes = make(map[int]bool) // clear everything
case escCodeResetIntensity:
delete(s.codes, escCodeBold)
delete(s.codes, escCodeDim)
case escCodeResetItalic:
delete(s.codes, escCodeItalic)
case escCodeResetUnderline:
delete(s.codes, escCodeUnderline)
case escCodeResetBlink:
delete(s.codes, escCodeBlinkSlow)
delete(s.codes, escCodeBlinkRapid)
case escCodeResetReverse:
delete(s.codes, escCodeReverse)
case escCodeResetCrossedOut:
delete(s.codes, escCodeCrossedOut)
default:
s.codes[codeNum] = true
}
}
}
}

func (s *escSeqParser) ParseString(str string) string {
s.escapeSeq, s.inEscSeq, s.escSeqKind = "", false, escSeqKindUnknown
for _, char := range str {
s.Consume(char)
}
return s.Sequence()
}

func (s *escSeqParser) Sequence() string {
out := strings.Builder{}
if s.IsOpen() {
out.WriteString(EscapeStart)
for idx, code := range s.Codes() {
if idx > 0 {
out.WriteRune(';')
}
out.WriteString(fmt.Sprint(code))
}
out.WriteString(EscapeStop)
}

return out.String()
}

const (
escapeStartConcealOSI = "\x1b]8;"
escapeStopConcealOSI = "\x1b\\"
)

func (s *escSeqParser) isEscapeStopRune(char rune) bool {
if strings.HasPrefix(s.escapeSeq, escapeStartConcealOSI) {
if strings.HasSuffix(s.escapeSeq, escapeStopConcealOSI) {
return true
}
} else if (s.escSeqKind == escSeqKindCSI && char == EscapeStopRuneCSI) ||
(s.escSeqKind == escSeqKindOSI && char == EscapeStopRuneOSI) {
return true
}
return false
}
74 changes: 74 additions & 0 deletions text/escape_seq_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package text

import (
"github.com/stretchr/testify/assert"
"testing"
)

func Test_escSeqParser(t *testing.T) {
t.Run("extract csi", func(t *testing.T) {
es := escSeqParser{}

assert.Equal(t, "\x1b[1;3;4;5;7;9;91m", es.ParseString("\x1b[91m\x1b[1m\x1b[3m\x1b[4m\x1b[5m\x1b[7m\x1b[9m Spicy"))
assert.Equal(t, "\x1b[3;4;5;7;9;91m", es.ParseString("\x1b[22m No Bold"))
assert.Equal(t, "\x1b[4;5;7;9;91m", es.ParseString("\x1b[23m No Italic"))
assert.Equal(t, "\x1b[5;7;9;91m", es.ParseString("\x1b[24m No Underline"))
assert.Equal(t, "\x1b[7;9;91m", es.ParseString("\x1b[25m No Blink"))
assert.Equal(t, "\x1b[9;91m", es.ParseString("\x1b[27m No Reverse"))
assert.Equal(t, "\x1b[91m", es.ParseString("\x1b[29m No Crossed-Out"))
assert.Equal(t, "", es.ParseString("\x1b[0m Resetted"))
})

t.Run("extract osi", func(t *testing.T) {
es := escSeqParser{}

assert.Equal(t, "\x1b[1;3;4;5;7;9;91m", es.ParseString("\x1b]91\\\x1b]1\\\x1b]3\\\x1b]4\\\x1b]5\\\x1b]7\\\x1b]9\\ Spicy"))
assert.Equal(t, "\x1b[3;4;5;7;9;91m", es.ParseString("\x1b]22\\ No Bold"))
assert.Equal(t, "\x1b[4;5;7;9;91m", es.ParseString("\x1b]23\\ No Italic"))
assert.Equal(t, "\x1b[5;7;9;91m", es.ParseString("\x1b]24\\ No Underline"))
assert.Equal(t, "\x1b[7;9;91m", es.ParseString("\x1b]25\\ No Blink"))
assert.Equal(t, "\x1b[9;91m", es.ParseString("\x1b]27\\ No Reverse"))
assert.Equal(t, "\x1b[91m", es.ParseString("\x1b]29\\ No Crossed-Out"))
assert.Equal(t, "", es.ParseString("\x1b[0m Resetted"))
})

t.Run("parse csi", func(t *testing.T) {
es := escSeqParser{}

es.ParseSeq("\x1b[91m", escSeqKindCSI) // color
es.ParseSeq("\x1b[1m", escSeqKindCSI) // bold
assert.Len(t, es.Codes(), 2)
assert.True(t, es.IsOpen())
assert.Equal(t, "\x1b[1;91m", es.Sequence())

es.ParseSeq("\x1b[22m", escSeqKindCSI) // un-bold
assert.Len(t, es.Codes(), 1)
assert.True(t, es.IsOpen())
assert.Equal(t, "\x1b[91m", es.Sequence())

es.ParseSeq("\x1b[0m", escSeqKindCSI) // reset
assert.Empty(t, es.Codes())
assert.False(t, es.IsOpen())
assert.Empty(t, es.Sequence())
})

t.Run("parse osi", func(t *testing.T) {
es := escSeqParser{}

es.ParseSeq("\x1b]91\\", escSeqKindOSI) // color
es.ParseSeq("\x1b]1\\", escSeqKindOSI) // bold
assert.Len(t, es.Codes(), 2)
assert.True(t, es.IsOpen())
assert.Equal(t, "\x1b[1;91m", es.Sequence())

es.ParseSeq("\x1b]22\\", escSeqKindOSI) // un-bold
assert.Len(t, es.Codes(), 1)
assert.True(t, es.IsOpen())
assert.Equal(t, "\x1b[91m", es.Sequence())

es.ParseSeq("\x1b]0\\", escSeqKindOSI) // reset
assert.Empty(t, es.Codes())
assert.False(t, es.IsOpen())
assert.Empty(t, es.Sequence())
})
}
Loading

0 comments on commit 183dee4

Please sign in to comment.