1package lsp
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/url"
7 "path/filepath"
8 "runtime"
9 "strings"
10)
11
12// Position and Range use the zero-based line and UTF-16 character indexing
13// defined by LSP.
14type Position struct {
15 Line int `json:"line"`
16 Character int `json:"character"`
17}
18
19type Range struct {
20 Start Position `json:"start"`
21 End Position `json:"end"`
22}
23
24type TextEdit struct {
25 Range Range `json:"range"`
26 NewText string `json:"newText"`
27}
28
29// WorkspaceEdit is the safe subset of a workspace edit understood by zut.
30// Resource operations in DocumentChanges are deliberately rejected.
31type WorkspaceEdit struct {
32 Changes map[string][]TextEdit `json:"changes,omitempty"`
33 DocumentChanges []json.RawMessage `json:"documentChanges,omitempty"`
34 ChangeAnnotations json.RawMessage `json:"changeAnnotations,omitempty"`
35}
36
37// Diagnostic is the normalized diagnostic representation shared by LSP and
38// CLI linters. Path is absolute when it came from a workspace-backed source.
39type Diagnostic struct {
40 Path string `json:"path"`
41 Range Range `json:"range"`
42 Severity string `json:"severity"`
43 Code string `json:"code,omitempty"`
44 Message string `json:"message"`
45 Source string `json:"source,omitempty"`
46 Server string `json:"server,omitempty"`
47 Clear bool `json:"-"`
48}
49
50func (d *Diagnostic) UnmarshalJSON(data []byte) error {
51 var raw struct {
52 Range Range `json:"range"`
53 Severity json.RawMessage `json:"severity"`
54 Code json.RawMessage `json:"code"`
55 Message string `json:"message"`
56 Source string `json:"source"`
57 }
58 if err := json.Unmarshal(data, &raw); err != nil {
59 return err
60 }
61 d.Range, d.Message, d.Source = raw.Range, raw.Message, raw.Source
62 d.Severity = severityFromJSON(raw.Severity)
63 d.Code = stringFromJSON(raw.Code)
64 return nil
65}
66
67func severityFromJSON(raw json.RawMessage) string {
68 var n int
69 if json.Unmarshal(raw, &n) == nil {
70 switch n {
71 case 1:
72 return "error"
73 case 2:
74 return "warning"
75 case 3:
76 return "info"
77 case 4:
78 return "hint"
79 }
80 }
81 var s string
82 if json.Unmarshal(raw, &s) == nil {
83 switch strings.ToLower(s) {
84 case "error", "err", "fatal":
85 return "error"
86 case "warning", "warn":
87 return "warning"
88 case "hint":
89 return "hint"
90 default:
91 return "info"
92 }
93 }
94 return "info"
95}
96
97func stringFromJSON(raw json.RawMessage) string {
98 if len(raw) == 0 || string(raw) == "null" {
99 return ""
100 }
101 var s string
102 if json.Unmarshal(raw, &s) == nil {
103 return s
104 }
105 var n json.Number
106 if json.Unmarshal(raw, &n) == nil {
107 return n.String()
108 }
109 return strings.Trim(string(raw), `"`)
110}
111
112// URIForPath returns the escaped file URI used by LSP document params.
113func URIForPath(path string) string {
114 uri, _ := pathToURI(path)
115 return uri
116}
117
118func pathToURI(path string) (string, error) {
119 abs, err := filepath.Abs(path)
120 if err != nil {
121 return "", err
122 }
123 // url.URL escapes spaces and non-ASCII while retaining path separators.
124 uriPath := filepath.ToSlash(abs)
125 if runtime.GOOS == "windows" && strings.HasPrefix(uriPath, "//") {
126 unc := strings.TrimPrefix(uriPath, "//")
127 if host, rest, ok := strings.Cut(unc, "/"); ok {
128 return (&url.URL{Scheme: "file", Host: host, Path: "/" + rest}).String(), nil
129 }
130 }
131 if !strings.HasPrefix(uriPath, "/") {
132 uriPath = "/" + uriPath
133 }
134 u := &url.URL{Scheme: "file", Path: uriPath}
135 return u.String(), nil
136}
137
138// URIToPath converts an LSP document URI to a platform-native path.
139func URIToPath(uri string) (string, error) {
140 return uriToPath(uri)
141}
142
143func uriToPath(uri string) (string, error) {
144 u, err := url.Parse(uri)
145 if err != nil {
146 return "", err
147 }
148 if u.Scheme != "" && !strings.EqualFold(u.Scheme, "file") {
149 return "", fmt.Errorf("unsupported document URI scheme %q", u.Scheme)
150 }
151 path := u.Path
152 if u.Host != "" && u.Host != "localhost" {
153 path = "//" + u.Host + path
154 }
155 if path == "" {
156 return "", fmt.Errorf("document URI has no path")
157 }
158 if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' { // file:///C:/...
159 path = path[1:]
160 }
161 return filepath.Clean(filepath.FromSlash(path)), nil
162}