snapshot of 962fc3c82c774ab371e3fc4e84c55ef26e61268d Annotations about the code that implements koment.

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
4
5 import (
6 "bytes"
7 "errors"
8 "fmt"
9 "io/fs"
10 "os"
11 "strings"
12
13 "github.com/koment-dev/koment/internal/store"
14 )
15
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
19
20 const (
21 StatusOK = store.AnchorOK
22 StatusAmbiguous = store.AnchorAmbiguous
23 StatusDrifted = store.AnchorDrifted
24 StatusOrphaned = store.AnchorOrphaned
25 )
26
27 type Resolution struct {
28 Annotation store.Annotation
29 Status Status
30 Line int
31 Occurrences int
32 }
33
34 type occurrence struct {
35 line int
36 startLine int
37 endLine int
38 before []string
39 after []string
40 }
41
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 }
46
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 }
54
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 }
61
62 func resolved(annotation store.Annotation, found occurrence, count int) Resolution {
63 return Resolution{Annotation: annotation, Status: StatusOK, Line: found.line, Occurrences: count}
64 }
65
66 func ResolveOrphaned(annotation store.Annotation) Resolution {
67 return Resolution{Annotation: annotation, Status: StatusOrphaned}
68 }
69
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 }
86
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 }
94
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 }
107
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 }
115
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 }
130
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 }
139
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 }
152
153 func contextLines(context string) []string {
154 if context == "" {
155 return nil
156 }
157 return strings.Split(context, "\n")
158 }
159
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 }
171
172 func first(lines []string, count int) []string {
173 if count > len(lines) {
174 count = len(lines)
175 }
176 return lines[:count]
177 }
178
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 }
185
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)
192
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 }
214
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 }
240
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 }
253
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 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close