diff --git a/uri.go b/uri.go index b389d46..5a4c3dd 100644 --- a/uri.go +++ b/uri.go @@ -5,13 +5,12 @@ package uri import ( - "bytes" - "encoding/json" "fmt" "net/url" "path/filepath" - "strconv" + "runtime" "strings" + "unicode" "golang.org/x/xerrors" ) @@ -28,15 +27,10 @@ const ( ) const ( - keyScheme = "scheme" - keyAuthority = "authority" - keyPath = "path" - keyFsPath = "fsPath" - keyQuery = "query" - keyFragment = "fragment" + hierPart = "://" ) -// URI Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986. +// URI Uniform Resource Identifier (URI) https://tools.ietf.org/html/rfc3986. // // This class is a simple parser which creates the basic component parts // (http://tools.ietf.org/html/rfc3986#section-3) with minimal validation @@ -49,453 +43,161 @@ const ( // | _____________________|__ // / \ / \ // urn:example:animal:ferret:nose -type URI struct { - // Scheme is the 'http' part of 'http://www.msft.com/some/path?query#fragment'. - // - // The part before the first colon. - Scheme string `json:"scheme"` +type URI string - // Authority is the 'www.msft.com' part of 'http://www.msft.com/some/path?query#fragment'. - // The part between the first double slashes and the next slash. - Authority string `json:"authority"` +// Filename returns the file path for the given URI. It will return an error if +// the URI is invalid, or if the URI does not have the file scheme. +func (uri URI) Filename() (string, error) { + filename, err := filename(uri) + if err != nil { + return "", err + } - // Path is the '/some/path' part of 'http://www.msft.com/some/path?query#fragment'. - Path string `json:"path"` + return filepath.FromSlash(filename), nil +} - // FsPath returns a string representing the corresponding file system path of this URI. - // - // Will handle UNC paths, normalizes windows drive letters to lower-case, and uses the - // platform specific path separator. - // - // * Will *not* validate the path for invalid characters and semantics. - // * Will *not* look at the scheme of this URI. - // * The result shall *not* be used for display purposes but for accessing a file on disk. - // - // - // The *difference* to `URI#path` is the use of the platform specific separator and the handling - // of UNC paths. See the below sample of a file-uri with an authority (UNC path). - // - // const u = URI.parse('file://server/c$/folder/file.txt') - // u.authority === 'server' - // u.path === '/shares/c$/file.txt' - // u.fsPath === '\\server\c$\folder\file.txt' - // - // Using `URI#path` to read a file (using fs-apis) would not be enough because parts of the path, - // namely the server name, would be missing. - // - // Therefore `URI#fsPath` exists - it's sugar to ease working with URIs that represent files on disk (`file` scheme). - FsPath string `json:"fsPath,omitempty"` +func filename(uri URI) (string, error) { + u, err := url.ParseRequestURI(string(uri)) + if err != nil { + return "", xerrors.Errorf("failed to parse request URI: %w", err) + } - // Query is the 'query' part of 'http://www.msft.com/some/path?query#fragment'. - Query string `json:"query,omitempty"` + if u.Scheme != FileScheme { + return "", xerrors.Errorf("only file URIs are supported, got %v", u.Scheme) + } - // Fragment is the 'fragment' part of 'http://www.msft.com/some/path?query#fragment'. - Fragment string `json:"fragment,omitempty"` + if isWindowsDriveURI(u.Path) { + u.Path = u.Path[1:] + } - formatted string - skipEncoding bool + return u.Path, nil } // MarshalJSON implements json.Marshaler. -func (u *URI) MarshalJSON() ([]byte, error) { - buf := new(bytes.Buffer) - - buf.WriteString("{") - - buf.WriteString(`"` + strconv.Quote(keyScheme) + `: "`) - scheme, err := json.Marshal(u.Scheme) - if err != nil { - return nil, err - } - buf.Write(scheme) - - buf.WriteString(",") - buf.WriteString(`"` + strconv.Quote(keyAuthority) + `: "`) - authority, err := json.Marshal(u.Authority) - if err != nil { - return nil, err - } - buf.Write(authority) - - buf.WriteString(",") - buf.WriteString(`"` + strconv.Quote(keyPath) + `: "`) - path, err := json.Marshal(u.Path) - if err != nil { - return nil, err - } - buf.Write(path) - - if u.Query != "" { - buf.WriteString(",") - buf.WriteString(`"` + strconv.Quote(keyQuery) + `: "`) - query, err := json.Marshal(u.Query) - if err != nil { - return nil, err - } - buf.Write(query) - } - - if u.FsPath != "" { - buf.WriteString(",") - buf.WriteString(`"` + strconv.Quote(keyFsPath) + `: "`) - fsPath, err := json.Marshal(u.FsPath) - if err != nil { - return nil, err - } - buf.Write(fsPath) - } - - if u.Fragment != "" { - buf.WriteString(",") - buf.WriteString(`"` + strconv.Quote(keyFragment) + `: "`) - fragment, err := json.Marshal(u.Fragment) - if err != nil { - return nil, err - } - buf.Write(fragment) - } - - buf.WriteString("}") - - return buf.Bytes(), nil +func (u URI) MarshalJSON() ([]byte, error) { + return nil, nil } // UnmarshalJSON implements json.Unmarshaler. func (u *URI) UnmarshalJSON(b []byte) error { - var schemeReceived bool - var authorityReceived bool - var pathReceived bool - var jm map[string]json.RawMessage - - if err := json.Unmarshal(b, &jm); err != nil { - return err - } - - // parse all the defined properties - for k, v := range jm { - switch k { - case keyScheme: - if err := json.Unmarshal([]byte(v), &u.Scheme); err != nil { - return err - } - schemeReceived = true - - case keyAuthority: - if err := json.Unmarshal([]byte(v), &u.Authority); err != nil { - return err - } - authorityReceived = true - - case keyPath: - if err := json.Unmarshal([]byte(v), &u.Path); err != nil { - return err - } - pathReceived = true - - case keyFsPath: - if err := json.Unmarshal([]byte(v), &u.FsPath); err != nil { - return err - } - - case keyQuery: - if err := json.Unmarshal([]byte(v), &u.Query); err != nil { - return err - } - - case keyFragment: - if err := json.Unmarshal([]byte(v), &u.Fragment); err != nil { - return err - } - - default: - return fmt.Errorf("additional property not allowed: \"" + k + "\"") - } - } - - // check if scheme (a required property) was received - if !schemeReceived { - return xerrors.Errorf("%q is required but was not present", keyScheme) - } - - // check if authority (a required property) was received - if !authorityReceived { - return xerrors.Errorf("%q is required but was not present", keyAuthority) - } - - // check if path (a required property) was received - if !pathReceived { - return xerrors.Errorf("%q is required but was not present", keyPath) - } - return nil } -// String implements fmt.Stringer. -func (u *URI) String() string { - switch u.Scheme { - case FileScheme, HTTPScheme, HTTPSScheme: - if u.skipEncoding { - return u.formatted - } - - u.formatted = format(*u, true) - u.skipEncoding = true - - return u.formatted - - default: - return "unknown scheme" +// New parses and creates a new URI from s. +func New(s string) URI { + if u, err := url.PathUnescape(s); err == nil { + s = u } + + if strings.HasPrefix(s, FileScheme+hierPart) { + return URI(s) + } + + return File(s) } -var encodeTable = map[byte]string{ - Colon: "%3A", // gen-delims - Slash: "%2F", - QuestionMark: "%3F", - Hash: "%23", - OpenSquareBracket: "%5B", - CloseSquareBracket: "%5D", - AtSign: "%40", - ExclamationMark: "%21", // sub-delims - DollarSign: "%24", - Ampersand: "%26", - SingleQuote: "%27", - OpenParen: "%28", - CloseParen: "%29", - Asterisk: "%2A", - Plus: "%2B", - Comma: "%2C", - Semicolon: "%3B", - Equals: "%3D", - Space: "%20", +// File parses and creates a new filesystem URI from path. +func File(path string) URI { + const goRootPragma = "$GOROOT" + if len(path) >= len(goRootPragma) && strings.EqualFold(goRootPragma, path[:len(goRootPragma)]) { + path = runtime.GOROOT() + path[len(goRootPragma):] + } + + if !isWindowsDrivePath(path) { + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + } + + if isWindowsDrivePath(path) { + path = "/" + path + } + + path = filepath.ToSlash(path) + u := url.URL{ + Scheme: FileScheme, + Path: path, + } + + return URI(u.String()) } -func encodeFast(uriComponent string, allowSlash bool) string { - b := new(strings.Builder) - nativeEncodePos := -1 - - for pos := 0; pos < len(uriComponent); pos++ { - code := uriComponent[pos] - - switch { - case code >= LowerA && code <= LowerZ, - code >= UpperA && code <= UpperZ, - code >= Digit0 && code <= Digit9, - code == Dash, - code == Period, - code == Underline, - code == Tilde, - allowSlash && (code == Slash): - - if nativeEncodePos != -1 { - b.WriteString(uriComponent[nativeEncodePos:pos]) - nativeEncodePos = -1 - } - str := b.String() - if str != "" { - b.WriteString(str) - b.WriteString(uriComponent[:pos]) - } - - default: - str := b.String() - if str != "" { - b.WriteString(str) - b.WriteString(uriComponent[0:pos]) - } - - escaped := encodeTable[code] - if escaped != "" { - if nativeEncodePos != -1 { - b.WriteString(uriComponent[nativeEncodePos:pos]) - } - - b.WriteString(escaped) - } else if nativeEncodePos == -1 { - nativeEncodePos = pos - } - } - } - - if nativeEncodePos != -1 { - b.WriteString(uriComponent[:nativeEncodePos]) - } - - return b.String() -} - -func encodeMinimal(path string, _ bool) string { - b := new(strings.Builder) - - for i := 0; i < len(path); i++ { - code := path[i] - if code == Hash || code == QuestionMark { - if b.String() == "" { - b.WriteString(path[0:i]) - } - b.WriteString(encodeTable[code]) - } else if b.String() != "" { - b.WriteByte(path[i]) - } - } - - res := b.String() - if res == "" { - res = path - } - - return res -} - -func format(uri URI, skipEncoding bool) string { - var encoder func(string, bool) string - switch skipEncoding { - case true: - encoder = encodeMinimal - case false: - encoder = encodeFast - } - - b := new(strings.Builder) - - scheme := uri.Scheme - if scheme != "" { - b.WriteString(scheme) - b.WriteByte(':') - } - - authority := uri.Authority - if authority != "" || scheme == FileScheme { - b.WriteRune(filepath.Separator) - b.WriteRune(filepath.Separator) - } - - if authority != "" { - idx := strings.LastIndex(authority, "@") - if idx != -1 { - // @ - userinfo := authority[:idx] - authority = authority[idx+1:] - - uiIdx := strings.Index(userinfo, ":") - if uiIdx == -1 { - b.WriteString(encoder(userinfo, false)) - } else { - // :@ - b.WriteString(encoder(userinfo[:idx], false)) - b.WriteRune(':') - b.WriteString(encoder(userinfo[idx+1:], false)) - } - - b.WriteRune('@') - } - - authority = strings.ToLower(authority) - - idx = strings.Index(authority, ":") - if idx == -1 { - b.WriteString(encoder(authority, false)) - } else { - // : - b.WriteString(encoder(authority[:idx], false)) - b.WriteString(authority[idx:]) - } - } - - if path := uri.Path; path != "" { - // lower-case windows drive letters in /C:/fff or C:/fff - if len(path) >= 3 && path[0] == Slash && path[2] == Colon { - code := path[1] - if code >= UpperA && code <= UpperZ { - path = "/" + string(code+32) + ":" + string(path[3]) // "/c:".length == 3 - } - } else if len(path) >= 2 && path[1] == Colon { - code := path[0] - if code >= UpperA && code <= UpperZ { - path = string(code+32) + ":" + string(path[2]) // "/c:".length == 3 - } - } - - // encode the rest of the path - b.WriteString(encoder(path, true)) - } - - if query := uri.Query; query != "" { - b.WriteRune('?') - b.WriteString(encoder(query, false)) - } - - if fragment := uri.Fragment; fragment != "" { - b.WriteRune('#') - - if skipEncoding { - b.WriteString(fragment) - } else { - b.WriteString(encodeFast(fragment, false)) - } - } - - return b.String() -} - -// Parse parses and creates a new URI from uri. -func Parse(s string) (u *URI, err error) { +// Parse parses and creates a new URI from s. +func Parse(s string) (u URI, err error) { us, err := url.Parse(s) if err != nil { - return nil, xerrors.Errorf("url.Parse: %w\n", err) + return u, xerrors.Errorf("url.Parse: %w\n", err) } switch us.Scheme { case FileScheme: - u = &URI{ - Scheme: FileScheme, - Path: us.Path, - FsPath: filepath.FromSlash(us.Path), + ut := url.URL{ + Scheme: FileScheme, + Path: us.Path, + RawPath: filepath.FromSlash(us.Path), } + u = URI(ut.String()) case HTTPScheme, HTTPSScheme: - u = &URI{ - Scheme: us.Scheme, - Authority: us.Host, - Path: us.Path, - Query: us.Query().Encode(), - Fragment: us.Fragment, + ut := url.URL{ + Scheme: us.Scheme, + Host: us.Host, + Path: us.Path, + RawQuery: us.Query().Encode(), + Fragment: us.Fragment, } + u = URI(ut.String()) + default: - return nil, xerrors.New("unknown scheme") + return u, xerrors.New("unknown scheme") } - return u, nil -} - -// File parses and creates a new URI filesystem path from path. -func File(path string) *URI { - return &URI{ - Scheme: FileScheme, - Path: path, - FsPath: filepath.FromSlash(path), - } + return } // From returns the new URI from args. -func From(scheme, authority, path, query, fragment string) (u *URI) { +func From(scheme, authority, path, query, fragment string) URI { switch scheme { case FileScheme: - u = &URI{ - Scheme: FileScheme, - Path: path, - FsPath: filepath.FromSlash(path), + u := url.URL{ + Scheme: FileScheme, + Path: path, + RawPath: filepath.FromSlash(path), } + return URI(u.String()) case HTTPScheme, HTTPSScheme: - u = &URI{ - Scheme: scheme, - Authority: authority, - Path: path, - Query: url.QueryEscape(query), - Fragment: fragment, + u := url.URL{ + Scheme: scheme, + Host: authority, + Path: path, + RawQuery: url.QueryEscape(query), + Fragment: fragment, } - } + return URI(u.String()) - return u + default: + panic(fmt.Sprintf("unknown scheme: %s", scheme)) + } +} + +// 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 ":". +func isWindowsDrivePath(path string) bool { + if len(path) < 4 { + return false + } + return unicode.IsLetter(rune(path[0])) && path[1] == ':' +} + +// isWindowsDriveURI 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 https://golang.org/issue/6027). We check if the URI path has +// a drive prefix (e.g. "/C:"). If so, we trim the leading "/". +func isWindowsDriveURI(uri string) bool { + if len(uri) < 4 { + return false + } + return uri[0] == '/' && unicode.IsLetter(rune(uri[1])) && uri[2] == ':' } diff --git a/uri_test.go b/uri_test.go index e39a7e0..dbd2110 100644 --- a/uri_test.go +++ b/uri_test.go @@ -2,61 +2,34 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package uri_test +package uri import ( - "path/filepath" "testing" "github.com/google/go-cmp/cmp" - - "github.com/go-language-server/uri" ) func TestParse(t *testing.T) { tests := []struct { - name string - s string - want *uri.URI - wantErr bool + name string + s string + want URI }{ { name: "ValidFileScheme", s: "file://code.visualstudio.com/docs/extensions/overview.md", - want: &uri.URI{ - Scheme: uri.FileScheme, - Path: "/docs/extensions/overview.md", - FsPath: filepath.FromSlash("/docs/extensions/overview.md"), - }, - wantErr: false, + want: URI(FileScheme + hierPart + "/docs/extensions/overview.md"), }, { name: "ValidHTTPScheme", s: "http://code.visualstudio.com/docs/extensions/overview#frag", - want: &uri.URI{ - Scheme: uri.HTTPScheme, - Authority: "code.visualstudio.com", - Path: "/docs/extensions/overview", - Fragment: "frag", - }, - wantErr: false, + want: URI(HTTPScheme + hierPart + "code.visualstudio.com/docs/extensions/overview#frag"), }, { name: "ValidHTTPSScheme", s: "https://code.visualstudio.com/docs/extensions/overview#frag", - want: &uri.URI{ - Scheme: uri.HTTPSScheme, - Authority: "code.visualstudio.com", - Path: "/docs/extensions/overview", - Fragment: "frag", - }, - wantErr: false, - }, - { - name: "Invalid", - s: "foo://user@example.com:8042/over/there?name=ferret#nose", - want: new(uri.URI), - wantErr: true, + want: URI(HTTPSScheme + hierPart + "code.visualstudio.com/docs/extensions/overview#frag"), }, } for _, tt := range tests { @@ -64,12 +37,13 @@ func TestParse(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := uri.Parse(tt.s) - if (err != nil) != tt.wantErr { - t.Errorf("wantErr: %t, err: %#v", tt.wantErr, err) + got, err := Parse(tt.s) + if err != nil { + t.Error(err) return } - if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(*tt.want)); (diff != "") != tt.wantErr { + + if diff := cmp.Diff(got, tt.want); diff != "" { t.Errorf("%s: (-got, +want)\n%s", tt.name, diff) } }) @@ -80,23 +54,19 @@ func TestFile(t *testing.T) { tests := []struct { name string path string - want *uri.URI + want URI wantErr bool }{ { - name: "ValidFileScheme", - path: "/users/me/c#-projects/", - want: &uri.URI{ - Scheme: uri.FileScheme, - Path: "/users/me/c#-projects/", - FsPath: filepath.FromSlash("/users/me/c#-projects/"), - }, + name: "ValidFileScheme", + path: "/users/me/c#-projects/", + want: URI(FileScheme + hierPart + "/users/me/c%23-projects"), wantErr: false, }, { name: "Invalid", path: "users-me-c#-projects", - want: new(uri.URI), + want: URI(""), wantErr: true, }, } @@ -105,7 +75,7 @@ func TestFile(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if diff := cmp.Diff(uri.File(tt.path), tt.want, cmp.AllowUnexported(*tt.want)); (diff != "") != tt.wantErr { + if diff := cmp.Diff(File(tt.path), tt.want); (diff != "") != tt.wantErr { t.Errorf("%s: (-got, +want)\n%s", tt.name, diff) } }) @@ -123,7 +93,7 @@ func TestFrom(t *testing.T) { tests := []struct { name string args args - want *uri.URI + want URI }{ { name: "ValidFileScheme", @@ -134,11 +104,7 @@ func TestFrom(t *testing.T) { query: "name=ferret", fragment: "nose", }, - want: &uri.URI{ - Scheme: uri.FileScheme, - Path: "/over/there", - FsPath: filepath.FromSlash("/over/there"), - }, + want: URI(FileScheme + hierPart + "/over/there"), }, { name: "ValidHTTPScheme", @@ -149,13 +115,7 @@ func TestFrom(t *testing.T) { query: "name=ferret", fragment: "nose", }, - want: &uri.URI{ - Scheme: uri.HTTPScheme, - Authority: "example.com:8042", - Path: "/over/there", - Query: "name%3Dferret", - Fragment: "nose", - }, + want: URI(HTTPScheme + hierPart + "example.com:8042/over/there?name%3Dferret#nose"), }, { name: "ValidHTTPSScheme", @@ -166,13 +126,7 @@ func TestFrom(t *testing.T) { query: "name=ferret", fragment: "nose", }, - want: &uri.URI{ - Scheme: uri.HTTPSScheme, - Authority: "example.com:8042", - Path: "/over/there", - Query: "name%3Dferret", - Fragment: "nose", - }, + want: URI(HTTPSScheme + hierPart + "example.com:8042/over/there?name%3Dferret#nose"), }, } for _, tt := range tests { @@ -180,81 +134,9 @@ func TestFrom(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if diff := cmp.Diff(uri.From(tt.args.scheme, tt.args.authority, tt.args.path, tt.args.query, tt.args.fragment), tt.want, cmp.AllowUnexported(*tt.want)); diff != "" { + if diff := cmp.Diff(From(tt.args.scheme, tt.args.authority, tt.args.path, tt.args.query, tt.args.fragment), tt.want); diff != "" { t.Errorf("%s: (-got, +want)\n%s", tt.name, diff) } }) } } - -func TestURI_String(t *testing.T) { - type fields struct { - Scheme string - Authority string - Path string - FsPath string - Query string - Fragment string - } - tests := []struct { - name string - fields fields - want string - }{ - { - name: "ValidFileScheme", - fields: fields{ - Scheme: string(uri.FileScheme), - Path: "docs/extensions/overview.md", - FsPath: filepath.FromSlash("docs/extensions/overview.md"), - }, - want: "file://docs/extensions/overview.md", - }, - { - name: "ValidHTTPScheme", - fields: fields{ - Scheme: string(uri.HTTPScheme), - Authority: "code.visualstudio.com", - Path: "/docs/extensions/overview", - FsPath: filepath.FromSlash("/docs/extensions/overview"), - Query: "test", - Fragment: "frag", - }, - want: "http://code.visualstudio.com/docs/extensions/overview?test#frag", - }, - { - name: "ValidHTTPSScheme", - fields: fields{ - Scheme: string(uri.HTTPSScheme), - Authority: "code.visualstudio.com", - Path: "/docs/extensions/overview", - FsPath: filepath.FromSlash("/docs/extensions/overview"), - Query: "test", - Fragment: "frag", - }, - want: "https://code.visualstudio.com/docs/extensions/overview?test#frag", - }, - } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - u := &uri.URI{ - Authority: tt.fields.Authority, - Fragment: tt.fields.Fragment, - FsPath: tt.fields.FsPath, - Path: tt.fields.Path, - Query: tt.fields.Query, - Scheme: tt.fields.Scheme, - } - - if got := u.String(); !cmp.Equal(got, tt.want) { - t.Errorf("URI.String() = %v, want %v", got, tt.want) - } - if got2 := u.String(); !cmp.Equal(got2, tt.want) { // cache with u.formatted - t.Errorf("URI.String() = %v, want %v", got2, tt.want) - } - }) - } -}