@@ -2,9 +2,15 @@ package diagnostics
22
33import (
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
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.
2035func 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