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/janpuc/koment/internal/store"
14
)
16
type Status string
18
const (
19
StatusOK Status = "ok"
20
StatusMoved Status = "moved"
21
StatusAmbiguous Status = "ambiguous"
22
StatusDrifted Status = "drifted"
23
StatusOrphaned Status = "orphaned"
24
)
26
func (s Status) IsFailure() bool {
27
return s == StatusAmbiguous || s == StatusDrifted || s == StatusOrphaned
28
}
30
type Resolution struct {
31
Annotation store.Annotation
32
Status Status
33
Line int
34
Occurrences int
35
}
37
type occurrence struct {
38
line int
39
startLine int
40
endLine int
41
before []string
42
after []string
43
}
45
func Resolve(annotation store.Annotation, content []byte) Resolution {
46
if annotation.Anchor.Scope == store.ScopeFile {
47
return Resolution{Annotation: annotation, Status: StatusOK}
48
}
50
found := findOccurrences(content, annotation.Anchor.Excerpt)
51
if len(found) == 0 {
52
return Resolution{Annotation: annotation, Status: StatusDrifted}
53
}
54
if len(found) == 1 {
55
return resolved(annotation, found[0], 1)
56
}
58
contextual := filterByContext(found, annotation.Anchor)
59
if len(contextual) != 1 {
60
return Resolution{Annotation: annotation, Status: StatusAmbiguous, Occurrences: len(found)}
61
}
62
return resolved(annotation, contextual[0], len(found))
63
}
65
func resolved(annotation store.Annotation, found occurrence, count int) Resolution {
66
status := StatusMoved
67
if found.line == annotation.Anchor.LastSeenLine {
68
status = StatusOK
69
}
70
return Resolution{Annotation: annotation, Status: status, Line: found.line, Occurrences: count}
71
}
73
func ResolveOrphaned(annotation store.Annotation) Resolution {
74
return Resolution{Annotation: annotation, Status: StatusOrphaned}
75
}
77
func ResolveStored(annotations *store.Store, file string) ([]Resolution, error) {
78
records, err := annotations.ForFile(file)
79
if err != nil {
80
return nil, err
81
}
82
content, err := annotations.ReadSource(file)
83
if errors.Is(err, fs.ErrNotExist) {
84
return resolveAll(records, ResolveOrphaned), nil
85
}
86
if err != nil {
87
return nil, err
88
}
89
return resolveAll(records, func(annotation store.Annotation) Resolution {
90
return Resolve(annotation, content)
91
}), nil
92
}
94
func ResolveRecord(annotation store.Annotation, sourcePath string) (Resolution, error) {
95
resolved, err := ResolveRecords([]store.Annotation{annotation}, sourcePath)
96
if err != nil {
97
return Resolution{}, err
98
}
99
return resolved[0], nil
100
}
102
func ResolveRecords(annotations []store.Annotation, sourcePath string) ([]Resolution, error) {
103
content, err := os.ReadFile(sourcePath)
104
if errors.Is(err, fs.ErrNotExist) {
105
return resolveAll(annotations, ResolveOrphaned), nil
106
}
107
if err != nil {
108
return nil, fmt.Errorf("reading %s: %w", sourcePath, err)
109
}
110
return resolveAll(annotations, func(annotation store.Annotation) Resolution {
111
return Resolve(annotation, content)
112
}), nil
113
}
115
func resolveAll(annotations []store.Annotation, resolve func(store.Annotation) Resolution) []Resolution {
116
resolutions := make([]Resolution, len(annotations))
117
for index, annotation := range annotations {
118
resolutions[index] = resolve(annotation)
119
}
120
return resolutions
121
}
123
func Capture(content []byte, excerpt string) (store.Anchor, error) {
124
found := findOccurrences(content, excerpt)
125
switch len(found) {
126
case 0:
127
return store.Anchor{}, fmt.Errorf("excerpt does not occur in the source")
128
case 1:
129
return anchorFrom(found[0], excerpt), nil
130
default:
131
return store.Anchor{}, fmt.Errorf("excerpt occurs %d times; provide a more specific excerpt", len(found))
132
}
133
}
135
func anchorFrom(found occurrence, excerpt string) store.Anchor {
136
return store.Anchor{
137
Scope: store.ScopeExcerpt,
138
Excerpt: excerpt,
139
Before: strings.Join(last(found.before, 3), "\n"),
140
After: strings.Join(first(found.after, 3), "\n"),
141
LastSeenLine: found.line,
142
}
143
}
145
func filterByContext(found []occurrence, anchor store.Anchor) []occurrence {
146
wantBefore := contextLines(anchor.Before)
147
wantAfter := contextLines(anchor.After)
148
filtered := make([]occurrence, 0, len(found))
149
for _, candidate := range found {
150
if equalStrings(last(candidate.before, len(wantBefore)), wantBefore) &&
151
equalStrings(first(candidate.after, len(wantAfter)), wantAfter) {
152
filtered = append(filtered, candidate)
153
}
154
}
155
return filtered
156
}
158
func contextLines(context string) []string {
159
if context == "" {
160
return nil
161
}
162
return strings.Split(context, "\n")
163
}
165
func equalStrings(left, right []string) bool {
166
if len(left) != len(right) {
167
return false
168
}
169
for index := range left {
170
if left[index] != right[index] {
171
return false
172
}
173
}
174
return true
175
}
177
func first(lines []string, count int) []string {
178
if count > len(lines) {
179
count = len(lines)
180
}
181
return lines[:count]
182
}
184
func last(lines []string, count int) []string {
185
if count > len(lines) {
186
count = len(lines)
187
}
188
return lines[len(lines)-count:]
189
}
191
func findOccurrences(content []byte, excerpt string) []occurrence {
192
needle := []byte(excerpt)
193
if len(needle) == 0 {
194
return nil
195
}
196
lines, starts := splitLines(content)
198
var found []occurrence
199
for searched := 0; searched <= len(content)-len(needle); {
200
index := bytes.Index(content[searched:], needle)
201
if index < 0 {
202
break
203
}
204
start := searched + index
205
end := start + len(needle) - 1
206
startLine := lineAt(starts, start)
207
endLine := lineAt(starts, end)
208
found = append(found, occurrence{
209
line: startLine + 1,
210
startLine: startLine,
211
endLine: endLine,
212
before: lines[:startLine],
213
after: lines[endLine+1:],
214
})
215
searched = start + 1
216
}
217
return found
218
}
220
func splitLines(content []byte) ([]string, []int) {
221
if len(content) == 0 {
222
return []string{""}, []int{0}
223
}
224
starts := []int{0}
225
for index, character := range content {
226
if character == '\n' && index+1 < len(content) {
227
starts = append(starts, index+1)
228
}
229
}
230
lines := make([]string, len(starts))
231
for index, start := range starts {
232
end := len(content)
233
if index+1 < len(starts) {
234
end = starts[index+1] - 1
235
} else if end > start && content[end-1] == '\n' {
236
end--
237
}
238
if end > start && content[end-1] == '\r' {
239
end--
240
}
241
lines[index] = string(content[start:end])
242
}
243
return lines, starts
244
}
246
func lineAt(starts []int, offset int) int {
247
low, high := 0, len(starts)
248
for low < high {
249
middle := low + (high-low)/2
250
if starts[middle] <= offset {
251
low = middle + 1
252
} else {
253
high = middle
254
}
255
}
256
return low - 1
257
}
259
func ExcerptLines(content []byte, excerpt string) []int {
260
found := findOccurrences(content, excerpt)
261
if len(found) == 0 {
262
return nil
263
}
264
lines := make([]int, len(found))
265
for index, occurrence := range found {
266
lines[index] = occurrence.line
267
}
268
return lines
269
}