internal/lsp/workspace.go
1
package lsp
3
import (
4
"errors"
5
"fmt"
6
"io/fs"
7
"net/url"
8
"path/filepath"
9
"runtime"
10
"sort"
11
"strings"
12
"unicode/utf16"
13
"unicode/utf8"
15
"github.com/janpuc/koment/internal/anchor"
16
"github.com/janpuc/koment/internal/application"
17
"github.com/janpuc/koment/internal/commentpolicy"
18
"github.com/janpuc/koment/internal/policy"
19
"github.com/janpuc/koment/internal/repository"
20
"github.com/janpuc/koment/internal/store"
21
)
23
type workspaceFile struct {
24
root string
25
relative string
26
content []byte
27
service *application.Service
28
store *store.Store
29
}
31
func loadWorkspaceFile(uri string, content []byte) (workspaceFile, error) {
32
absolute, err := pathFromURI(uri)
33
if err != nil {
34
return workspaceFile{}, err
35
}
36
root, err := store.FindRoot(filepath.Dir(absolute))
37
if err != nil {
38
return workspaceFile{}, err
39
}
40
annotations := store.Open(root)
41
relative, err := annotations.FromWorkingDirectory(absolute)
42
if err != nil {
43
return workspaceFile{}, err
44
}
45
if content == nil {
46
content, err = annotations.ReadSource(relative)
47
if err != nil {
48
return workspaceFile{}, fmt.Errorf("reading %s: %w", relative, err)
49
}
50
}
51
entry := repository.Repository{ID: filepath.Base(root), Name: filepath.Base(root), Root: root}
52
return workspaceFile{
53
root: root, relative: relative, content: content,
54
service: application.NewService(entry), store: annotations,
55
}, nil
56
}
58
func pathFromURI(uri string) (string, error) {
59
parsed, err := url.Parse(uri)
60
if err != nil || parsed.Scheme != "file" {
61
return "", fmt.Errorf("URI %q is not a local file", uri)
62
}
63
value, err := url.PathUnescape(parsed.EscapedPath())
64
if err != nil {
65
return "", fmt.Errorf("decoding URI %q: %w", uri, err)
66
}
67
if runtime.GOOS == "windows" && len(value) >= 3 && value[0] == '/' && value[2] == ':' {
68
value = value[1:]
69
}
70
return filepath.Clean(filepath.FromSlash(value)), nil
71
}
73
func annotationViews(file workspaceFile) ([]application.AnnotationView, error) {
74
records, err := file.store.ForFile(file.relative)
75
if err != nil {
76
return nil, err
77
}
78
snapshot, err := application.AssembleSnapshot(application.SnapshotInput{
79
Repository: application.RepositoryIdentity{ID: filepath.Base(file.root), Name: filepath.Base(file.root)},
80
Records: records, Sources: map[string][]byte{file.relative: file.content},
81
})
82
if err != nil {
83
return nil, err
84
}
85
resolved, exists := snapshot.File(file.relative)
86
if !exists {
87
return nil, nil
88
}
89
return resolved.Annotations, nil
90
}
92
func annotationItems(file workspaceFile) ([]annotationItem, error) {
93
views, err := annotationViews(file)
94
if err != nil {
95
return nil, err
96
}
97
items := make([]annotationItem, 0, len(views))
98
for _, view := range views {
99
line := max(1, view.Line)
100
if view.Record.Anchor.Scope == store.ScopeFile {
101
line = 1
102
}
103
annotationRange := rangeValue{
104
Start: position{Line: line - 1},
105
End: position{Line: line - 1, Character: lineUTF16Length(file.content, line-1)},
106
}
107
items = append(items, annotationItem{
108
ID: view.Record.ID, Kind: string(view.Record.Kind), Body: view.Record.Body,
109
Status: string(view.Status), Line: line, Warning: view.Warning, Range: annotationRange,
110
})
111
}
112
sort.Slice(items, func(left, right int) bool {
113
if items[left].Line != items[right].Line {
114
return items[left].Line < items[right].Line
115
}
116
return items[left].ID < items[right].ID
117
})
118
return items, nil
119
}
121
func documentDiagnostics(file workspaceFile) ([]diagnostic, error) {
122
items, err := annotationItems(file)
123
if err != nil {
124
return nil, err
125
}
126
diagnostics := []diagnostic{}
127
for _, item := range items {
128
switch anchor.Status(item.Status) {
129
case anchor.StatusAmbiguous, anchor.StatusDrifted, anchor.StatusOrphaned:
130
diagnostics = append(diagnostics, diagnostic{
131
Range: item.Range, Severity: 1, Code: "koment." + item.Status,
132
Source: "koment", Message: item.Warning, Data: map[string]string{"id": item.ID},
133
})
134
}
135
}
136
if filepath.Ext(file.relative) != ".go" {
137
return diagnostics, nil
138
}
139
configured, err := policy.Load(file.root)
140
if errors.Is(err, fs.ErrNotExist) {
141
return diagnostics, nil
142
}
143
if err != nil {
144
return nil, err
145
}
146
records, err := file.store.ForFile(file.relative)
147
if err != nil {
148
return nil, err
149
}
150
violations, err := commentpolicy.CheckContent(file.relative, file.content, configured, records)
151
if err != nil {
152
return nil, err
153
}
154
for _, violation := range violations {
155
diagnostics = append(diagnostics, diagnostic{
156
Range: rangeFromOffsets(file.content, violation.Comment.Start, violation.Comment.End),
157
Severity: 2, Code: "koment.comment", Source: "koment",
158
Message: violation.Reason,
159
Data: map[string]any{
160
"comment": violation.Comment.Raw, "file": file.relative,
161
"autoPrompt": commentpolicy.IsCommentIntent(violation.Comment),
162
},
163
})
164
}
165
return diagnostics, nil
166
}
168
func rangeFromOffsets(content []byte, start, end int) rangeValue {
169
return rangeValue{Start: positionAt(content, start), End: positionAt(content, end)}
170
}
172
func positionAt(content []byte, offset int) position {
173
offset = min(max(offset, 0), len(content))
174
lineStart := 0
175
line := 0
176
for index, character := range content[:offset] {
177
if character == '\n' {
178
line++
179
lineStart = index + 1
180
}
181
}
182
units := 0
183
for remaining := content[lineStart:offset]; len(remaining) > 0; {
184
character, size := utf8.DecodeRune(remaining)
185
units += len(utf16.Encode([]rune{character}))
186
remaining = remaining[size:]
187
}
188
return position{Line: line, Character: units}
189
}
191
func lineUTF16Length(content []byte, wanted int) int {
192
start := 0
193
line := 0
194
for index, character := range content {
195
if line == wanted && character == '\n' {
196
return positionAt(content, index).Character
197
}
198
if character == '\n' {
199
line++
200
start = index + 1
201
}
202
}
203
if line == wanted {
204
return positionAt(content, len(content)).Character
205
}
206
_ = start
207
return 0
208
}
210
func markdown(item annotationItem) string {
211
var text strings.Builder
212
fmt.Fprintf(&text, "**%s** · `%s` · `%s`\n\n%s", item.Kind, item.Status, item.ID, item.Body)
213
if item.Warning != "" {
214
fmt.Fprintf(&text, "\n\n> %s", item.Warning)
215
}
216
return text.String()
217
}