Skip to content

Commit 3a29bc0

Browse files
committed
perf: place alerts by offset instead of searching for their text
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent 9bd0cf9 commit 3a29bc0

13 files changed

Lines changed: 325 additions & 8 deletions

File tree

cmd/vale/main.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,18 @@ func handleError(err error) {
8383
}
8484

8585
func main() {
86+
// Every exit path below calls this explicitly: os.Exit skips deferred
87+
// functions, so a defer here would silently drop the profile.
88+
stopProfiling := startProfiling()
89+
8690
pflag.Parse()
8791

8892
args := pflag.Args()
8993
argc := len(args)
9094

9195
if Flags.Version { //nolint:gocritic
9296
fmt.Println("vale version " + version)
97+
stopProfiling()
9398
os.Exit(0)
9499
} else if Flags.Help {
95100
pflag.Usage()
@@ -103,6 +108,7 @@ func main() {
103108
if err := cmd(args[1:], &Flags); err != nil {
104109
handleError(err)
105110
}
111+
stopProfiling()
106112
os.Exit(0)
107113
}
108114
}
@@ -125,9 +131,11 @@ func main() {
125131
hasErrors, err := PrintAlerts(linted, config)
126132
if err != nil {
127133
handleError(err)
128-
} else if hasErrors && !Flags.NoExit {
129-
os.Exit(1)
130134
}
131135

136+
stopProfiling()
137+
if hasErrors && !Flags.NoExit {
138+
os.Exit(1)
139+
}
132140
os.Exit(0)
133141
}

cmd/vale/profile.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"runtime"
7+
"runtime/pprof"
8+
)
9+
10+
// Profiling is written when VALE_CPUPROFILE or VALE_MEMPROFILE names a file.
11+
//
12+
// Environment variables rather than flags: Vale's flag set is user-facing and
13+
// stable, while this is a development tool. It also means a profile can be
14+
// captured from a run driven by an editor or a script without changing the
15+
// command line.
16+
type profiler struct {
17+
cpu *os.File
18+
mem string
19+
}
20+
21+
// startProfiling begins any profiling the environment asks for. The returned
22+
// function must be called before the process exits.
23+
func startProfiling() func() {
24+
p := &profiler{mem: os.Getenv("VALE_MEMPROFILE")}
25+
26+
if path := os.Getenv("VALE_CPUPROFILE"); path != "" {
27+
f, err := os.Create(path)
28+
switch {
29+
case err != nil:
30+
fmt.Fprintf(os.Stderr, "vale: creating CPU profile: %v\n", err)
31+
default:
32+
if serr := pprof.StartCPUProfile(f); serr != nil {
33+
fmt.Fprintf(os.Stderr, "vale: starting CPU profile: %v\n", serr)
34+
f.Close()
35+
} else {
36+
p.cpu = f
37+
}
38+
}
39+
}
40+
41+
return p.stop
42+
}
43+
44+
func (p *profiler) stop() {
45+
if p.cpu != nil {
46+
pprof.StopCPUProfile()
47+
p.cpu.Close()
48+
}
49+
50+
if p.mem == "" {
51+
return
52+
}
53+
54+
f, err := os.Create(p.mem)
55+
if err != nil {
56+
fmt.Fprintf(os.Stderr, "vale: creating memory profile: %v\n", err)
57+
return
58+
}
59+
defer f.Close()
60+
61+
// A profile of the live heap, so the numbers describe what Vale is holding
62+
// rather than everything it ever allocated.
63+
runtime.GC()
64+
if werr := pprof.WriteHeapProfile(f); werr != nil {
65+
fmt.Fprintf(os.Stderr, "vale: writing memory profile: %v\n", werr)
66+
}
67+
}

