| 1 | package lsp |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "net/url" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "unicode/utf16" |
| 11 | ) |
| 12 | |
| 13 | // Position is a zero-based LSP position. Character is counted in the encoding the |
| 14 | // server negotiated at initialize (utf-16 by default, utf-8 when both sides |
| 15 | // agree). |
| 16 | type Position struct { |
| 17 | Line int `json:"line"` |
| 18 | Character int `json:"character"` |
| 19 | } |
| 20 | |
| 21 | // Range is a half-open span between two positions. |
| 22 | type Range struct { |
| 23 | Start Position `json:"start"` |
| 24 | End Position `json:"end"` |
| 25 | } |
| 26 | |
| 27 | // Location is a file URI plus a range, the shape definition/references return. |
| 28 | type Location struct { |
| 29 | URI string `json:"uri"` |
| 30 | Range Range `json:"range"` |
| 31 | } |
| 32 | |
| 33 | func pathToURI(p string) string { |
| 34 | return pathToURIForOS(p, runtime.GOOS) |
| 35 | } |
| 36 | |
| 37 | func pathToURIForOS(p, goos string) string { |
| 38 | p = filepath.ToSlash(p) |
| 39 | if goos == "windows" { |
| 40 | p = strings.ReplaceAll(p, `\`, "/") |
| 41 | if hostAndPath, ok := strings.CutPrefix(p, "//"); ok { |
| 42 | host, uriPath, found := strings.Cut(hostAndPath, "/") |
| 43 | if found && host != "" { |
| 44 | return (&url.URL{Scheme: "file", Host: host, Path: "/" + uriPath}).String() |
| 45 | } |
| 46 | } |
| 47 | if len(p) > 1 && p[1] == ':' { |
| 48 | p = "/" + p // C:/x → /C:/x so the URI becomes file:///C:/x |
| 49 | } |
| 50 | } |
| 51 | u := url.URL{Scheme: "file", Path: p} |
| 52 | return u.String() |
| 53 | } |
| 54 | |
| 55 | func uriToPath(uri string) (string, error) { |
| 56 | return uriToPathForOS(uri, runtime.GOOS) |
| 57 | } |
| 58 | |
| 59 | func uriToPathForOS(uri, goos string) (string, error) { |
| 60 | u, err := url.Parse(uri) |
| 61 | if err != nil { |
| 62 | return "", fmt.Errorf("parse URI: %w", err) |
| 63 | } |
| 64 | if !strings.EqualFold(u.Scheme, "file") || u.Opaque != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { |
| 65 | return "", errors.New("URI is not a local file URI") |
| 66 | } |
| 67 | if strings.ContainsRune(u.Path, 0) { |
| 68 | return "", errors.New("file URI path contains NUL") |
| 69 | } |
| 70 | host := u.Hostname() |
| 71 | if u.Host != "" && (host == "" || u.Port() != "") { |
| 72 | return "", errors.New("file URI has an invalid authority") |
| 73 | } |
| 74 | if host != "" && !strings.EqualFold(host, "localhost") { |
| 75 | if goos != "windows" { |
| 76 | return "", fmt.Errorf("remote file URI authority %q is not local on %s", host, goos) |
| 77 | } |
| 78 | if u.Path == "" || u.Path == "/" { |
| 79 | return "", errors.New("UNC file URI is missing a share path") |
| 80 | } |
| 81 | return `\\` + host + `\` + strings.ReplaceAll(strings.TrimPrefix(u.Path, "/"), "/", `\`), nil |
| 82 | } |
| 83 | p := u.Path |
| 84 | if p == "" { |
| 85 | return "", errors.New("file URI path is empty") |
| 86 | } |
| 87 | if goos == "windows" && len(p) > 2 && p[0] == '/' && p[2] == ':' { |
| 88 | p = p[1:] |
| 89 | } |
| 90 | if goos == "windows" { |
| 91 | return strings.ReplaceAll(p, "/", `\`), nil |
| 92 | } |
| 93 | return p, nil |
| 94 | } |
| 95 | |
| 96 | // locate finds symbol on the 1-based line of content and returns the LSP position |
| 97 | // of its first byte, converting the byte column into the server's encoding. |
| 98 | func locate(content string, line1 int, symbol, enc string) (Position, error) { |
| 99 | lines := strings.Split(content, "\n") |
| 100 | if line1 < 1 || line1 > len(lines) { |
| 101 | return Position{}, fmt.Errorf("line %d out of range (file has %d lines)", line1, len(lines)) |
| 102 | } |
| 103 | text := strings.TrimSuffix(lines[line1-1], "\r") |
| 104 | before, _, ok := strings.Cut(text, symbol) |
| 105 | if !ok { |
| 106 | return Position{}, fmt.Errorf("symbol %q not found on line %d", symbol, line1) |
| 107 | } |
| 108 | return Position{Line: line1 - 1, Character: encodeChar(before, enc)}, nil |
| 109 | } |
| 110 | |
| 111 | func encodeChar(prefix, enc string) int { |
| 112 | if enc == encodingUTF8 { |
| 113 | return len(prefix) |
| 114 | } |
| 115 | return len(utf16.Encode([]rune(prefix))) |
| 116 | } |
| 117 | |
| 118 | const ( |
| 119 | encodingUTF8 = "utf-8" |
| 120 | encodingUTF16 = "utf-16" |
| 121 | ) |
| 122 |