Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions private/buf/buflsp/definition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package buflsp_test

import (
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -206,3 +207,47 @@ func TestDefinition(t *testing.T) {
})
}
}

// TestDefinitionURLEncoding verifies that file paths with special characters
// like '@' are properly URL-encoded in the URI responses.
func TestDefinitionURLEncoding(t *testing.T) {
t.Parallel()

ctx := t.Context()

// Use a file from a directory with '@' in the path
testProtoPath, err := filepath.Abs("testdata/uri@encode/test.proto")
require.NoError(t, err)

clientJSONConn, testURI := setupLSPServer(t, testProtoPath)

// Note: The client may send URIs with unencoded @ symbols, but the LSP
// server normalizes them internally to ensure consistency

// Test definition lookup for a type reference within the same file
var locations []protocol.Location
_, defErr := clientJSONConn.Call(ctx, protocol.MethodTextDocumentDefinition, protocol.DefinitionParams{
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: testURI,
},
Position: protocol.Position{
Line: 13, // Line with "Status status = 3;" (0-indexed, line 14 in file)
Character: 2, // On "Status" type
},
},
}, &locations)
require.NoError(t, defErr)

require.Len(t, locations, 1, "expected exactly one definition location")
location := locations[0]

// Construct the expected URI with @ encoded as %40
// Use uri.File() to get the correct URI format for the platform (e.g., file:/// on Windows)
expectedURI := protocol.URI(strings.ReplaceAll(string(uri.File(testProtoPath)), "@", "%40"))

assert.Equal(t, expectedURI, location.URI, "returned URI should have @ encoded as %40")

// Verify it points to the correct location in the file
assert.Equal(t, uint32(17), location.Range.Start.Line, "should point to Status enum definition (0-indexed, line 18 in file)")
}
9 changes: 5 additions & 4 deletions private/buf/buflsp/file_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,26 @@ func newFileManager(lsp *lsp) *fileManager {
//
// This will increment the file's refcount.
func (fm *fileManager) Track(uri protocol.URI) *file {
file, found := fm.uriToFile.Insert(uri)
normalizedURI := normalizeURI(uri)
file, found := fm.uriToFile.Insert(normalizedURI)
if !found {
file.lsp = fm.lsp
file.uri = uri
file.uri = normalizedURI
}
return file
}

// Get finds a file with the given URI, or returns nil.
func (fm *fileManager) Get(uri protocol.URI) *file {
return fm.uriToFile.Get(uri)
return fm.uriToFile.Get(normalizeURI(uri))
}

// Close marks a file as closed.
//
// This will not necessarily evict the file, since there may be more than one user
// for this file.
func (fm *fileManager) Close(ctx context.Context, uri protocol.URI) {
if deleted := fm.uriToFile.Delete(uri); deleted != nil {
if deleted := fm.uriToFile.Delete(normalizeURI(uri)); deleted != nil {
deleted.Reset(ctx)
}
}
3 changes: 3 additions & 0 deletions private/buf/buflsp/testdata/uri@encode/buf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
version: v2
modules:
- path: .
45 changes: 45 additions & 0 deletions private/buf/buflsp/testdata/uri@encode/test.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
syntax = "proto3";

package uri.encode.v1;

// User represents a user in the system.
message User {
// The unique identifier for the user.
string id = 1;

// The user's email address.
string email = 2;

// The user's current status.
Status status = 3;
}

// Status represents the current state of a user.
enum Status {
// The status is not specified.
STATUS_UNSPECIFIED = 0;

// The user is active.
STATUS_ACTIVE = 1;

// The user is inactive.
STATUS_INACTIVE = 2;
}

// UserService provides operations for managing users.
service UserService {
// GetUser retrieves a user by their ID.
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

// GetUserRequest is the request message for GetUser.
message GetUserRequest {
// The ID of the user to retrieve.
string user_id = 1;
}

// GetUserResponse is the response message for GetUser.
message GetUserResponse {
// The retrieved user.
User user = 1;
}
11 changes: 11 additions & 0 deletions private/buf/buflsp/testdata/uri@encode/types.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
syntax = "proto3";

package uri.encode.v1;

// Address represents a physical address.
message Address {
string street = 1;
string city = 2;
string state = 3;
string zip_code = 4;
}
40 changes: 40 additions & 0 deletions private/buf/buflsp/uri.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2020-2025 Buf Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package buflsp

import (
"strings"

"go.lsp.dev/protocol"
"go.lsp.dev/uri"
)

// normalizeURI ensures that URIs are properly percent-encoded for LSP compatibility.
//
// The go.lsp.dev/uri package (which uses Go's net/url) follows RFC 3986 strictly and
// allows '@' unencoded in path components. However, VS Code's LSP client uses the
// microsoft/vscode-uri package which encodes '@' as '%40' everywhere to avoid ambiguity
// with the authority component separator (user@host).
//
// When URIs don't match exactly, LSP operations like go-to-definition fail because
// the client's URI (with %40) doesn't match the server's URI (with @).
func normalizeURI(u protocol.URI) protocol.URI {
return protocol.URI(strings.ReplaceAll(string(u), "@", "%40"))
}

// filePathToURI converts a file path to a properly encoded URI.
func filePathToURI(path string) protocol.URI {
return normalizeURI(uri.File(path))
}
3 changes: 1 addition & 2 deletions private/buf/buflsp/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import (
"github.com/bufbuild/buf/private/pkg/normalpath"
"github.com/bufbuild/buf/private/pkg/storage"
"go.lsp.dev/protocol"
"go.lsp.dev/uri"
)

// errUnresolvableWorkspace is an unsupported workspace error.
Expand Down Expand Up @@ -248,7 +247,7 @@ func (w *workspace) indexFiles(ctx context.Context) {
for fileInfo := range w.fileInfos(ctx) {
file, ok := previous[fileInfo.Path()]
if !ok {
fileURI := uri.File(fileInfo.LocalPath())
fileURI := filePathToURI(fileInfo.LocalPath())
file = w.lsp.fileManager.Track(fileURI)
w.lsp.logger.Debug("workspace: index track file", slog.String("path", file.uri.Filename()))
}
Expand Down