Skip to content

Commit 85c1343

Browse files
committed
fix: place a match inside a block that inline markup rewrote
Fixes #502 Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent b47ccb0 commit 85c1343

12 files changed

Lines changed: 295 additions & 53 deletions

File tree

‎internal/check/anchor.go‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,28 @@ import (
2323
// so their offsets do not address the original document; checking that the
2424
// text really sits where the offset claims is what keeps those out.
2525
func anchor(a *core.Alert, blk nlp.Block) {
26-
if blk.Offset < 0 || len(a.Span) != 2 {
26+
if len(a.Span) != 2 {
27+
return
28+
}
29+
30+
lo, hi, ok := runeSpanToBytes(blk.Text, a.Span[0], a.Span[1])
31+
if !ok {
32+
return
33+
}
34+
35+
if blk.Offset < 0 {
36+
// A block that inline markup has rewritten -- `has <b>has</b>` read as
37+
// `has has` -- is nowhere in the document to be found, but each of its
38+
// runs was placed as it was read, and that is enough to say where the
39+
// match is. The span reaches from the first byte to the last, markup
40+
// between them included, because that is its extent in the file. See
41+
// #502.
42+
from, to := blk.SourceOffset(lo), blk.SourceOffset(hi-1)
43+
if from < 0 || to < from {
44+
return
45+
}
46+
a.Span = []int{from, to + 1}
47+
a.HasByteOffsets = true
2748
return
2849
}
2950

@@ -36,11 +57,6 @@ func anchor(a *core.Alert, blk nlp.Block) {
3657
return
3758
}
3859

39-
lo, hi, ok := runeSpanToBytes(blk.Text, a.Span[0], a.Span[1])
40-
if !ok {
41-
return
42-
}
43-
4460
a.Span = []int{blk.Offset + lo, blk.Offset + hi}
4561
a.HasByteOffsets = true
4662
}

‎internal/check/anchor_test.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package check
33
import (
44
"testing"
55
"unicode/utf8"
6+
7+
"github.com/errata-ai/vale/v3/internal/core"
8+
"github.com/errata-ai/vale/v3/internal/nlp"
69
)
710

811
// runeSpanToBytes underpins every anchored alert; an off-by-one here
@@ -117,3 +120,51 @@ func TestRe2LocMatchesReference(t *testing.T) {
117120
}
118121
}
119122
}
123+
124+
// A block whose text inline markup rewrote has no offset of its own, and its
125+
// alerts used to fall back to a text search that placed them on the wrong line
126+
// -- or dropped them. Its runs place them exactly. See #502.
127+
func TestAnchorFromRuns(t *testing.T) {
128+
// " <p>ab <b>cd</b> e</p>" read as "ab cd e".
129+
blk := nlp.Block{
130+
Context: " <p>ab <b>cd</b> e</p>",
131+
Text: "ab cd e",
132+
Offset: -1,
133+
Runs: []nlp.Run{
134+
{At: 0, Src: 5, N: 3}, // "ab "
135+
{At: 3, Src: 11, N: 2}, // "cd"
136+
{At: 5, Src: 17, N: 2}, // " e"
137+
},
138+
}
139+
140+
tests := []struct {
141+
name string
142+
span []int
143+
want []int
144+
anchored bool
145+
}{
146+
// "ab cd" reaches from the first byte to the last, `<b>` included.
147+
{"across the markup", []int{0, 5}, []int{5, 13}, true},
148+
{"before it", []int{0, 2}, []int{5, 7}, true},
149+
{"after it", []int{3, 5}, []int{11, 13}, true},
150+
{"past the last run", []int{0, 30}, nil, false},
151+
}
152+
153+
for _, tt := range tests {
154+
t.Run(tt.name, func(t *testing.T) {
155+
a := core.Alert{Span: tt.span}
156+
157+
anchor(&a, blk)
158+
159+
if a.HasByteOffsets != tt.anchored {
160+
t.Fatalf("HasByteOffsets = %v, want %v", a.HasByteOffsets, tt.anchored)
161+
}
162+
if !tt.anchored {
163+
return
164+
}
165+
if a.Span[0] != tt.want[0] || a.Span[1] != tt.want[1] {
166+
t.Errorf("Span = %v, want %v", a.Span, tt.want)
167+
}
168+
})
169+
}
170+
}

