internal/anchor/anchor.go
1
// Package anchor decides where an annotation still applies, and says so when it
2
// no longer does.
3
package anchor
5
import (
6
"bytes"
7
"errors"
8
"fmt"
9
"io/fs"
10
"os"
11
"strings"
13
"github.com/koment-dev/koment/internal/store"
14
)
16
// Status is the record's own vocabulary. The store owns it because a record
17
// persists the last observed one; naming it twice would let the two drift.
18
type Status = store.AnchorStatus
20
const (
21
StatusOK = store.AnchorOK
22
StatusAmbiguous = store.AnchorAmbiguous
23
StatusDrifted = store.AnchorDrifted
24
StatusOrphaned = store.AnchorOrphaned
25
)
27
type Resolution struct {
28
Annotation store.Annotation
29
Status Status
30
Line int
31
Occurrences int
32
}
34
type occurrence struct {
35
line int
36
startLine int
37
endLine int
38
before []string
39
after []string
40
}
42
func Resolve(annotation store.Annotation, content []byte) Resolution {
43
if annotation.Spec.Anchor.Scope == store.ScopeFile {
44
return Resolution{Annotation: annotation, Status: StatusOK}
45
}
47
found := findOccurrences(content, annotation.Spec.Anchor.Excerpt)
48
if len(found) == 0 {
49
return Resolution{Annotation: annotation, Status: StatusDrifted}
50
}
51
if len(found) == 1 {
52
return resolved(annotation, found[0], 1)
53
}
55
contextual := filterByContext(found, annotation.Spec.Anchor)
56
if len(contextual) != 1 {
57
return Resolution{Annotation: annotation, Status: StatusAmbiguous, Occurrences: len(found)}
58
}
59
return resolved(annotation, contextual[0], len(found))
60
}
62
func resolved(annotation store.Annotation, found occurrence, count int) Resolution {
63
return Resolution{Annotation: annotation, Status: StatusOK, Line: found.line, Occurrences: count}
64
}
66
func ResolveOrphaned(annotation store.Annotation) Resolution {
67
return Resolution{Annotation: annotation, Status: StatusOrphaned}
68
}
70
func ResolveStored(annotations *store.Store, file string) ([]Resolution, error) {
71
records, err := annotations.ForFile(file)
72
if err != nil {
73
return nil, err
74
}
75
content, err := annotations.ReadSource(file)
76
if errors.Is(err, fs.ErrNotExist) {
77
return resolveAll(records, ResolveOrphaned), nil
78
}
79
if err != nil {
80
return nil, err
81
}
82
return resolveAll(records, func(annotation store.Annotation) Resolution {
83
return Resolve(annotation, content)
84
}), nil
85
}
87
func ResolveRecord(annotation store.Annotation, sourcePath string) (Resolution, error) {
88
resolved, err := ResolveRecords([]store.Annotation{annotation}, sourcePath)
89
if err != nil {
90
return Resolution{}, err
91
}
92
return resolved[0], nil
93
}
95
func ResolveRecords(annotations []store.Annotation, sourcePath string) ([]Resolution, error) {
96
content, err := os.ReadFile(sourcePath)
97
if errors.Is(err, fs.ErrNotExist) {
98
return resolveAll(annotations, ResolveOrphaned), nil
99
}
100
if err != nil {
101
return nil, fmt.Errorf("reading %s: %w", sourcePath, err)
102
}
103
return resolveAll(annotations, func(annotation store.Annotation) Resolution {
104
return Resolve(annotation, content)
105
}), nil
106
}
108
func resolveAll(annotations []store.Annotation, resolve func(store.Annotation) Resolution) []Resolution {
109
resolutions := make([]Resolution, len(annotations))
110
for index, annotation := range annotations {
111
resolutions[index] = resolve(annotation)
112
}
113
return resolutions
114
}
116
// Capture builds an anchor and reports the line the excerpt was found on. The
117
// line comes back separately because it is observed state: it belongs in the
118
// record's status, not in the anchor the author decided on.
119
func Capture(content []byte, excerpt string) (store.Anchor, int, error) {
120
found := findOccurrences(content, excerpt)
121
switch len(found) {
122
case 0:
123
return store.Anchor{}, 0, fmt.Errorf("excerpt does not occur in the source")
124
case 1:
125
return anchorFrom(found[0], excerpt), found[0].line, nil
126
default:
127
return store.Anchor{}, 0, fmt.Errorf("excerpt occurs %d times; provide a more specific excerpt", len(found))
128
}
129
}
131
func anchorFrom(found occurrence, excerpt string) store.Anchor {
132
return store.Anchor{
133
Scope: store.ScopeExcerpt,
134
Excerpt: excerpt,
135
Before: strings.Join(last(found.before, 3), "\n"),
136
After: strings.Join(first(found.after, 3), "\n"),
137
}
138
}
140
func filterByContext(found []occurrence, anchor store.Anchor) []occurrence {
141
wantBefore := contextLines(anchor.Before)
142
wantAfter := contextLines(anchor.After)
143
filtered := make([]occurrence, 0, len(found))
144
for _, candidate := range found {
145
if equalStrings(last(candidate.before, len(wantBefore)), wantBefore) &&
146
equalStrings(first(candidate.after, len(wantAfter)), wantAfter) {
147
filtered = append(filtered, candidate)
148
}
149
}
150
return filtered
151
}
153
func contextLines(context string) []string {
154
if context == "" {
155
return nil
156
}
157
return strings.Split(context, "\n")
158
}
160
func equalStrings(left, right []string) bool {
161
if len(left) != len(right) {
162
return false
163
}
164
for index := range left {
165
if left[index] != right[index] {
166
return false
167
}
168
}
169
return true
170
}
172
func first(lines []string, count int) []string {
173
if count > len(lines) {
174
count = len(lines)
175
}
176
return lines[:count]
177
}
179
func last(lines []string, count int) []string {
180
if count > len(lines) {
181
count = len(lines)
182
}
183
return lines[len(lines)-count:]
184
}
186
func findOccurrences(content []byte, excerpt string) []occurrence {
187
needle := []byte(excerpt)
188
if len(needle) == 0 {
189
return nil
190
}
191
lines, starts := splitLines(content)
193
var found []occurrence
194
for searched := 0; searched <= len(content)-len(needle); {
195
index := bytes.Index(content[searched:], needle)
196
if index < 0 {
197
break
198
}
199
start := searched + index
200
end := start + len(needle) - 1
201
startLine := lineAt(starts, start)
202
endLine := lineAt(starts, end)
203
found = append(found, occurrence{
204
line: startLine + 1,
205
startLine: startLine,
206
endLine: endLine,
207
before: lines[:startLine],
208
after: lines[endLine+1:],
209
})
210
searched = start + 1
211
}
212
return found
213
}
215
func splitLines(content []byte) ([]string, []int) {
216
if len(content) == 0 {
217
return []string{""}, []int{0}
218
}
219
starts := []int{0}
220
for index, character := range content {
221
if character == '\n' && index+1 < len(content) {
222
starts = append(starts, index+1)
223
}
224
}
225
lines := make([]string, len(starts))
226
for index, start := range starts {
227
end := len(content)
228
if index+1 < len(starts) {
229
end = starts[index+1] - 1
230
} else if end > start && content[end-1] == '\n' {
231
end--
232
}
233
if end > start && content[end-1] == '\r' {
234
end--
235
}
236
lines[index] = string(content[start:end])
237
}
238
return lines, starts
239
}
241
func lineAt(starts []int, offset int) int {
242
low, high := 0, len(starts)
243
for low < high {
244
middle := low + (high-low)/2
245
if starts[middle] <= offset {
246
low = middle + 1
247
} else {
248
high = middle
249
}
250
}
251
return low - 1
252
}
254
func ExcerptLines(content []byte, excerpt string) []int {
255
found := findOccurrences(content, excerpt)
256
if len(found) == 0 {
257
return nil
258
}
259
lines := make([]int, len(found))
260
for index, occurrence := range found {
261
lines[index] = occurrence.line
262
}
263
return lines
264
}