snapshot of eee7b5cfc0776c68816d7fdaa8af9d7a0e0da2e6 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/janpuc/koment/internal/store"
14 )
15
16 type Status string
17
18 const (
19 StatusOK Status = "ok"
20 StatusMoved Status = "moved"
21 StatusAmbiguous Status = "ambiguous"
22 StatusDrifted Status = "drifted"
23 StatusOrphaned Status = "orphaned"
24 )
25
26 func (s Status) IsFailure() bool {
27 return s == StatusAmbiguous || s == StatusDrifted || s == StatusOrphaned
28 }
29
30 type Resolution struct {
31 Annotation store.Annotation
32 Status Status
33 Line int
34 Occurrences int
35 }
36
37 type occurrence struct {
38 line int
39 startLine int
40 endLine int
41 before []string
42 after []string
43 }
44
45 func Resolve(annotation store.Annotation, content []byte) Resolution {
46 if annotation.Anchor.Scope == store.ScopeFile {
47 return Resolution{Annotation: annotation, Status: StatusOK}
48 }
49
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 }
57
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 }
64
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 }
72
73 func ResolveOrphaned(annotation store.Annotation) Resolution {
74 return Resolution{Annotation: annotation, Status: StatusOrphaned}
75 }
76
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 }
93
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 }
101
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 }
114
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 }
122
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 }
134
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 }
144
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 }
157
158 func contextLines(context string) []string {
159 if context == "" {
160 return nil
161 }
162 return strings.Split(context, "\n")
163 }
164
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 }
176
177 func first(lines []string, count int) []string {
178 if count > len(lines) {
179 count = len(lines)
180 }
181 return lines[:count]
182 }
183
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 }
190
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)
197
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 }
219
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 }
245
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 }
258
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 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close