Skip to content

Commit 183dee4

Browse files
authored
text: handle hyperlink embedded text correctly; fixes #329 (#334)
1 parent c4081bb commit 183dee4

File tree

8 files changed

+301
-210
lines changed

8 files changed

+301
-210
lines changed

text/escape.go

Lines changed: 0 additions & 50 deletions
This file was deleted.

text/escape_seq_parser.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package text
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strconv"
7+
"strings"
8+
)
9+
10+
// Constants
11+
const (
12+
EscapeReset = EscapeResetCSI
13+
EscapeResetCSI = EscapeStartCSI + "0" + EscapeStopCSI
14+
EscapeResetOSI = EscapeStartOSI + "0" + EscapeStopOSI
15+
EscapeStart = EscapeStartCSI
16+
EscapeStartCSI = "\x1b["
17+
EscapeStartOSI = "\x1b]"
18+
EscapeStartRune = rune(27) // \x1b
19+
EscapeStartRuneCSI = '[' // [
20+
EscapeStartRuneOSI = ']' // ]
21+
EscapeStop = EscapeStopCSI
22+
EscapeStopCSI = "m"
23+
EscapeStopOSI = "\\"
24+
EscapeStopRune = EscapeStopRuneCSI
25+
EscapeStopRuneCSI = 'm'
26+
EscapeStopRuneOSI = '\\'
27+
)
28+
29+
// Deprecated Constants
30+
const (
31+
CSIStartRune = EscapeStartRuneCSI
32+
CSIStopRune = EscapeStopRuneCSI
33+
OSIStartRune = EscapeStartRuneOSI
34+
OSIStopRune = EscapeStopRuneOSI
35+
)
36+
37+
type escSeqKind int
38+
39+
const (
40+
escSeqKindUnknown escSeqKind = iota
41+
escSeqKindCSI
42+
escSeqKindOSI
43+
)
44+
45+
type escSeqParser struct {
46+
codes map[int]bool
47+
48+
// consume specific
49+
inEscSeq bool
50+
escSeqKind escSeqKind
51+
escapeSeq string
52+
}
53+
54+
func (s *escSeqParser) Codes() []int {
55+
codes := make([]int, 0)
56+
for code, val := range s.codes {
57+
if val {
58+
codes = append(codes, code)
59+
}
60+
}
61+
sort.Ints(codes)
62+
return codes
63+
}
64+
65+
func (s *escSeqParser) Consume(char rune) {
66+
if !s.inEscSeq && char == EscapeStartRune {
67+
s.inEscSeq = true
68+
s.escSeqKind = escSeqKindUnknown
69+
s.escapeSeq = ""
70+
} else if s.inEscSeq && s.escSeqKind == escSeqKindUnknown {
71+
if char == EscapeStartRuneCSI {
72+
s.escSeqKind = escSeqKindCSI
73+
} else if char == EscapeStartRuneOSI {
74+
s.escSeqKind = escSeqKindOSI
75+
}
76+
}
77+
78+
if s.inEscSeq {
79+
s.escapeSeq += string(char)
80+
81+
if s.isEscapeStopRune(char) {
82+
s.ParseSeq(s.escapeSeq, s.escSeqKind)
83+
s.Reset()
84+
}
85+
}
86+
}
87+
88+
func (s *escSeqParser) InSequence() bool {
89+
return s.inEscSeq
90+
}
91+
92+
func (s *escSeqParser) IsOpen() bool {
93+
return len(s.codes) > 0
94+
}
95+
96+
func (s *escSeqParser) Reset() {
97+
s.inEscSeq = false
98+
s.escSeqKind = escSeqKindUnknown
99+
s.escapeSeq = ""
100+
}
101+
102+
const (
103+
escCodeResetAll = 0
104+
escCodeResetIntensity = 22
105+
escCodeResetItalic = 23
106+
escCodeResetUnderline = 24
107+
escCodeResetBlink = 25
108+
escCodeResetReverse = 27
109+
escCodeResetCrossedOut = 29
110+
escCodeBold = 1
111+
escCodeDim = 2
112+
escCodeItalic = 3
113+
escCodeUnderline = 4
114+
escCodeBlinkSlow = 5
115+
escCodeBlinkRapid = 6
116+
escCodeReverse = 7
117+
escCodeConceal = 8
118+
escCodeCrossedOut = 9
119+
)
120+
121+
func (s *escSeqParser) ParseSeq(seq string, seqKind escSeqKind) {
122+
if s.codes == nil {
123+
s.codes = make(map[int]bool)
124+
}
125+
126+
if seqKind == escSeqKindOSI {
127+
seq = strings.Replace(seq, EscapeStartOSI, "", 1)
128+
seq = strings.Replace(seq, EscapeStopOSI, "", 1)
129+
} else { // escSeqKindCSI
130+
seq = strings.Replace(seq, EscapeStartCSI, "", 1)
131+
seq = strings.Replace(seq, EscapeStopCSI, "", 1)
132+
}
133+
134+
codes := strings.Split(seq, ";")
135+
for _, code := range codes {
136+
code = strings.TrimSpace(code)
137+
if codeNum, err := strconv.Atoi(code); err == nil {
138+
switch codeNum {
139+
case escCodeResetAll:
140+
s.codes = make(map[int]bool) // clear everything
141+
case escCodeResetIntensity:
142+
delete(s.codes, escCodeBold)
143+
delete(s.codes, escCodeDim)
144+
case escCodeResetItalic:
145+
delete(s.codes, escCodeItalic)
146+
case escCodeResetUnderline:
147+
delete(s.codes, escCodeUnderline)
148+
case escCodeResetBlink:
149+
delete(s.codes, escCodeBlinkSlow)
150+
delete(s.codes, escCodeBlinkRapid)
151+
case escCodeResetReverse:
152+
delete(s.codes, escCodeReverse)
153+
case escCodeResetCrossedOut:
154+
delete(s.codes, escCodeCrossedOut)
155+
default:
156+
s.codes[codeNum] = true
157+
}
158+
}
159+
}
160+
}
161+
162+
func (s *escSeqParser) ParseString(str string) string {
163+
s.escapeSeq, s.inEscSeq, s.escSeqKind = "", false, escSeqKindUnknown
164+
for _, char := range str {
165+
s.Consume(char)
166+
}
167+
return s.Sequence()
168+
}
169+
170+
func (s *escSeqParser) Sequence() string {
171+
out := strings.Builder{}
172+
if s.IsOpen() {
173+
out.WriteString(EscapeStart)
174+
for idx, code := range s.Codes() {
175+
if idx > 0 {
176+
out.WriteRune(';')
177+
}
178+
out.WriteString(fmt.Sprint(code))
179+
}
180+
out.WriteString(EscapeStop)
181+
}
182+
183+
return out.String()
184+
}
185+
186+
const (
187+
escapeStartConcealOSI = "\x1b]8;"
188+
escapeStopConcealOSI = "\x1b\\"
189+
)
190+
191+
func (s *escSeqParser) isEscapeStopRune(char rune) bool {
192+
if strings.HasPrefix(s.escapeSeq, escapeStartConcealOSI) {
193+
if strings.HasSuffix(s.escapeSeq, escapeStopConcealOSI) {
194+
return true
195+
}
196+
} else if (s.escSeqKind == escSeqKindCSI && char == EscapeStopRuneCSI) ||
197+
(s.escSeqKind == escSeqKindOSI && char == EscapeStopRuneOSI) {
198+
return true
199+
}
200+
return false
201+
}