internal/check/anchor.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package check
2+
3+
import (
4+
"unicode/utf8"
5+
6+
"github.com/errata-ai/vale/v3/internal/core"
7+
"github.com/errata-ai/vale/v3/internal/nlp"
8+
)
9+
10+
// anchor converts an alert's block-relative rune span into an absolute byte
11+
// span within the document.
12+
//
13+
// Vale otherwise locates an alert by searching the document for the text it
14+
// matched, and masking that text so the next alert from the same rule finds a
15+
// later occurrence. That copies the whole context per alert, which is the bulk
16+
// of Vale's allocation on a document with many findings -- and it mislocates a
17+
// match that appears more than once or that spans irregular whitespace.
18+
//
19+
// An anchored alert needs none of that: it already says exactly where it is.
20+
//
21+
// Anchoring is skipped unless the block's position is known *and* verifiable.
22+
// Blocks carved out of markup hold text that has been stripped of its markup,
23+
// so their offsets do not address the original document; checking that the
24+
// text really sits where the offset claims is what keeps those out.
25+
func anchor(a *core.Alert, blk nlp.Block) {
26+
if blk.Offset < 0 || len(a.Span) != 2 {
27+
return
28+
}
29+
30+
// The offset is only ever set after a successful search for this text, so
31+
// it is already known good. It cannot be re-checked here: extraction masks
32+
// text in the context buffer as it goes, so by now the bytes at that
33+
// position may have been overwritten -- length-preservingly, which is what
34+
// keeps the offset itself valid.
35+
if blk.Offset+len(blk.Text) > len(blk.Context) {
36+
return
37+
}
38+
39+
lo, hi, ok := runeSpanToBytes(blk.Text, a.Span[0], a.Span[1])
40+
if !ok {
41+
return
42+
}
43+
44+
a.Span = []int{blk.Offset + lo, blk.Offset + hi}
45+
a.HasByteOffsets = true
46+
}
47+
48+
// runeSpanToBytes converts a rune-indexed span into byte offsets.
49+
//
50+
// regexp2 reports positions in runes; everything downstream of the check
51+
// addresses the document in bytes.
52+
func runeSpanToBytes(s string, from, to int) (int, int, bool) {
53+
if from < 0 || to < from {
54+
return 0, 0, false
55+
}
56+
57+
var (
58+
runes int
59+
lo, hi = -1, -1
60+
i int
61+
)
62+
for i = 0; i < len(s); {
63+
if runes == from && lo < 0 {
64+
lo = i
65+
}
66+
if runes == to {
67+
hi = i
68+
break
69+
}
70+
_, size := utf8.DecodeRuneInString(s[i:])
71+
i += size
72+
runes++
73+
}
74+
75+
// A span reaching the end of the string ends past the final rune.
76+
if runes == from && lo < 0 {
77+
lo = i
78+
}
79+
if runes == to && hi < 0 {
80+
hi = i
81+
}
82+
83+
if lo < 0 || hi < 0 || hi < lo {
84+
return 0, 0, false
85+
}
86+
return lo, hi, true
87+
}

