diff --git a/private/buf/buflsp/buflsp_test.go b/private/buf/buflsp/buflsp_test.go new file mode 100644 index 0000000000..832b910912 --- /dev/null +++ b/private/buf/buflsp/buflsp_test.go @@ -0,0 +1,182 @@ +// 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_test + +import ( + "context" + "net" + "net/http" + "os" + "path/filepath" + "testing" + + "buf.build/go/app" + "buf.build/go/app/appext" + "github.com/bufbuild/buf/private/buf/bufctl" + "github.com/bufbuild/buf/private/buf/buflsp" + "github.com/bufbuild/buf/private/buf/bufwkt/bufwktstore" + "github.com/bufbuild/buf/private/bufpkg/bufmodule" + "github.com/bufbuild/buf/private/bufpkg/bufparse" + "github.com/bufbuild/buf/private/bufpkg/bufplugin" + "github.com/bufbuild/buf/private/bufpkg/bufpolicy" + "github.com/bufbuild/buf/private/pkg/git" + "github.com/bufbuild/buf/private/pkg/httpauth" + "github.com/bufbuild/buf/private/pkg/slogtestext" + "github.com/bufbuild/buf/private/pkg/storage/storageos" + "github.com/bufbuild/buf/private/pkg/wasm" + "github.com/bufbuild/protocompile/experimental/incremental" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + "go.lsp.dev/uri" +) + +// nopModuleKeyProvider is a no-op implementation of ModuleKeyProvider for testing +type nopModuleKeyProvider struct{} + +func (nopModuleKeyProvider) GetModuleKeysForModuleRefs(context.Context, []bufparse.Ref, bufmodule.DigestType) ([]bufmodule.ModuleKey, error) { + return nil, os.ErrNotExist +} + +// setupLSPServer creates and initializes an LSP server for testing. +// Returns the client JSON-RPC connection and the test file URI. +func setupLSPServer( + t *testing.T, + testProtoPath string, +) (jsonrpc2.Conn, protocol.URI) { + t.Helper() + + ctx := t.Context() + + logger := slogtestext.NewLogger(t) + + appContainer, err := app.NewContainerForOS() + require.NoError(t, err) + + nameContainer, err := appext.NewNameContainer(appContainer, "buf-test") + require.NoError(t, err) + appextContainer := appext.NewContainer(nameContainer, logger) + + graphProvider := bufmodule.NopGraphProvider + moduleDataProvider := bufmodule.NopModuleDataProvider + commitProvider := bufmodule.NopCommitProvider + pluginKeyProvider := bufplugin.NopPluginKeyProvider + pluginDataProvider := bufplugin.NopPluginDataProvider + policyKeyProvider := bufpolicy.NopPolicyKeyProvider + policyDataProvider := bufpolicy.NopPolicyDataProvider + + tmpDir := t.TempDir() + storageBucket, err := storageos.NewProvider().NewReadWriteBucket(tmpDir) + require.NoError(t, err) + + wktStore := bufwktstore.NewStore(logger, storageBucket) + + controller, err := bufctl.NewController( + logger, + appContainer, + graphProvider, + nopModuleKeyProvider{}, + moduleDataProvider, + commitProvider, + pluginKeyProvider, + pluginDataProvider, + policyKeyProvider, + policyDataProvider, + wktStore, + &http.Client{}, + httpauth.NewNopAuthenticator(), + git.ClonerOptions{}, + ) + require.NoError(t, err) + + wktBucket, err := wktStore.GetBucket(ctx) + require.NoError(t, err) + + wasmRuntime, err := wasm.NewRuntime(ctx) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, wasmRuntime.Close(ctx)) + }) + + queryExecutor := incremental.New() + + serverConn, clientConn := net.Pipe() + t.Cleanup(func() { + require.NoError(t, serverConn.Close()) + require.NoError(t, clientConn.Close()) + }) + + stream := jsonrpc2.NewStream(serverConn) + + go func() { + conn, err := buflsp.Serve( + ctx, + wktBucket, + appextContainer, + controller, + wasmRuntime, + stream, + queryExecutor, + ) + if err != nil { + t.Errorf("Failed to start server: %v", err) + return + } + t.Cleanup(func() { + require.NoError(t, conn.Close()) + }) + <-ctx.Done() + }() + + clientStream := jsonrpc2.NewStream(clientConn) + clientJSONConn := jsonrpc2.NewConn(clientStream) + clientJSONConn.Go(ctx, jsonrpc2.AsyncHandler(func(_ context.Context, reply jsonrpc2.Replier, _ jsonrpc2.Request) error { + return reply(ctx, nil, nil) + })) + t.Cleanup(func() { + require.NoError(t, clientJSONConn.Close()) + }) + + testWorkspaceDir := filepath.Dir(testProtoPath) + testURI := uri.New(testProtoPath) + var initResult protocol.InitializeResult + _, initErr := clientJSONConn.Call(ctx, protocol.MethodInitialize, &protocol.InitializeParams{ + RootURI: uri.New(testWorkspaceDir), + Capabilities: protocol.ClientCapabilities{ + TextDocument: &protocol.TextDocumentClientCapabilities{}, + }, + }, &initResult) + require.NoError(t, initErr) + assert.True(t, initResult.Capabilities.HoverProvider != nil) + + err = clientJSONConn.Notify(ctx, protocol.MethodInitialized, &protocol.InitializedParams{}) + require.NoError(t, err) + + testProtoContent, err := os.ReadFile(testProtoPath) + require.NoError(t, err) + + err = clientJSONConn.Notify(ctx, protocol.MethodTextDocumentDidOpen, &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: testURI, + LanguageID: "protobuf", + Version: 1, + Text: string(testProtoContent), + }, + }) + require.NoError(t, err) + + return clientJSONConn, testURI +} diff --git a/private/buf/buflsp/hover_test.go b/private/buf/buflsp/hover_test.go new file mode 100644 index 0000000000..41ce906289 --- /dev/null +++ b/private/buf/buflsp/hover_test.go @@ -0,0 +1,159 @@ +// 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_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lsp.dev/protocol" +) + +func TestHover(t *testing.T) { + t.Parallel() + + // if runtime.GOOS == "windows" { + // t.Skip("Skipping on Windows") + // } + + ctx := t.Context() + + testProtoPath, err := filepath.Abs("testdata/hover/test.proto") + require.NoError(t, err) + + clientJSONConn, testURI := setupLSPServer(t, testProtoPath) + + tests := []struct { + name string + line uint32 + character uint32 + expectedContains string + expectNoHover bool + }{ + { + name: "hover_on_user_message", + line: 7, // Line with "message User {" + character: 8, // On the word "User" + expectedContains: "User represents a user in the system", + }, + { + name: "hover_on_id_field", + line: 9, // Line with "string id = 1;" + character: 10, // On the word "id" + expectedContains: "The unique identifier for the user", + }, + { + name: "hover_on_status_enum", + line: 19, // Line with "enum Status {" + character: 5, // On the word "Status" + expectedContains: "Status represents the current state of a user", + }, + { + name: "hover_on_status_active", + line: 24, // Line with "STATUS_ACTIVE = 1;" + character: 2, // On "STATUS_ACTIVE" + expectedContains: "The user is active", + }, + { + name: "hover_on_user_service", + line: 31, // Line with "service UserService {" + character: 8, // On "UserService" + expectedContains: "UserService provides operations for managing users", + }, + { + name: "hover_on_get_user_rpc", + line: 33, // Line with "rpc GetUser" + character: 6, // On "GetUser" + expectedContains: "GetUser retrieves a user by their ID", + }, + { + name: "hover_on_status_type_reference", + line: 15, // Line with "Status status = 3;" + character: 2, // On "Status" type + expectedContains: "Status represents the current state of a user", + }, + { + name: "hover_on_user_type_reference", + line: 45, // Line with "User user = 1;" + character: 2, // On "User" type + expectedContains: "User represents a user in the system", + }, + { + name: "hover_on_rpc_request_type", + line: 33, // Line with "rpc GetUser(GetUserRequest)" + character: 14, // On "GetUserRequest" + expectedContains: "GetUserRequest is the request message for GetUser", + }, + { + name: "hover_on_rpc_response_type", + line: 33, // Line with "returns (GetUserResponse)" + character: 39, // On "GetUserResponse" + expectedContains: "GetUserResponse is the response message for GetUser", + }, + { + name: "hover_on_syntax_keyword", + line: 0, // Line with "syntax = "proto3";" + character: 0, // On "syntax" + expectNoHover: true, + }, + { + name: "hover_on_proto3_string", + line: 0, // Line with "syntax = "proto3";" + character: 10, // On "proto3" + expectNoHover: true, + }, + { + name: "hover_on_package_keyword", + line: 2, // Line with "package example.v1;" + character: 0, // On "package" + expectNoHover: true, + }, + { + name: "hover_on_package_name", + line: 2, // Line with "package example.v1;" + character: 8, // On "example" + expectNoHover: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var hover *protocol.Hover + _, hoverErr := clientJSONConn.Call(ctx, protocol.MethodTextDocumentHover, protocol.HoverParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: testURI, + }, + Position: protocol.Position{ + Line: tt.line, + Character: tt.character, + }, + }, + }, &hover) + require.NoError(t, hoverErr) + + if tt.expectNoHover { + assert.Nil(t, hover, "expected no hover information") + } else if tt.expectedContains != "" { + require.NotNil(t, hover, "expected hover to be non-nil") + assert.Equal(t, protocol.Markdown, hover.Contents.Kind) + assert.Contains(t, hover.Contents.Value, tt.expectedContains) + } + }) + } +} diff --git a/private/buf/buflsp/testdata/hover/buf.yaml b/private/buf/buflsp/testdata/hover/buf.yaml new file mode 100644 index 0000000000..f74da98a3c --- /dev/null +++ b/private/buf/buflsp/testdata/hover/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: . +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/private/buf/buflsp/testdata/hover/test.proto b/private/buf/buflsp/testdata/hover/test.proto new file mode 100644 index 0000000000..29eb1fde6f --- /dev/null +++ b/private/buf/buflsp/testdata/hover/test.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +package example.v1; + +// User represents a user in the system. +// This is a multi-line comment that provides +// detailed information about the User message. +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; +}