text/escape_seq_parser_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package text
2+
3+
import (
4+
"github.com/stretchr/testify/assert"
5+
"testing"
6+
)
7+
8+
func Test_escSeqParser(t *testing.T) {
9+
t.Run("extract csi", func(t *testing.T) {
10+
es := escSeqParser{}
11+
12+
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"))
13+
assert.Equal(t, "\x1b[3;4;5;7;9;91m", es.ParseString("\x1b[22m No Bold"))
14+
assert.Equal(t, "\x1b[4;5;7;9;91m", es.ParseString("\x1b[23m No Italic"))
15+
assert.Equal(t, "\x1b[5;7;9;91m", es.ParseString("\x1b[24m No Underline"))
16+
assert.Equal(t, "\x1b[7;9;91m", es.ParseString("\x1b[25m No Blink"))
17+
assert.Equal(t, "\x1b[9;91m", es.ParseString("\x1b[27m No Reverse"))
18+
assert.Equal(t, "\x1b[91m", es.ParseString("\x1b[29m No Crossed-Out"))
19+
assert.Equal(t, "", es.ParseString("\x1b[0m Resetted"))
20+
})
21+
22+
t.Run("extract osi", func(t *testing.T) {
23+
es := escSeqParser{}
24+
25+
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"))
26+
assert.Equal(t, "\x1b[3;4;5;7;9;91m", es.ParseString("\x1b]22\\ No Bold"))
27+
assert.Equal(t, "\x1b[4;5;7;9;91m", es.ParseString("\x1b]23\\ No Italic"))
28+
assert.Equal(t, "\x1b[5;7;9;91m", es.ParseString("\x1b]24\\ No Underline"))
29+
assert.Equal(t, "\x1b[7;9;91m", es.ParseString("\x1b]25\\ No Blink"))
30+
assert.Equal(t, "\x1b[9;91m", es.ParseString("\x1b]27\\ No Reverse"))
31+
assert.Equal(t, "\x1b[91m", es.ParseString("\x1b]29\\ No Crossed-Out"))
32+
assert.Equal(t, "", es.ParseString("\x1b[0m Resetted"))
33+
})
34+
35+
t.Run("parse csi", func(t *testing.T) {
36+
es := escSeqParser{}
37+
38+
es.ParseSeq("\x1b[91m", escSeqKindCSI) // color
39+
es.ParseSeq("\x1b[1m", escSeqKindCSI) // bold
40+
assert.Len(t, es.Codes(), 2)
41+
assert.True(t, es.IsOpen())
42+
assert.Equal(t, "\x1b[1;91m", es.Sequence())
43+
44+
es.ParseSeq("\x1b[22m", escSeqKindCSI) // un-bold
45+
assert.Len(t, es.Codes(), 1)
46+
assert.True(t, es.IsOpen())
47+
assert.Equal(t, "\x1b[91m", es.Sequence())
48+
49+
es.ParseSeq("\x1b[0m", escSeqKindCSI) // reset
50+
assert.Empty(t, es.Codes())
51+
assert.False(t, es.IsOpen())
52+
assert.Empty(t, es.Sequence())
53+
})
54+
55+
t.Run("parse osi", func(t *testing.T) {
56+
es := escSeqParser{}
57+
58+
es.ParseSeq("\x1b]91\\", escSeqKindOSI) // color
59+
es.ParseSeq("\x1b]1\\", escSeqKindOSI) // bold
60+
assert.Len(t, es.Codes(), 2)
61+
assert.True(t, es.IsOpen())
62+
assert.Equal(t, "\x1b[1;91m", es.Sequence())
63+
64+
es.ParseSeq("\x1b]22\\", escSeqKindOSI) // un-bold
65+
assert.Len(t, es.Codes(), 1)
66+
assert.True(t, es.IsOpen())
67+
assert.Equal(t, "\x1b[91m", es.Sequence())
68+
69+
es.ParseSeq("\x1b]0\\", escSeqKindOSI) // reset
70+
assert.Empty(t, es.Codes())
71+
assert.False(t, es.IsOpen())
72+
assert.Empty(t, es.Sequence())
73+
})
74+
}

0 commit comments

Comments
 (0)