diff --git a/lsp/impl_test.go b/lsp/impl_test.go index 5de4531..e117d5a 100644 --- a/lsp/impl_test.go +++ b/lsp/impl_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "testing/synctest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -469,3 +470,50 @@ func symbolNames(syms protocol.SymbolInformationSlice) []string { return names } + +// Test_InitializeDefersTheWorkspaceWalk pins the startup flow: initialize +// returns without touching the workspace, and the walk runs once on the +// Initialized notification, registering every thrift file under the +// workspace folder. +func Test_InitializeDefersTheWorkspaceWalk(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.thrift"), []byte("struct FromA {}"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "nested"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "nested", "b.thrift"), []byte("struct FromB {}"), 0o644)) + + srv := NewServer(cache.New(nil), nil, formatter.Options{}) + + _, err := srv.Initialize(t.Context(), &protocol.InitializeParams{ + WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{ + WorkspaceFolders: protocol.NewNullable([]protocol.WorkspaceFolder{{URI: uri.File(dir)}}), + }, + }) + require.NoError(t, err) + + // The walk is deferred: nothing is known yet, and no view exists. + assert.Empty(t, srv.session.Views()) + + require.NoError(t, srv.Initialized(t.Context(), &protocol.InitializedParams{})) + + synctest.Wait() + + // The walk registered the folder as a view and marked both files + // known — including the nested one. + views := srv.session.Views() + require.Len(t, views, 1) + assert.Equal(t, uri.File(dir), views[0].Folder()) + + known := views[0].KnownFiles() + assert.Contains(t, known, uri.File(filepath.Join(dir, "a.thrift"))) + assert.Contains(t, known, uri.File(filepath.Join(dir, "nested", "b.thrift"))) + + // Workspace symbols resolve from the walked files. + files, err := srv.Symbols(t.Context(), &protocol.WorkspaceSymbolParams{Query: ""}) + require.NoError(t, err) + + syms, ok := files.(protocol.SymbolInformationSlice) + require.True(t, ok) + assert.Equal(t, []string{"FromA", "FromB"}, symbolNames(syms)) + }) +} diff --git a/lsp/initialize.go b/lsp/initialize.go index 41bc5ec..ef93fc8 100644 --- a/lsp/initialize.go +++ b/lsp/initialize.go @@ -42,19 +42,20 @@ func (s *Server) initialize(ctx context.Context, params *protocol.InitializePara slog.Debug("initialized folders", "folders", folders) - if len(folders) > 0 { - s.session.Initialize(func() { - for i := range folders { - s.walkFoldersThriftFile(folders[i]) - } - }) - } + // The walk happens on the Initialized notification; the session's + // once-guard keeps it from running twice. + s.folders = folders return initializeResult(), nil } func (s *Server) walkFoldersThriftFile(folder uri.URI) { slog.Debug("walk dir", "folder", folder.Path()) + + // The view is the folder itself, so files in nested directories + // resolve to it via ContainsFile. + s.session.AddView(folder) + // WalkDir walk files with lexical order _ = filepath.WalkDir(folder.Path(), func(path string, d fs.DirEntry, err error) error { slog.Debug("walking", "path", path) diff --git a/lsp/server.go b/lsp/server.go index cc5e350..4927f60 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -5,6 +5,7 @@ import ( "log/slog" "go.lsp.dev/protocol" + "go.lsp.dev/uri" "github.com/karitham/thrift-ls/formatter" "github.com/karitham/thrift-ls/lsp/cache" @@ -17,6 +18,11 @@ type Server struct { client protocol.Client formatOpts formatter.Options + + // folders are the workspace folders from the initialize request; the + // walk starts on the Initialized notification so the initialize + // handshake never blocks on parsing the workspace. + folders []uri.URI } func NewServer(c *cache.Cache, client protocol.Client, formatOpts formatter.Options) *Server { @@ -36,6 +42,17 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara } func (s *Server) Initialized(ctx context.Context, params *protocol.InitializedParams) (err error) { + // The workspace walk parses every thrift file to warm the cache; it + // runs once, off the request path, so the initialize handshake and + // early requests never block on it. + s.session.Initialize(func() { + go func() { + for _, folder := range s.folders { + s.walkFoldersThriftFile(folder) + } + }() + }) + return nil } diff --git a/lsp/symbols/workspace.go b/lsp/symbols/workspace.go index d95ad17..5410e2a 100644 --- a/lsp/symbols/workspace.go +++ b/lsp/symbols/workspace.go @@ -48,27 +48,33 @@ func WorkspaceSymbols(ctx context.Context, session *cache.Session, query string, } // documentSymbolsFlat returns the document symbols of a file flattened -// into workspace symbols; the location points at the symbol's name. +// into workspace symbols: each child carries its parent's name as the +// container, and the location points at the symbol's name. func documentSymbolsFlat(ctx context.Context, ss *cache.Snapshot, file uri.URI) []protocol.SymbolInformation { syms := make([]protocol.SymbolInformation, 0, 16) for _, sym := range DocumentSymbols(ctx, ss, file) { - flattenSymbol(sym, file, &syms) + flattenSymbol(sym, file, "", &syms) } return syms } -func flattenSymbol(sym *protocol.DocumentSymbol, file uri.URI, out *[]protocol.SymbolInformation) { - *out = append(*out, protocol.SymbolInformation{ +func flattenSymbol(sym *protocol.DocumentSymbol, file uri.URI, container string, out *[]protocol.SymbolInformation) { + info := protocol.SymbolInformation{ BaseSymbolInformation: protocol.BaseSymbolInformation{ Name: sym.Name, Kind: sym.Kind, }, Location: protocol.Location{URI: file, Range: sym.SelectionRange}, - }) + } + if container != "" { + info.ContainerName = new(container) + } + + *out = append(*out, info) for i := range sym.Children { - flattenSymbol(&sym.Children[i], file, out) + flattenSymbol(&sym.Children[i], file, sym.Name, out) } } diff --git a/lsp/symbols/workspace_test.go b/lsp/symbols/workspace_test.go index 848c78a..52870cb 100644 --- a/lsp/symbols/workspace_test.go +++ b/lsp/symbols/workspace_test.go @@ -256,3 +256,56 @@ typedef string PilotName`, }) } } + +// TestWorkspaceSymbolsContainerName pins the container name of nested +// symbols: each member carries its enclosing definition's name. +func TestWorkspaceSymbolsContainerName(t *testing.T) { + dir := writeTree(t, map[string]string{ + "shapes.thrift": `struct MobileSuit { + 1: required string Name, +} + +enum ZeonForces { + ZAKU_I = 1, +} + +service Federation { + void Deploy(1: string suitName), +}`, + }) + + session := cache.NewSession(cache.New(nil)) + openTree(t, session, dir, nil) + + tests := []struct { + name string + container *string + }{ + {"MobileSuit", nil}, + {"Name", new("MobileSuit")}, + {"ZAKU_I", new("ZeonForces")}, + {"Deploy", new("Federation")}, + } + + syms := WorkspaceSymbols(t.Context(), session, "", 0) + byName := make(map[string]protocol.SymbolInformation, len(syms)) + for _, s := range syms { + byName[s.Name] = s + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sym, ok := byName[tt.name] + require.True(t, ok, "symbol %q missing", tt.name) + + if tt.container == nil { + assert.Nil(t, sym.ContainerName) + + return + } + + require.NotNil(t, sym.ContainerName) + assert.Equal(t, *tt.container, *sym.ContainerName) + }) + } +}