blob: 86775b065f5d49c367412703374899011dfee651 [file] [log] [blame]
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package protocol
// This file declares URI, DocumentURI, and its methods.
//
// For the LSP definition of these types, see
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#uri
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"unicode"
"golang.org/x/tools/gopls/internal/util/pathutil"
)
// A DocumentURI is the URI of a client editor document.
//
// According to the LSP specification:
//
// Care should be taken to handle encoding in URIs. For
// example, some clients (such as VS Code) may encode colons
// in drive letters while others do not. The URIs below are
// both valid, but clients and servers should be consistent
// with the form they use themselves to ensure the other party
// doesn’t interpret them as distinct URIs. Clients and
// servers should not assume that each other are encoding the
// same way (for example a client encoding colons in drive
// letters cannot assume server responses will have encoded
// colons). The same applies to casing of drive letters - one
// party should not assume the other party will return paths
// with drive letters cased the same as it.
//
// file:///c:/project/readme.md
// file:///C%3A/project/readme.md
//
// This is done during JSON unmarshalling;
// see [DocumentURI.UnmarshalText] for details.
type DocumentURI string
// A URI is an arbitrary URL (e.g. https), not necessarily a file.
type URI = string
// UnmarshalText implements decoding of DocumentURI values.
//
// In particular, it implements a systematic correction of various odd
// features of the definition of DocumentURI in the LSP spec that
// appear to be workarounds for bugs in VS Code. For example, it may
// URI-encode the URI itself, so that colon becomes %3A, and it may
// send file://foo.go URIs that have two slashes (not three) and no
// hostname.
//
// We use UnmarshalText, not UnmarshalJSON, because it is called even
// for non-addressable values such as keys and values of map[K]V,
// where there is no pointer of type *K or *V on which to call
// UnmarshalJSON. (See Go issue #28189 for more detail.)
//
// Non-empty DocumentURIs are valid "file"-scheme URIs.
// The empty DocumentURI is valid.
func (uri *DocumentURI) UnmarshalText(data []byte) (err error) {
*uri, err = ParseDocumentURI(string(data))
return
}
// Path returns the file path for the given URI.
//
// DocumentURI("").Path() returns the empty string.
//
// Path panics if called on a URI that is not a valid filename.
func (uri DocumentURI) Path() string {
filename, err := filename(uri)
if err != nil {
// e.g. ParseRequestURI failed.
//
// This can only affect DocumentURIs created by
// direct string manipulation; all DocumentURIs
// received from the client pass through
// ParseRequestURI, which ensures validity.
panic(err)
}
return filepath.FromSlash(filename)
}
// Dir returns the URI for the directory containing the receiver.
func (uri DocumentURI) Dir() DocumentURI {
// This function could be more efficiently implemented by avoiding any call
// to Path(), but at least consolidates URI manipulation.
return URIFromPath(filepath.Dir(uri.Path()))
}
// Encloses reports whether uri's path, considered as a sequence of segments,
// is a prefix of file's path.
func (uri DocumentURI) Encloses(file DocumentURI) bool {
return pathutil.InDir(uri.Path(), file.Path())
}
func filename(uri DocumentURI) (string, error) {
if uri == "" {
return "", nil
}
// This conservative check for the common case
// of a simple non-empty absolute POSIX filename
// avoids the allocation of a net.URL.
if strings.HasPrefix(string(uri), "file:///") {
rest := string(uri)[len("file://"):] // leave one slash
for i := 0; i < len(rest); i++ {
b := rest[i]
// Reject these cases:
if b < ' ' || b == 0x7f || // control character
b == '%' || b == '+' || // URI escape
b == ':' || // Windows drive letter
b == '@' || b == '&' || b == '?' { // authority or query
goto slow
}
}
return rest, nil
}
slow:
u, err := url.ParseRequestURI(string(uri))
if err != nil {
return "", err
}
if u.Scheme != fileScheme {
return "", fmt.Errorf("only file URIs are supported, got %q from %q", u.Scheme, uri)
}
// If the URI is a Windows URI, we trim the leading "/" and uppercase
// the drive letter, which will never be case sensitive.
if isWindowsDriveURIPath(u.Path) {
u.Path = strings.ToUpper(string(u.Path[1])) + u.Path[2:]
}
return u.Path, nil
}
// ParseDocumentURI interprets a string as a DocumentURI, applying VS
// Code workarounds; see [DocumentURI.UnmarshalText] for details.
func ParseDocumentURI(s string) (DocumentURI, error) {
if s == "" {
return "", nil
}
if !strings.HasPrefix(s, "file://") {
return "", fmt.Errorf("DocumentURI scheme is not 'file': %s", s)
}
// VS Code sends URLs with only two slashes,
// which are invalid. golang/go#39789.
if !strings.HasPrefix(s, "file:///") {
s = "file:///" + s[len("file://"):]
}
// Even though the input is a URI, it may not be in canonical form. VS Code
// in particular over-escapes :, @, etc. Unescape and re-encode to canonicalize.
path, err := url.PathUnescape(s[len("file://"):])
if err != nil {
return "", err
}
// File URIs from Windows may have lowercase drive letters.
// Since drive letters are guaranteed to be case insensitive,
// we change them to uppercase to remain consistent.
// For example, file:///c:/x/y/z becomes file:///C:/x/y/z.
if isWindowsDriveURIPath(path) {
path = path[:1] + strings.ToUpper(string(path[1])) + path[2:]
}
u := url.URL{Scheme: fileScheme, Path: path}
return DocumentURI(u.String()), nil
}
// URIFromPath returns DocumentURI for the supplied file path.
// Given "", it returns "".
func URIFromPath(path string) DocumentURI {
if path == "" {
return ""
}
if !isWindowsDrivePath(path) {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
}
// Check the file path again, in case it became absolute.
if isWindowsDrivePath(path) {
path = "/" + strings.ToUpper(string(path[0])) + path[1:]
}
path = filepath.ToSlash(path)
u := url.URL{
Scheme: fileScheme,
Path: path,
}
return DocumentURI(u.String())
}
const fileScheme = "file"
// isWindowsDrivePath returns true if the file path is of the form used by
// Windows. We check if the path begins with a drive letter, followed by a ":".
// For example: C:/x/y/z.
func isWindowsDrivePath(path string) bool {
if len(path) < 3 {
return false
}
return unicode.IsLetter(rune(path[0])) && path[1] == ':'
}
// isWindowsDriveURIPath returns true if the file URI is of the format used by
// Windows URIs. The url.Parse package does not specially handle Windows paths
// (see golang/go#6027), so we check if the URI path has a drive prefix (e.g. "/C:").
func isWindowsDriveURIPath(uri string) bool {
if len(uri) < 4 {
return false
}
return uri[0] == '/' && unicode.IsLetter(rune(uri[1])) && uri[2] == ':'
}