‎internal/lint/ast.go‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -214,17 +214,16 @@ func (l *Linter) lintHTMLTokens(f *core.File, raw []byte, offset int) error { //
214214
txt = txt[1:]
215215
}
216216
// Record where this run came from before it loses its identity
217-
// in the block -- but only when something is going to ask. The
218-
// mapping exists to place inline fragments, so a style that
219-
// scopes none of them should not pay for it.
217+
// in the block. It places inline fragments, and it places a
218+
// match in a block that inline markup has made unfindable in
219+
// the source -- `has <b>has</b>` arrives as `has has`, which is
220+
// nowhere in the file. See #502.
220221
//
221222
// Only a run that survived extraction unchanged can be mapped;
222223
// `clean` may have prefixed a space, which belongs to the block
223224
// and not to the source.
224-
if len(wanted) > 0 {
225-
if body := strings.TrimLeft(txt, " "); body == raw {
226-
walker.mapRun(buf.Len()+(len(txt)-len(body)), raw)
227-
}
225+
if body := strings.TrimLeft(txt, " "); body == raw {
226+
walker.mapRun(buf.Len()+(len(txt)-len(body)), raw)
228227
}
229228
buf.WriteString(txt)
230229
// Feed the same text to any inline element still open, so a
@@ -283,7 +282,7 @@ func (l *Linter) lintScope(f *core.File, state *walker, txt string) error {
283282
txt = strings.TrimLeft(txt, " ")
284283
shift -= len(txt)
285284

286-
b := state.block(txt, withClasses(scope, state)+f.MetaScope+f.RealExt)
285+
b := state.block(txt, withClasses(scope, state)+f.MetaScope+f.RealExt, shift)
287286

288287
// Prose, not just a block: a list item or a heading is made of
289288
// sentences the same way a paragraph is, and only this path segments
@@ -317,7 +316,7 @@ func (l *Linter) lintScope(f *core.File, state *walker, txt string) error {
317316
// `paragraphs` means what the `paragraph` scope reaches -- this branch.
318317
f.Metrics["paragraphs"]++
319318

320-
b := state.block(txt, withClasses("text", state)+f.MetaScope+f.RealExt)
319+
b := state.block(txt, withClasses("text", state)+f.MetaScope+f.RealExt, 0)
321320
if err := l.lintProse(f, b, state.lines, true); err != nil {
322321
return err
323322
}
@@ -418,7 +417,7 @@ func (l *Linter) lintTags(f *core.File, state *walker, tok html.Token) error {
418417
if a.Key == "alt" && !ignored {
419418
err := l.lintBlock(
420419
f,
421-
state.block(a.Val, "text.attr."+a.Key), state.lines, 0, false)
420+
state.block(a.Val, "text.attr."+a.Key, 0), state.lines, 0, false)
422421
if err != nil {
423422
return err
424423
}

‎internal/lint/walk.go‎

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,6 @@ import (
1313
"github.com/errata-ai/vale/v3/internal/nlp"
1414
)
1515

16-
// srcSpan maps a run of a block's stripped text back to where it came from in
17-
// the source.
18-
//
19-
// Extraction removes markup, so a block's text is not a substring of the source
20-
// and an index into one is not an index into the other. Recording each run as
21-
// it is read keeps the correspondence that would otherwise be lost.
22-
type srcSpan struct {
23-
text int // where the run begins in the block's text
24-
src int // where it begins in the source
25-
n int // its length, the same in both
26-
}
27-
2816
// inlineCapture is the text of one inline element, gathered as the block that
2917
// contains it is read.
3018
type inlineCapture struct {
@@ -80,7 +68,7 @@ type walker struct {
8068
// spans maps this block's text back to the source, and srcCursor is how far
8169
// the search for the next run has already gone. Separate from `cursor`,
8270
// which places whole blocks and must not be moved by this.
83-
spans []srcSpan
71+
spans []nlp.Run
8472
srcCursor int
8573

8674
// inline holds the inline elements captured within the current block --
@@ -266,7 +254,9 @@ func (w *walker) close() {
266254
w.end = 0
267255
}
268256

269-
func (w *walker) block(text, scope string) nlp.Block {
257+
// block builds the block for text, which begins `shift` bytes into the runs
258+
// recorded as it was read -- the space `clean` prefixes and lintScope trims.
259+
func (w *walker) block(text, scope string, shift int) nlp.Block {
270260
line := w.idx
271261

272262
pos := w.advance(text)
@@ -277,9 +267,30 @@ func (w *walker) block(text, scope string) nlp.Block {
277267
b := nlp.NewLinedBlock(w.getCtx(), text, scope, line)
278268
b.Offset = w.locate(text)
279269

270+
// A block whose text is in the source verbatim needs nothing more; one
271+
// holding inline markup is not there to be found, and only its runs can
272+
// place a match inside it. See #502.
273+
if b.Offset < 0 {
274+
b.Runs = w.runs(shift, len(text))
275+
}
276+
280277
return b
281278
}
282279

280+
// runs returns the recorded runs covering [shift, shift+n) of the walker's
281+
// buffer, rebased onto a block that starts there.
282+
func (w *walker) runs(shift, n int) []nlp.Run {
283+
var out []nlp.Run
284+
for _, r := range w.spans {
285+
lo, hi := max(r.At, shift), min(r.At+r.N, shift+n)
286+
if lo >= hi {
287+
continue
288+
}
289+
out = append(out, nlp.Run{At: lo - shift, Src: r.Src + (lo - r.At), N: hi - lo})
290+
}
291+
return out
292+
}
293+
283294
// locate returns where text begins in the context, advancing the cursor past
284295
// it, or -1 if it cannot be found from the cursor onwards.
285296
//
@@ -344,15 +355,15 @@ func (w *walker) mapRun(at int, raw string) {
344355

345356
src := w.srcCursor + i
346357
w.srcCursor = src + len(raw)
347-
w.spans = append(w.spans, srcSpan{text: at, src: src, n: len(raw)})
358+
w.spans = append(w.spans, nlp.Run{At: at, Src: src, N: len(raw)})
348359
}
349360

350361
// sourceOffset returns where index `i` of the block's text sits in the source,
351362
// or -1 if that run was never mapped.
352363
func (w *walker) sourceOffset(i int) int {
353364
for _, s := range w.spans {
354-
if i >= s.text && i < s.text+s.n {
355-
return s.src + (i - s.text)
365+
if i >= s.At && i < s.At+s.N {
366+
return s.Src + (i - s.At)
356367
}
357368
}
358369
return -1
@@ -417,11 +428,12 @@ func (w *walker) replaceToks(tok html.Token) {
417428
// more work: 38% slower overall. Worth revisiting with assignLoc rather
418429
// than on its own; the two are coupled.
419430
//
420-
// That last attempt did surface a real defect: with the line taken from the
421-
// offset, an rst alert moved from 56:19 -- inside a literal block -- to 43:45,
422-
// the prose it actually belongs to. `testdata/features/frontmatter.feature`
423-
// still encodes the wrong position, and core.assignLoc has a standing NOTE
424-
// saying blk.Line is untrustworthy. Both are the same bug.
431+
// The line this returns is a fallback, and core.assignLoc has a standing NOTE
432+
// saying so. It matters less than it did: an alert in a block holding inline
433+
// markup is now placed by the runs the block was read from rather than by
434+
// searching from this line, which is what moved the rst alert in
435+
// `testdata/e2e/frontmatter.yaml` out of a literal block and onto the prose it
436+
// belongs to. What still comes through here is a block no run could place.
425437
func (w *walker) advance(text string) int {
426438
ctx := w.getCtx()
427439

‎internal/nlp/provider.go‎

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,52 @@ type Block struct {
3131
// finds the first occurrence rather than the one that matched. A sentence
3232
// repeated in a document is the common case.
3333
Offset int
34+
35+
// Runs maps pieces of Text back to the source they were read from, for a
36+
// block that has no Offset of its own.
37+
//
38+
// Extraction drops inline markup, so `has <b>has</b>` arrives as `has has`
39+
// and the block is nowhere in Context as a whole. Its pieces are, though,
40+
// and each was placed as it was read. See #502.
41+
Runs []Run
42+
}
43+
44+
// A Run is a piece of a block's text and where it came from: At indexes the
45+
// block, Src the source, and N is how long both are.
46+
type Run struct {
47+
At, Src, N int
48+
}
49+
50+
// SourceOffset returns where index i of Text sits in Context, or -1 if that
51+
// part of the block was never mapped.
52+
func (b *Block) SourceOffset(i int) int {
53+
if b.Offset >= 0 {
54+
return b.Offset + i
55+
}
56+
for _, r := range b.Runs {
57+
if i >= r.At && i < r.At+r.N {
58+
return r.Src + (i - r.At)
59+
}
60+
}
61+
return -1
62+
}
63+
64+
// withRuns returns a copy of b carrying the runs of `parent` that fall within
65+
// [start, start+len(b.Text)), rebased onto b's own text.
66+
func (b Block) withRuns(parent []Run, start int) Block {
67+
if start < 0 || len(parent) == 0 {
68+
return b
69+
}
70+
71+
end := start + len(b.Text)
72+
for _, r := range parent {
73+
lo, hi := max(r.At, start), min(r.At+r.N, end)
74+
if lo >= hi {
75+
continue
76+
}
77+
b.Runs = append(b.Runs, Run{At: lo - start, Src: r.Src + (lo - r.At), N: hi - lo})
78+
}
79+
return b
3480
}
3581

3682
// NewBlock makes a new Block with prepared text and a Selector.
@@ -149,22 +195,27 @@ func (n *Info) Compute(block *Block, split bool) ([]Block, error) {
149195
// cursor advances past each piece so that repeated text resolves to successive
150196
// occurrences rather than always the first -- which is the whole point of
151197
// tracking offsets instead of searching for them later.
152-
func offsetOf(blk *Block, base int, piece string, cursor *int) int {
153-
if base < 0 || *cursor > len(blk.Text) {
154-
return -1
198+
func offsetOf(blk *Block, base int, piece string, cursor *int) (int, int) {
199+
if *cursor > len(blk.Text) {
200+
return -1, -1
155201
}
156202

157203
i := strings.Index(blk.Text[*cursor:], piece)
158204
if i < 0 {
159205
// A segmenter that rewrites text (a remote endpoint, say) can return
160206
// something that is not a substring of the input.
161-
return -1
207+
return -1, -1
162208
}
163209

164210
start := *cursor + i
165211
*cursor = start + len(piece)
166212

167-
return base + start
213+
if base < 0 {
214+
// The piece is placed within the block, which is itself unplaced; the
215+
// runs it inherits are what will locate it.
216+
return start, -1
217+
}
218+
return start, base + start
168219
}
169220

170221
func (n *Info) doNLP(blk *Block, seg segmenter, split bool) ([]Block, error) {
@@ -178,7 +229,8 @@ func (n *Info) doNLP(blk *Block, seg segmenter, split bool) ([]Block, error) {
178229
cursor := 0
179230
for _, p := range strings.SplitAfter(blk.Text, "\n\n") {
180231
b := NewLinedBlock(ctx, p, "paragraph."+blk.Scope, idx)
181-
blks = append(blks, b.at(offsetOf(blk, base, p, &cursor)))
232+
start, off := offsetOf(blk, base, p, &cursor)
233+
blks = append(blks, b.at(off).withRuns(blk.Runs, start))
182234
}
183235
}
184236

@@ -190,14 +242,15 @@ func (n *Info) doNLP(blk *Block, seg segmenter, split bool) ([]Block, error) {
190242
continue
191243
}
192244
b := NewLinedBlock(ctx, s, "sentence."+blk.Scope, idx)
193-
blks = append(blks, b.at(offsetOf(blk, base, s, &cursor)))
245+
start, off := offsetOf(blk, base, s, &cursor)
246+
blks = append(blks, b.at(off).withRuns(blk.Runs, start))
194247
}
195248
}
196249

197250
// The block itself, which is what most rules run against. It needs the
198251
// offset as much as its children do.
199252
blks = append(
200-
blks, NewLinedBlock(ctx, blk.Text, blk.Scope, idx).at(base))
253+
blks, NewLinedBlock(ctx, blk.Text, blk.Scope, idx).at(base).withRuns(blk.Runs, 0))
201254

202255
return blks, nil
203256
}

0 commit comments

Comments
 (0)