Skip to content

Commit 0a6c283

Browse files
committed
feat(components/diagnostics): add line number and column numbers
1 parent 662d391 commit 0a6c283

6 files changed

Lines changed: 331 additions & 23 deletions

File tree

pkg/cmd/builddiagnostic.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ func handleBuildsDiagnosticsList(ctx context.Context, cmd *cli.Command) error {
122122
if err := iter.Err(); err != nil {
123123
return err
124124
}
125-
fmt.Print(diagnostics.ViewDiagnostics(diags, int(maxItems), workspace.Relative(wc.OpenAPISpec), workspace.Relative(wc.StainlessConfig)))
125+
fmt.Print(diagnostics.ViewDiagnostics(diags, int(maxItems),
126+
workspace.Relative(wc.OpenAPISpec),
127+
workspace.Relative(wc.StainlessConfig),
128+
))
126129
return nil
127130
}
128131

pkg/cmd/lint.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ func (m lintModel) View() string {
134134
content = m.spinner.View() + " Linting"
135135
}
136136
} else {
137-
content = diagnostics.ViewDiagnostics(m.diagnostics, -1, workspace.Relative(m.wc.OpenAPISpec), workspace.Relative(m.wc.StainlessConfig))
137+
content = diagnostics.ViewDiagnostics(m.diagnostics, -1,
138+
workspace.Relative(m.wc.OpenAPISpec),
139+
workspace.Relative(m.wc.StainlessConfig),
140+
)
138141
if m.skipped {
139142
content += "\nContinuing..."
140143
} else if m.watching {

pkg/components/diagnostics/model.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ func (m Model) View() string {
6060
if m.Diagnostics == nil {
6161
return ""
6262
}
63-
return ViewDiagnostics(m.Diagnostics, 10, workspace.Relative(m.WorkspaceConfig.OpenAPISpec), workspace.Relative(m.WorkspaceConfig.StainlessConfig))
63+
return ViewDiagnostics(m.Diagnostics, 10,
64+
workspace.Relative(m.WorkspaceConfig.OpenAPISpec),
65+
workspace.Relative(m.WorkspaceConfig.StainlessConfig),
66+
)
6467
}
6568

6669
func (m Model) FetchDiagnostics(buildID string) tea.Cmd {

pkg/components/diagnostics/testdata/view_diagnostics.snapshot

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,23 @@
55
error: failed to fetch diagnostics: connection refused
66

77
error[MissingField]: The field 'name' is required but missing
8-
 --> openapi.yml: /paths/~1users/post/requestBody
9-
 --> stainless.yml: /endpoints/~1users/post
8+
 --> openapi.yml:6:16: /paths/~1users/post/requestBody
9+
 --> stainless.yml:7:15: /endpoints/~1users/post
1010

1111
fatal[FatalError]: Build failed due to configuration error
12-
 --> openapi.yml: /paths/~1users
12+
 --> openapi.yml:4:9: /paths/~1users
1313
Check your stainless.yml for syntax errors.
1414
See docs for details.
1515

1616
warning[DeprecatedUsage]: The x-deprecated extension is deprecated
17-
 --> openapi.yml: /paths/~1foo/get
17+
 --> openapi.yml:12:16: /paths/~1foo/get
1818

1919
... and 1 more diagnostics
2020

2121
error[MissingField]: Field 'id' is required
22-
 --> specs/openapi.json: /paths/~1pets/get
23-
 --> .stainless/stainless.yaml: /endpoints/~1pets/get
22+
 --> specs/openapi.json:5:16: /paths/~1pets/get
23+
 --> .stainless/stainless.yaml:4:15: /endpoints/~1pets/get
24+
25+
error[BothSpecifiedAndUnspecified]: Endpoint is in both places
26+
 --> openapi.json:6:16: #/paths/%2Fusers/post/requestBody
27+
 --> openapi.stainless.yml:4:15: #/endpoints/%2Fusers/post

pkg/components/diagnostics/view.go

Lines changed: 183 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@ package diagnostics
22

33
import (
44
"fmt"
5+
"net/url"
6+
"os"
7+
"path/filepath"
8+
"strconv"
59
"strings"
610

711
"github.com/charmbracelet/lipgloss"
12+
"github.com/goccy/go-yaml/ast"
13+
"github.com/goccy/go-yaml/parser"
814
"github.com/stainless-api/stainless-api-go"
915
)
1016

@@ -16,6 +22,15 @@ var (
1622
refStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
1723
)
1824

25+
type sourceResolver struct {
26+
parsed map[string]parsedSource
27+
}
28+
29+
type parsedSource struct {
30+
file *ast.File
31+
err error
32+
}
33+
1934
// levelLabel returns the colored level prefix and bracket-wrapped code for a diagnostic.
2035
func levelLabel(level stainless.BuildDiagnosticLevel, code string) string {
2136
var levelStr string
@@ -48,15 +63,9 @@ func ViewDiagnosticsError(err error) string {
4863
}
4964

5065
// ViewDiagnostics renders build diagnostics in Rust-style formatting.
51-
// Notes are hidden by default. oasLabel and configLabel are the filenames
52-
// shown in source references (e.g. "openapi.json", "stainless.yaml").
53-
func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int, oasLabel, configLabel string) string {
54-
if oasLabel == "" {
55-
oasLabel = "openapi.yml"
56-
}
57-
if configLabel == "" {
58-
configLabel = "stainless.yml"
59-
}
66+
// Notes are hidden by default. oasPath and configPath should be display paths,
67+
// typically relative to the current working directory.
68+
func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int, oasPath, configPath string) string {
6069
// Filter out notes
6170
var visible []stainless.BuildDiagnostic
6271
for _, d := range diagnostics {
@@ -71,6 +80,7 @@ func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int
7180
}
7281

7382
var s strings.Builder
83+
resolver := sourceResolver{parsed: map[string]parsedSource{}}
7484

7585
truncated := false
7686
shown := len(visible)
@@ -98,11 +108,11 @@ func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int
98108

99109
// Source references
100110
if diag.OasRef != "" {
101-
s.WriteString(refStyle.Render(" --> " + oasLabel + ": " + diag.OasRef))
111+
s.WriteString(refStyle.Render(" --> " + resolver.resolveRef(oasPath, "openapi.yml", diag.OasRef)))
102112
s.WriteString("\n")
103113
}
104114
if diag.ConfigRef != "" {
105-
s.WriteString(refStyle.Render(" --> " + configLabel + ": " + diag.ConfigRef))
115+
s.WriteString(refStyle.Render(" --> " + resolver.resolveRef(configPath, "stainless.yml", diag.ConfigRef)))
106116
s.WriteString("\n")
107117
}
108118

@@ -137,3 +147,165 @@ func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int
137147

138148
return s.String()
139149
}
150+
151+
func (r *sourceResolver) resolveRef(path, fallbackLabel, pointer string) string {
152+
label := sourceLabel(path, fallbackLabel)
153+
if line, column, ok := r.resolvePointer(path, pointer); ok {
154+
return fmt.Sprintf("%s:%d:%d: %s", label, line, column, pointer)
155+
}
156+
return label + ": " + pointer
157+
}
158+
159+
func sourceLabel(path, fallbackLabel string) string {
160+
if path == "" {
161+
return fallbackLabel
162+
}
163+
return path
164+
}
165+
166+
func (r *sourceResolver) resolvePointer(displayPath, pointer string) (int, int, bool) {
167+
path, ok := resolveSourcePath(displayPath)
168+
if !ok {
169+
return 0, 0, false
170+
}
171+
172+
parsed, ok := r.parsed[path]
173+
if !ok {
174+
content, err := os.ReadFile(path)
175+
if err != nil {
176+
parsed = parsedSource{err: err}
177+
} else {
178+
file, err := parser.ParseBytes(content, 0)
179+
parsed = parsedSource{file: file, err: err}
180+
}
181+
r.parsed[path] = parsed
182+
}
183+
if parsed.err != nil || parsed.file == nil {
184+
return 0, 0, false
185+
}
186+
187+
node, ok := resolveJSONPointer(parsed.file, pointer)
188+
if !ok {
189+
return 0, 0, false
190+
}
191+
192+
token := node.GetToken()
193+
if token == nil || token.Position == nil {
194+
return 0, 0, false
195+
}
196+
return token.Position.Line, token.Position.Column, true
197+
}
198+
199+
func resolveSourcePath(displayPath string) (string, bool) {
200+
if displayPath == "" {
201+
return "", false
202+
}
203+
204+
path, err := filepath.Abs(displayPath)
205+
if err != nil {
206+
return "", false
207+
}
208+
return path, true
209+
}
210+
211+
func resolveJSONPointer(file *ast.File, pointer string) (ast.Node, bool) {
212+
node := firstDocumentNode(file)
213+
if node == nil {
214+
return nil, false
215+
}
216+
217+
segments, ok := parseJSONPointer(pointer)
218+
if !ok {
219+
return nil, false
220+
}
221+
222+
for _, segment := range segments {
223+
var found bool
224+
node, found = descendNode(node, segment)
225+
if !found {
226+
return nil, false
227+
}
228+
}
229+
230+
return node, true
231+
}
232+
233+
func firstDocumentNode(file *ast.File) ast.Node {
234+
for _, doc := range file.Docs {
235+
if doc.Body == nil || doc.Body.Type() == ast.DirectiveType {
236+
continue
237+
}
238+
return doc.Body
239+
}
240+
return nil
241+
}
242+
243+
func parseJSONPointer(pointer string) ([]string, bool) {
244+
if pointer == "" || pointer == "#" {
245+
return nil, true
246+
}
247+
248+
switch {
249+
case strings.HasPrefix(pointer, "#/"):
250+
pointer = pointer[1:]
251+
case !strings.HasPrefix(pointer, "/"):
252+
return nil, false
253+
}
254+
255+
parts := strings.Split(pointer[1:], "/")
256+
segments := make([]string, 0, len(parts))
257+
for _, part := range parts {
258+
unescaped, err := url.PathUnescape(part)
259+
if err != nil {
260+
return nil, false
261+
}
262+
part = unescaped
263+
part = strings.ReplaceAll(part, "~1", "/")
264+
part = strings.ReplaceAll(part, "~0", "~")
265+
segments = append(segments, part)
266+
}
267+
return segments, true
268+
}
269+
270+
func descendNode(node ast.Node, segment string) (ast.Node, bool) {
271+
switch node := node.(type) {
272+
case *ast.MappingNode:
273+
for _, value := range node.Values {
274+
if mapKeyString(value.Key) == segment {
275+
return value.Value, true
276+
}
277+
}
278+
case *ast.SequenceNode:
279+
idx, err := strconv.Atoi(segment)
280+
if err != nil || idx < 0 || idx >= len(node.Values) {
281+
return nil, false
282+
}
283+
return node.Values[idx], true
284+
}
285+
return nil, false
286+
}
287+
288+
func mapKeyString(key ast.MapKeyNode) string {
289+
if key == nil || key.GetToken() == nil {
290+
return ""
291+
}
292+
293+
value := key.GetToken().Value
294+
if len(value) == 0 {
295+
return value
296+
}
297+
298+
switch value[0] {
299+
case '"':
300+
unquoted, err := strconv.Unquote(value)
301+
if err == nil {
302+
return unquoted
303+
}
304+
case '\'':
305+
if len(value) > 1 && value[len(value)-1] == '\'' {
306+
return value[1 : len(value)-1]
307+
}
308+
}
309+
310+
return value
311+
}

0 commit comments

Comments
 (0)