Skip to content

Commit 2e40619

Browse files
committed
refactor: stream delegated tokens
DelegatingLexer buffered every insertion, root token, and output token, causing token memory to grow with input size. Merge iterators lazily while retaining the concatenated Other input so embedded spans cannot corrupt stateful root lexers.
1 parent 609af80 commit 2e40619

2 files changed

Lines changed: 130 additions & 59 deletions

File tree

delegate.go

Lines changed: 65 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package chroma
22

33
import (
4-
"bytes"
54
"iter"
6-
"slices"
5+
"strings"
76
)
87

98
type delegatingLexer struct {
@@ -14,7 +13,7 @@ type delegatingLexer struct {
1413
// DelegatingLexer combines two lexers to handle the common case of a language embedded inside another, such as PHP
1514
// inside HTML or PHP inside plain text.
1615
//
17-
// It takes two lexer as arguments: a root lexer and a language lexer. First everything is scanned using the language
16+
// It takes two lexers as arguments: a root lexer and a language lexer. First everything is scanned using the language
1817
// lexer, which must return "Other" for unrecognised tokens. Then all "Other" tokens are lexed using the root lexer.
1918
// Finally, these two sets of tokens are merged.
2019
//
@@ -54,79 +53,86 @@ func (d *delegatingLexer) Config() *Config {
5453
return d.language.Config()
5554
}
5655

57-
// An insertion is the character range where language tokens should be inserted.
58-
type insertion struct {
59-
start, end int
60-
tokens []Token
61-
}
62-
6356
func (d *delegatingLexer) Tokenise(options *TokeniseOptions, text string) (iter.Seq[Token], error) { // nolint: gocognit
64-
tokens, err := Tokenise(Coalesce(d.language), options, text)
57+
languageTokens, err := Tokenise(Coalesce(d.language), options, text)
6558
if err != nil {
6659
return nil, err
6760
}
68-
// Compute insertions and gather "Other" tokens.
69-
others := &bytes.Buffer{}
70-
insertions := []*insertion{}
71-
var insert *insertion
72-
offset := 0
73-
first := true
74-
var lastType TokenType
75-
for t := range tokens {
76-
if t.Type == Other {
77-
if !first && insert != nil && lastType != Other {
78-
insert.end = offset
79-
}
80-
others.WriteString(t.Value)
61+
var rootText strings.Builder
62+
hasLanguageTokens := false
63+
for token := range languageTokens {
64+
if token.Type == Other {
65+
rootText.WriteString(token.Value)
8166
} else {
82-
if first || lastType == Other {
83-
insert = &insertion{start: offset}
84-
insertions = append(insertions, insert)
85-
}
86-
insert.tokens = append(insert.tokens, t)
67+
hasLanguageTokens = true
8768
}
88-
first = false
89-
lastType = t.Type
90-
offset += len(t.Value)
9169
}
92-
93-
if len(insertions) == 0 {
70+
if !hasLanguageTokens {
9471
return d.root.Tokenise(options, text)
9572
}
9673

97-
// Lex the other tokens.
98-
rootIt, err := Tokenise(Coalesce(d.root), options, others.String())
74+
rootTokens, err := Tokenise(Coalesce(d.root), options, rootText.String())
9975
if err != nil {
10076
return nil, err
10177
}
102-
rootTokens := slices.Collect(rootIt)
78+
languageTokens, err = Tokenise(Coalesce(d.language), options, text)
79+
if err != nil {
80+
return nil, err
81+
}
82+
return func(yield func(Token) bool) {
83+
nextRoot, stopRoot := iter.Pull(rootTokens)
84+
defer stopRoot()
85+
86+
var root Token
87+
var pendingRoot Token
88+
for language := range languageTokens {
89+
if language.Value == "" {
90+
continue
91+
}
92+
if language.Type != Other {
93+
if pendingRoot.Value != "" {
94+
if !yield(pendingRoot) {
95+
return
96+
}
97+
pendingRoot = Token{}
98+
}
99+
if !yield(language) {
100+
return
101+
}
102+
continue
103+
}
103104

104-
// Interleave the two sets of tokens.
105-
var out []Token
106-
offset = 0
107-
ti := 0
108-
ii := 0
109-
for ti < len(rootTokens) || ii < len(insertions) {
110-
if ti >= len(rootTokens) || (ii < len(insertions) && insertions[ii].start < offset+len(rootTokens[ti].Value)) {
111-
ins := insertions[ii]
112-
ii++
113-
if ti < len(rootTokens) {
114-
l, r := splitToken(rootTokens[ti], ins.start-offset)
115-
if l.Value != "" {
116-
out = append(out, l)
117-
offset += len(l.Value)
105+
// Consume only Other spans because embedded language spans were removed from the root input.
106+
remaining := len(language.Value)
107+
for remaining > 0 {
108+
if root.Value == "" {
109+
var ok bool
110+
root, ok = nextRoot()
111+
if !ok {
112+
break
113+
}
114+
continue
115+
}
116+
consumed, rest := splitToken(root, min(remaining, len(root.Value)))
117+
root = rest
118+
remaining -= len(consumed.Value)
119+
if pendingRoot.Value == "" {
120+
pendingRoot = consumed
121+
} else {
122+
pendingRoot.Value += consumed.Value
123+
}
124+
if root.Value == "" {
125+
if !yield(pendingRoot) {
126+
return
127+
}
128+
pendingRoot = Token{}
118129
}
119-
rootTokens[ti] = r
120130
}
121-
out = append(out, ins.tokens...)
122-
offset += ins.end - ins.start
123-
} else {
124-
out = append(out, rootTokens[ti])
125-
offset += len(rootTokens[ti].Value)
126-
ti++
127131
}
128-
}
129-
return slices.Values(out), nil
132+
if pendingRoot.Value != "" {
133+
yield(pendingRoot)
134+
}
135+
}, nil
130136
}
131137

132138
func splitToken(t Token, offset int) (l Token, r Token) {

delegate_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
11
package chroma
22

33
import (
4+
"iter"
45
"slices"
56
"testing"
67

78
assert "github.com/alecthomas/assert/v2"
89
)
910

11+
type countingLexer struct {
12+
Lexer
13+
tokens []Token
14+
consumed int
15+
texts []string
16+
}
17+
18+
func (l *countingLexer) Tokenise(_ *TokeniseOptions, text string) (iter.Seq[Token], error) {
19+
l.texts = append(l.texts, text)
20+
return func(yield func(Token) bool) {
21+
for _, token := range l.tokens {
22+
l.consumed++
23+
if !yield(token) {
24+
return
25+
}
26+
}
27+
}, nil
28+
}
29+
1030
func makeDelegationTestLexers(t *testing.T) (lang Lexer, root Lexer) {
1131
t.Helper()
1232
return mustNewLexer(t, nil, Rules{ // nolint: forbidigo
@@ -110,3 +130,48 @@ func TestDelegate(t *testing.T) {
110130
})
111131
}
112132
}
133+
134+
func TestDelegateStreamsRootTokens(t *testing.T) {
135+
root := &countingLexer{tokens: []Token{
136+
{Name, "a"},
137+
{Text, "c"},
138+
{Name, "e"},
139+
}}
140+
language := &countingLexer{tokens: []Token{
141+
{Other, "a"},
142+
{Keyword, "b"},
143+
{Other, "c"},
144+
{Keyword, "d"},
145+
{Other, "e"},
146+
}}
147+
it, err := DelegatingLexer(root, language).Tokenise(nil, "abcde")
148+
assert.NoError(t, err)
149+
assert.Equal(t, 0, root.consumed)
150+
assert.Equal(t, 5, language.consumed)
151+
152+
var actual Token
153+
for token := range it {
154+
actual = token
155+
break
156+
}
157+
assert.Equal(t, Token{Name, "a"}, actual)
158+
assert.Equal(t, 2, root.consumed)
159+
assert.Equal(t, 7, language.consumed)
160+
}
161+
162+
func TestDelegateSplitsRootTokens(t *testing.T) {
163+
root := &countingLexer{tokens: []Token{{Text, "before after"}}}
164+
language := &countingLexer{tokens: []Token{
165+
{Other, "before "},
166+
{Keyword, "embedded"},
167+
{Other, " after"},
168+
}}
169+
it, err := DelegatingLexer(root, language).Tokenise(nil, "before embedded after")
170+
assert.NoError(t, err)
171+
assert.Equal(t, []string{"before after"}, root.texts)
172+
assert.Equal(t, []Token{
173+
{Text, "before "},
174+
{Keyword, "embedded"},
175+
{Text, " after"},
176+
}, slices.Collect(it))
177+
}

0 commit comments

Comments
 (0)