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/koment-dev/koment/internal/anchor"
16
"github.com/koment-dev/koment/internal/application"
17
"github.com/koment-dev/koment/internal/commentpolicy"
18
"github.com/koment-dev/koment/internal/policy"
19
"github.com/koment-dev/koment/internal/repository"
20
"github.com/koment-dev/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.Spec.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.Metadata.ID, Kind: string(view.Record.Spec.Type),
109
Title: view.Record.Headline(), Body: view.Record.Spec.Body,
110
Status: string(view.Status), Line: line, Warning: view.Warning, Range: annotationRange,
111
})
112
}
113
sort.Slice(items, func(left, right int) bool {
114
if items[left].Line != items[right].Line {
115
return items[left].Line < items[right].Line
116
}
117
return items[left].ID < items[right].ID
118
})
119
return items, nil
120
}
122
func documentDiagnostics(file workspaceFile) ([]diagnostic, error) {
123
items, err := annotationItems(file)
124
if err != nil {
125
return nil, err
126
}
127
diagnostics := []diagnostic{}
128
for _, item := range items {
129
switch anchor.Status(item.Status) {
130
case anchor.StatusAmbiguous, anchor.StatusDrifted, anchor.StatusOrphaned:
131
diagnostics = append(diagnostics, diagnostic{
132
Range: item.Range, Severity: 1, Code: "koment." + item.Status,
133
Source: "koment", Message: item.Warning, Data: map[string]string{"id": item.ID},
134
})
135
}
136
}
137
if filepath.Ext(file.relative) != ".go" {
138
return diagnostics, nil
139
}
140
configured, err := policy.Load(file.root)
141
if errors.Is(err, fs.ErrNotExist) {
142
return diagnostics, nil
143
}
144
if err != nil {
145
return nil, err
146
}
147
records, err := file.store.ForFile(file.relative)
148
if err != nil {
149
return nil, err
150
}
151
violations, err := commentpolicy.CheckContent(file.relative, file.content, configured, records)
152
if err != nil {
153
return nil, err
154
}
155
for _, violation := range violations {
156
diagnostics = append(diagnostics, diagnostic{
157
Range: rangeFromOffsets(file.content, violation.Comment.Start, violation.Comment.End),
158
Severity: 2, Code: "koment.comment", Source: "koment",
159
Message: violation.Reason,
160
Data: map[string]any{
161
"comment": violation.Comment.Raw, "file": file.relative,
162
"autoPrompt": commentpolicy.IsCommentIntent(violation.Comment),
163
},
164
})
165
}
166
return diagnostics, nil
167
}
169
func rangeFromOffsets(content []byte, start, end int) rangeValue {
170
return rangeValue{Start: positionAt(content, start), End: positionAt(content, end)}
171
}
173
func positionAt(content []byte, offset int) position {
174
offset = min(max(offset, 0), len(content))
175
lineStart := 0
176
line := 0
177
for index, character := range content[:offset] {
178
if character == '\n' {
179
line++
180
lineStart = index + 1
181
}
182
}
183
units := 0
184
for remaining := content[lineStart:offset]; len(remaining) > 0; {
185
character, size := utf8.DecodeRune(remaining)
186
units += len(utf16.Encode([]rune{character}))
187
remaining = remaining[size:]
188
}
189
return position{Line: line, Character: units}
190
}
192
func lineUTF16Length(content []byte, wanted int) int {
193
start := 0
194
line := 0
195
for index, character := range content {
196
if line == wanted && character == '\n' {
197
return positionAt(content, index).Character
198
}
199
if character == '\n' {
200
line++
201
start = index + 1
202
}
203
}
204
if line == wanted {
205
return positionAt(content, len(content)).Character
206
}
207
_ = start
208
return 0
209
}
211
func markdown(item annotationItem) string {
212
var text strings.Builder
213
fmt.Fprintf(&text, "### %s\n\n**%s** · `%s` · `%s`\n\n%s", item.Title, item.Kind, item.Status, item.ID, item.Body)
214
if item.Warning != "" {
215
fmt.Fprintf(&text, "\n\n> %s", item.Warning)
216
}
217
return text.String()
218
}