internal/check/anchor_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package check
2+
3+
import "testing"
4+
5+
// runeSpanToBytes underpins every anchored alert; an off-by-one here
6+
// mislocates findings rather than failing loudly.
7+
func TestRuneSpanToBytes(t *testing.T) {
8+
cases := []struct {
9+
name string
10+
s string
11+
from, to int
12+
want string
13+
ok bool
14+
}{
15+
{"ascii start", "hello world", 0, 5, "hello", true},
16+
{"ascii middle", "hello world", 6, 11, "world", true},
17+
{"ascii to end", "hello", 0, 5, "hello", true},
18+
{"empty span", "hello", 2, 2, "", true},
19+
{"multibyte before", "héllo wörld", 6, 11, "wörld", true},
20+
{"multibyte inside", "héllo", 0, 5, "héllo", true},
21+
{"emoji", "a 🎉 b", 2, 3, "🎉", true},
22+
{"cjk", "漢字テスト", 0, 2, "漢字", true},
23+
{"whole string", "naïve", 0, 5, "naïve", true},
24+
{"negative", "abc", -1, 2, "", false},
25+
{"reversed", "abc", 2, 1, "", false},
26+
}
27+
28+
for _, c := range cases {
29+
t.Run(c.name, func(t *testing.T) {
30+
lo, hi, ok := runeSpanToBytes(c.s, c.from, c.to)
31+
if ok != c.ok {
32+
t.Fatalf("ok = %v, want %v", ok, c.ok)
33+
}
34+
if !ok {
35+
return
36+
}
37+
if got := c.s[lo:hi]; got != c.want {
38+
t.Errorf("s[%d:%d] = %q, want %q", lo, hi, got, c.want)
39+
}
40+
})
41+
}
42+
}
43+
44+
// The conversion must agree with the rune slicing it replaces, for every span
45+
// of a multi-byte string.
46+
func TestRuneSpanToBytesMatchesRuneSlicing(t *testing.T) {
47+
for _, s := range []string{
48+
"hello world",
49+
"héllo wörld",
50+
"漢字テストです",
51+
"a 🎉 b 😀 c",
52+
"naïve café résumé",
53+
"",
54+
} {
55+
runes := []rune(s)
56+
for from := 0; from <= len(runes); from++ {
57+
for to := from; to <= len(runes); to++ {
58+
lo, hi, ok := runeSpanToBytes(s, from, to)
59+
if !ok {
60+
t.Fatalf("%q [%d:%d]: not ok", s, from, to)
61+
}
62+
want := string(runes[from:to])
63+
if got := s[lo:hi]; got != want {
64+
t.Errorf("%q [%d:%d] = %q, want %q", s, from, to, got, want)
65+
}
66+
}
67+
}
68+
}
69+
}

internal/check/capitalization.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ func (c Capitalization) Run(blk nlp.Block, _ *core.File, cfg *core.Config) ([]co
135135
c.Description, blk.Text, expected)
136136
a.Action = action
137137

138+
anchor(&a, blk)
138139
alerts = append(alerts, a)
139140
}
140141

internal/check/consistency.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ func (o Consistency) Run(blk nlp.Block, f *core.File, cfg *core.Config) ([]core.
100100
if matches != nil && core.AllStringsInSlice(s.subs, f.Sequences) {
101101
o.Name = o.Extends
102102

103+
// Not anchored, deliberately. `loc` is whatever the submatch loop
104+
// above left behind, which is the *last* match in the block rather
105+
// than the one being reported; searching for the matched text
106+
// instead lands on the first occurrence, which is what this check
107+
// has always reported. Anchoring would promote that leftover into
108+
// the output. The rule fires at most once per block, so there is
109+
// nothing to gain by it either.
103110
a, err := makeAlert(o.Definition, loc, txt, cfg)
104111
if err != nil {
105112
return alerts, err

internal/check/existence.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ func (e Existence) Run(blk nlp.Block, _ *core.File, cfg *core.Config) ([]core.Al
9797
if erra != nil {
9898
return alerts, erra
9999
}
100+
anchor(&a, blk)
100101
alerts = append(alerts, a)
101102
}
102103
}

internal/check/occurrence.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ func (o Occurrence) Run(blk nlp.Block, _ *core.File, cfg *core.Config) ([]core.A
102102
if err != nil {
103103
return alerts, err
104104
}
105+
// Only this branch: the zero-occurrence case above reports a line
106+
// number, not a span into the text.
107+
anchor(&a, blk)
105108
}
106109

107110
// Pass the count as an int (not a string) so messages can use either

internal/check/repetition.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ func (o Repetition) Run(blk nlp.Block, _ *core.File, cfg *core.Config) ([]core.A
113113

114114
a.Message, a.Description = formatMessages(o.Message,
115115
o.Description, curr)
116+
117+
anchor(&a, blk)
116118
alerts = append(alerts, a)
117119
count = 0
118120
}

internal/check/substitution.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ func (s Substitution) Run(blk nlp.Block, _ *core.File, cfg *core.Config) ([]core
172172
s.Description, expected, observed)
173173
a.Action = action
174174

175+
anchor(&a, blk)
175176
alerts = append(alerts, a)
176177
}
177178
}

0 commit comments

Comments
 (0)