From c3da8e56eaacf3ad79000f872c95283bc2180c5e Mon Sep 17 00:00:00 2001 From: karitham Date: Fri, 7 Aug 2026 23:34:14 +0200 Subject: [PATCH] lsp: consolidate features into a source package, gopls-style One package for all feature logic, uniform (ctx, snapshot, file) APIs, and the protocol layer reduced to dumb adapters. Packages merged into lsp/source (codejump, completion, diagnostic, semantic, symbols, folding, links): - duplicated range helpers (nodeRange, tokenRange) and test fixtures (buildSnapshot) deduplicated; diagnostic's Interface renamed Checker; semantic's classify renamed classifyToken. - WorkspaceSymbols no longer takes the session: it queries one snapshot over a file list, and the server iterates views (folders sorted by URI), mirroring gopls's snapshot-as-currency rule. - the diagnostic registry and completion types lost their package prefixes (source.NewDiagnostic, source.DefaultTokenCompletion). Formatting and the fix-all code action moved into source as FormatDocument, FormatRange, and FormatDocumentAction; the root files are thin withFile wrappers. rangeFormatting no longer swallows mapper errors as nil,nil, and the code action end position uses the mapper's UTF-16-correct GetLSPEndPosition instead of byte counting. The 17 copies of the ViewOf -> Snapshot -> release boilerplate (in three subtly different shapes) collapsed into withSnapshot/withFile helpers; getFileContext (the one manual-release variant) is gone. Dead code deleted: Snapshot.Initialize no-op, Snapshot.ctx, the ResolveIncludeWithText alias, lsputils.GetIncludePath/IncludeURI/ IncludeURIWithPaths, and the whole lsputils package - its survivors moved home: Include.PathText() is a syntax method, and includeNameOf/parseIdent live in source. The workspace walk starts at the end of initialize instead of the Initialized notification: notifications are fire-and-forget, so a client that dropped it never indexed the workspace until the first edit. initialize is a request, the walk is already async, and the once-guard is unchanged. --- lsp/cache/snapshot.go | 14 +- lsp/codeaction.go | 90 +---- lsp/codejump.go | 45 +-- lsp/diagnostic.go | 4 +- lsp/folding.go | 17 +- lsp/format.go | 312 +--------------- lsp/highlight.go | 17 +- lsp/hover.go | 65 ++-- lsp/impl.go | 56 +-- lsp/impl_test.go | 11 +- lsp/initialize.go | 18 +- lsp/links.go | 17 +- lsp/lsputils/utils.go | 146 -------- lsp/lsputils/utils_test.go | 338 ------------------ lsp/rename.go | 31 +- lsp/semantic.go | 27 +- lsp/server.go | 44 ++- lsp/snapshot.go | 40 +++ lsp/{completion => source}/completion_test.go | 2 +- .../utils.go => source/completion_utils.go} | 2 +- lsp/{completion => source}/context.go | 2 +- lsp/{completion => source}/context_test.go | 2 +- .../cross_project_test.go | 2 +- .../cross_reference_test.go | 2 +- lsp/{diagnostic => source}/cycle_detect.go | 5 +- .../cycle_detect_test.go | 124 ++++++- lsp/{codejump => source}/definition.go | 9 +- .../definition_list_test.go | 2 +- lsp/{codejump => source}/definition_test.go | 2 +- lsp/{diagnostic => source}/diagnostic.go | 26 +- lsp/{symbols => source}/document.go | 2 +- lsp/{diagnostic => source}/fieldid_check.go | 2 +- .../fieldid_check_test.go | 2 +- lsp/{folding => source}/folding.go | 2 +- lsp/{folding => source}/folding_test.go | 2 +- lsp/source/format.go | 306 ++++++++++++++++ lsp/{ => source}/format_range_fuzz_test.go | 2 +- lsp/{ => source}/format_range_test.go | 2 +- lsp/{codejump => source}/highlight_test.go | 2 +- lsp/{codejump => source}/hits.go | 2 +- lsp/{codejump => source}/hover.go | 2 +- lsp/{codejump => source}/hover_test.go | 2 +- lsp/{links => source}/links.go | 13 +- lsp/{links => source}/links_test.go | 8 +- lsp/source/name.go | 67 ++++ lsp/source/name_test.go | 161 +++++++++ lsp/{diagnostic => source}/parse.go | 2 +- lsp/{completion => source}/provider.go | 2 +- lsp/{codejump => source}/reference.go | 19 +- lsp/{codejump => source}/reference_test.go | 2 +- lsp/{codejump => source}/rename.go | 7 +- lsp/{codejump => source}/rename_test.go | 2 +- lsp/{semantic => source}/semantic.go | 6 +- .../semantic_analysis.go | 7 +- .../semantic_analysis_test.go | 2 +- .../semantic_based_completion.go | 2 +- .../semantic_completion.go | 2 +- lsp/{semantic => source}/semantic_test.go | 2 +- .../slot_completion_test.go | 2 +- lsp/{codejump => source}/target.go | 2 +- lsp/{codejump => source}/target_test.go | 2 +- .../token_completion.go | 2 +- lsp/{codejump => source}/type_definition.go | 2 +- .../type_definition_test.go | 2 +- lsp/{completion => source}/types.go | 2 +- lsp/{codejump => source}/utils.go | 7 +- lsp/{symbols => source}/workspace.go | 42 +-- lsp/{symbols => source}/workspace_test.go | 36 +- lsp/symbols.go | 26 +- syntax/ast.go | 12 + 70 files changed, 999 insertions(+), 1242 deletions(-) delete mode 100644 lsp/lsputils/utils.go delete mode 100644 lsp/lsputils/utils_test.go create mode 100644 lsp/snapshot.go rename lsp/{completion => source}/completion_test.go (98%) rename lsp/{completion/utils.go => source/completion_utils.go} (98%) rename lsp/{completion => source}/context.go (99%) rename lsp/{completion => source}/context_test.go (99%) rename lsp/{codejump => source}/cross_project_test.go (99%) rename lsp/{codejump => source}/cross_reference_test.go (99%) rename lsp/{diagnostic => source}/cycle_detect.go (94%) rename lsp/{diagnostic => source}/cycle_detect_test.go (57%) rename lsp/{codejump => source}/definition.go (95%) rename lsp/{codejump => source}/definition_list_test.go (99%) rename lsp/{codejump => source}/definition_test.go (99%) rename lsp/{diagnostic => source}/diagnostic.go (78%) rename lsp/{symbols => source}/document.go (99%) rename lsp/{diagnostic => source}/fieldid_check.go (99%) rename lsp/{diagnostic => source}/fieldid_check_test.go (99%) rename lsp/{folding => source}/folding.go (99%) rename lsp/{folding => source}/folding_test.go (99%) create mode 100644 lsp/source/format.go rename lsp/{ => source}/format_range_fuzz_test.go (99%) rename lsp/{ => source}/format_range_test.go (99%) rename lsp/{codejump => source}/highlight_test.go (99%) rename lsp/{codejump => source}/hits.go (95%) rename lsp/{codejump => source}/hover.go (99%) rename lsp/{codejump => source}/hover_test.go (99%) rename lsp/{links => source}/links.go (70%) rename lsp/{links => source}/links_test.go (87%) create mode 100644 lsp/source/name.go create mode 100644 lsp/source/name_test.go rename lsp/{diagnostic => source}/parse.go (98%) rename lsp/{completion => source}/provider.go (99%) rename lsp/{codejump => source}/reference.go (94%) rename lsp/{codejump => source}/reference_test.go (99%) rename lsp/{codejump => source}/rename.go (94%) rename lsp/{codejump => source}/rename_test.go (99%) rename lsp/{semantic => source}/semantic.go (97%) rename lsp/{diagnostic => source}/semantic_analysis.go (97%) rename lsp/{diagnostic => source}/semantic_analysis_test.go (99%) rename lsp/{completion => source}/semantic_based_completion.go (97%) rename lsp/{completion => source}/semantic_completion.go (99%) rename lsp/{semantic => source}/semantic_test.go (99%) rename lsp/{completion => source}/slot_completion_test.go (99%) rename lsp/{codejump => source}/target.go (99%) rename lsp/{codejump => source}/target_test.go (99%) rename lsp/{completion => source}/token_completion.go (99%) rename lsp/{codejump => source}/type_definition.go (99%) rename lsp/{codejump => source}/type_definition_test.go (99%) rename lsp/{completion => source}/types.go (97%) rename lsp/{codejump => source}/utils.go (95%) rename lsp/{symbols => source}/workspace.go (57%) rename lsp/{symbols => source}/workspace_test.go (89%) diff --git a/lsp/cache/snapshot.go b/lsp/cache/snapshot.go index 36c1a6f..20ebe43 100644 --- a/lsp/cache/snapshot.go +++ b/lsp/cache/snapshot.go @@ -12,7 +12,6 @@ import ( "go.lsp.dev/uri" - "github.com/karitham/thrift-ls/lsp/lsputils" "github.com/karitham/thrift-ls/resolver" "github.com/karitham/thrift-ls/syntax" ) @@ -101,12 +100,6 @@ func (r *Resolver) ResolveInclude(cur uri.URI, includePath string) uri.URI { return uri.File(resolvedPath) } -// ResolveIncludeWithText resolves an include path using the raw text from the AST. -// This is more efficient when the include text is already available. -func (r *Resolver) ResolveIncludeWithText(cur uri.URI, includeText string) uri.URI { - return r.ResolveInclude(cur, includeText) -} - // GetIncludePath returns the include path text for a given include name. // Returns empty string if not found. func (r *Resolver) GetIncludePath(ast *syntax.Document, includeName string) string { @@ -115,7 +108,7 @@ func (r *Resolver) GetIncludePath(ast *syntax.Document, includeName string) stri continue } - path := lsputils.IncludePathText(include) + path := include.PathText() name := getIncludeNameFromPath(path) if name == includeName { @@ -150,9 +143,6 @@ type Snapshot struct { view *View - // ctx is used to cancel background job - ctx context.Context - refCount sync.WaitGroup files *FilesMap @@ -168,7 +158,6 @@ func NewSnapshot(view *View, includePaths []string) *Snapshot { id: rand.Int63(), view: view, - ctx: context.Background(), refCount: sync.WaitGroup{}, context: NewContext(), parsedCache: NewParseCaches(), @@ -285,7 +274,6 @@ func (s *Snapshot) clone() (*Snapshot, func()) { snap := &Snapshot{ id: rand.Int63(), view: s.view, - ctx: context.Background(), // TODO(jpf): file change 没有更新,导致读到旧的缓存 files: s.files.Clone(), // files: &FilesMap{ diff --git a/lsp/codeaction.go b/lsp/codeaction.go index ea3080f..090aefc 100644 --- a/lsp/codeaction.go +++ b/lsp/codeaction.go @@ -5,91 +5,23 @@ import ( "go.lsp.dev/protocol" - "go.lsp.dev/uri" - - "github.com/karitham/thrift-ls/formatter" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) -// formatDocumentAction is the source.fixAll code action that formats the -// document, mirroring the formatting request. // codeAction returns the quickfixes for the document: formatting the whole // document when the range covers it, or the range when it is a selection. func (s *Server) codeAction(ctx context.Context, params *protocol.CodeActionParams) ([]protocol.CommandOrCodeAction, error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - fh, err := ss.ReadFile(ctx, file) - if err != nil { - return nil, err - } - - content, err := fh.Content() - if err != nil { - return nil, err - } - - pf, err := ss.Parse(ctx, file) - if err != nil { - return nil, err - } - - if pf.AST() == nil { - return nil, nil - } - - out, err := formatter.Format(pf.AST(), s.formatOpts) - if err != nil { - return nil, err - } - - if string(content) == out { - return nil, nil - } - - // The edit covers the whole document; for a selection the client - // applies the range intersection. - edit := protocol.TextEdit{ - Range: protocol.Range{ - Start: protocol.Position{Line: 0, Character: 0}, - End: endPosition(content), - }, - NewText: out, - } - - action := &protocol.CodeAction{ - Title: "Format document", - Kind: new(protocol.CodeActionKindSourceFixAll), - Edit: &protocol.WorkspaceEdit{ - Changes: map[uri.URI][]protocol.TextEdit{ - file: {edit}, - }, - }, - } - - return []protocol.CommandOrCodeAction{action}, nil -} - -// endPosition returns the position after the last byte of content. -func endPosition(content []byte) protocol.Position { - line, char := uint32(0), uint32(0) - - for _, b := range content { - if b == '\n' { - line++ - char = 0 - - continue + return withFile(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot, fh cache.FileHandle) ([]protocol.CommandOrCodeAction, error) { + action, err := source.FormatDocumentAction(ctx, ss, fh, s.formatOpts) + if err != nil { + return nil, err } - char++ - } + if action == nil { + return nil, nil + } - return protocol.Position{Line: line, Character: char} + return []protocol.CommandOrCodeAction{action}, nil + }) } diff --git a/lsp/codejump.go b/lsp/codejump.go index 6936ac3..a96caea 100644 --- a/lsp/codejump.go +++ b/lsp/codejump.go @@ -5,47 +5,24 @@ import ( "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/codejump" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) definition(ctx context.Context, params *protocol.DefinitionParams) (result []protocol.Location, err error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - return codejump.Definition(ctx, ss, params.TextDocument.URI, params.Position) + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.Location, error) { + return source.Definition(ctx, ss, params.TextDocument.URI, params.Position) + }) } func (s *Server) references(ctx context.Context, params *protocol.ReferenceParams) (result []protocol.Location, err error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - return codejump.Reference(ctx, ss, params.TextDocument.URI, params.Position) + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.Location, error) { + return source.Reference(ctx, ss, params.TextDocument.URI, params.Position) + }) } func (s *Server) typeDefinition(ctx context.Context, params *protocol.TypeDefinitionParams) (result []protocol.Location, err error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - return codejump.TypeDefinition(ctx, ss, params.TextDocument.URI, params.Position) + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.Location, error) { + return source.TypeDefinition(ctx, ss, params.TextDocument.URI, params.Position) + }) } diff --git a/lsp/diagnostic.go b/lsp/diagnostic.go index 816982c..ace3420 100644 --- a/lsp/diagnostic.go +++ b/lsp/diagnostic.go @@ -9,7 +9,7 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/diagnostic" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) diagnostic(ctx context.Context, ss *cache.Snapshot, file uri.URI) error { @@ -20,7 +20,7 @@ func (s *Server) diagnostic(ctx context.Context, ss *cache.Snapshot, file uri.UR slog.Debug("diagnostic called") defer slog.Debug("diagnostic finished") - diag := diagnostic.NewDiagnostic() + diag := source.NewDiagnostic() diagRes, err := diag.Diagnostic(ctx, ss, []uri.URI{file}) if err != nil { diff --git a/lsp/folding.go b/lsp/folding.go index b26946b..d94f2dc 100644 --- a/lsp/folding.go +++ b/lsp/folding.go @@ -5,19 +5,12 @@ import ( "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/folding" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) foldingRanges(ctx context.Context, params *protocol.FoldingRangeParams) ([]protocol.FoldingRange, error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - return folding.Ranges(ctx, ss, file), nil + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.FoldingRange, error) { + return source.Ranges(ctx, ss, params.TextDocument.URI), nil + }) } diff --git a/lsp/format.go b/lsp/format.go index 075b24f..24fb4ab 100644 --- a/lsp/format.go +++ b/lsp/format.go @@ -1,319 +1,31 @@ package lsp import ( - "bytes" "context" "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/formatter" - "github.com/karitham/thrift-ls/lsp/mapper" - "github.com/karitham/thrift-ls/lsp/types" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) formatting(ctx context.Context, params *protocol.DocumentFormattingParams) (result []protocol.TextEdit, err error) { - opts := s.formatOpts - - document := params.TextDocument - fileURI := document.URI - - view, err := s.session.ViewOf(fileURI) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - fh, err := ss.ReadFile(ctx, fileURI) - if err != nil { - return nil, err - } - - bytes, err := fh.Content() - if err != nil { - return nil, err - } - - pf, err := ss.Parse(ctx, fileURI) - if err != nil { - return nil, err - } - - if len(pf.Errors()) > 0 || pf.AST() == nil { - return nil, pf.AggregatedError() - } - - formatted, err := formatter.Format(pf.AST(), opts) - if err != nil { - return nil, err - } - - mp := mapper.NewMapper(fileURI, bytes) - endPos := mp.GetLSPEndPosition() - textEdit := protocol.TextEdit{ - Range: protocol.Range{ - Start: protocol.Position{ - Line: 0, - Character: 0, - }, - End: protocol.Position{ - Line: endPos.Line, - Character: endPos.Character, - }, - }, - NewText: formatted, - } - - result = append(result, textEdit) - - return result, err -} - -// rangeFormatting implements textDocument/rangeFormatting. -// -// The formatter only knows how to print whole documents, so a range is -// formatted by formatting the whole document and diffing it against the -// original at the granularity of blank-line-separated blocks. Blank lines -// are preserved exactly by the formatter, so the blocks align one-to-one; -// every edit is bounded by blank lines or file edges, and any subset -// splices safely. Only the edits overlapping the selection are returned. -func (s *Server) rangeFormatting(ctx context.Context, params *protocol.DocumentRangeFormattingParams) (result []protocol.TextEdit, err error) { - fileURI := params.TextDocument.URI - - view, err := s.session.ViewOf(fileURI) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - fh, err := ss.ReadFile(ctx, fileURI) - if err != nil { - return nil, err - } - - content, err := fh.Content() - if err != nil { - return nil, err - } - - pf, err := ss.Parse(ctx, fileURI) - if err != nil { - return nil, err - } - - if len(pf.Errors()) > 0 || pf.AST() == nil { - return nil, pf.AggregatedError() - } - - formatted, err := formatter.Format(pf.AST(), s.formatOpts) - if err != nil { - return nil, err - } - - if string(content) == formatted { - return nil, nil - } - - mp := mapper.NewMapper(fileURI, content) - - start, err := mp.LSPPosToParserPosition(lspPosition(params.Range.Start)) - if err != nil { - return nil, nil - } - - end, err := mp.LSPPosToParserPosition(lspPosition(params.Range.End)) - if err != nil { - return nil, nil - } - - // The selection expanded to whole lines. - selStart := lineStart(content, start.Offset) - selEnd := nextLineStart(content, lineStart(content, end.Offset)) - - for _, be := range blockDiff(content, []byte(formatted)) { - // Overlap test on byte offsets; adjacent edits touch at most. - if be.end <= selStart || be.start >= selEnd { - continue - } - - startPos, err := mp.OffsetToLSPPosition(be.start) + return withFile(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot, fh cache.FileHandle) ([]protocol.TextEdit, error) { + edit, err := source.FormatDocument(ctx, ss, fh, s.formatOpts) if err != nil { - return nil, nil + return nil, err } - endPos, err := mp.OffsetToLSPPosition(be.end) - if err != nil { + if edit == nil { return nil, nil } - result = append(result, protocol.TextEdit{ - Range: protocol.Range{ - Start: protocolPosition(startPos), - End: protocolPosition(endPos), - }, - NewText: be.text, - }) - } - - return result, nil -} - -// lspPosition converts a protocol position to the internal position type. -func lspPosition(p protocol.Position) types.Position { - return types.Position{ - Line: uint32(p.Line), - Character: uint32(p.Character), - } -} - -// protocolPosition converts an internal position to a protocol position. -func protocolPosition(p types.Position) protocol.Position { - return protocol.Position{ - Line: p.Line, - Character: p.Character, - } -} - -// blockEdit replaces content[start:end] with text. Every block edit is -// bounded by blank lines or file edges, so it splices safely. -type blockEdit struct { - start, end int - text string + return []protocol.TextEdit{*edit}, nil + }) } -// blockDiff returns the edits turning old into new, one per changed block -// of non-blank lines, plus the leading and trailing blank regions when -// they differ. Blank lines are preserved exactly by the formatter, so old -// and new split into the same number of aligned blocks. -// blockDiff returns the edits turning old into new, one per changed -// segment: the blank-line runs and the blocks of non-blank lines between -// them. Blank lines are preserved structurally by the formatter (their -// whitespace may be trimmed), so old and new split into the same number of -// aligned blocks; every edit is bounded by blank lines or file edges, so -// any subset splices safely. -func blockDiff(old, new []byte) []blockEdit { - // CRLF input normalizes to LF everywhere, blank lines included: the - // block alignment no longer holds, so a single whole-document edit is - // the only safe splice. - if bytes.Contains(old, []byte("\r\n")) { - if string(old) == string(new) { - return nil - } - - return []blockEdit{{0, len(old), string(new)}} - } - - oldBlocks := blocks(old) - newBlocks := blocks(new) - - // No non-blank lines at all, or an unaligned block structure: fall - // back to a single whole-document edit. - if len(oldBlocks) == 0 || len(oldBlocks) != len(newBlocks) { - if string(old) == string(new) { - return nil - } - - return []blockEdit{{0, len(old), string(new)}} - } - - var edits []blockEdit - - prevOld, prevNew := 0, 0 - for i := range oldBlocks { - // The segment before the block: leading blanks, or the blank run - // between two blocks. - if !bytes.Equal(old[prevOld:oldBlocks[i].start], new[prevNew:newBlocks[i].start]) { - edits = append(edits, blockEdit{ - start: prevOld, - end: oldBlocks[i].start, - text: string(new[prevNew:newBlocks[i].start]), - }) - } - - // The block itself. - if !bytes.Equal(old[oldBlocks[i].start:oldBlocks[i].end], new[newBlocks[i].start:newBlocks[i].end]) { - edits = append(edits, blockEdit{ - start: oldBlocks[i].start, - end: oldBlocks[i].end, - text: string(new[newBlocks[i].start:newBlocks[i].end]), - }) - } - - prevOld, prevNew = oldBlocks[i].end, newBlocks[i].end - } - - // The trailing segment. - if !bytes.Equal(old[prevOld:], new[prevNew:]) { - edits = append(edits, blockEdit{prevOld, len(old), string(new[prevNew:])}) - } - - return edits -} - -// block is a maximal run of non-blank lines: the byte range from the first -// line's start to just after the last line's newline, with the exact text. -type block struct { - start, end int - text string -} - -// blocks splits content into runs of non-blank lines. -func blocks(content []byte) []block { - var out []block - - i := 0 - for i < len(content) { - // Skip blank lines. - for i < len(content) && len(bytes.TrimSpace(content[i:lineEnd(content, i)])) == 0 { - i = nextLineStart(content, i) - } - - if i >= len(content) { - break - } - - start := i - for i < len(content) && len(bytes.TrimSpace(content[i:lineEnd(content, i)])) > 0 { - i = nextLineStart(content, i) - } - - out = append(out, block{start: start, end: i, text: string(content[start:i])}) - } - - return out -} - -// nextLineStart returns the offset just after the newline ending the line -// containing offset, or len(content) for the last line. -func nextLineStart(content []byte, offset int) int { - if i := bytes.IndexByte(content[offset:], '\n'); i != -1 { - return offset + i + 1 - } - - return len(content) -} - -// lineStart returns the byte offset of the start of the line containing offset. -func lineStart(content []byte, offset int) int { - if i := bytes.LastIndexByte(content[:offset], '\n'); i != -1 { - return i + 1 - } - - return 0 -} - -// lineEnd returns the byte offset of the newline ending the line containing -// offset, or len(content) for the last line. -func lineEnd(content []byte, offset int) int { - if i := bytes.IndexByte(content[offset:], '\n'); i != -1 { - return offset + i - } - - return len(content) +func (s *Server) rangeFormatting(ctx context.Context, params *protocol.DocumentRangeFormattingParams) (result []protocol.TextEdit, err error) { + return withFile(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot, fh cache.FileHandle) ([]protocol.TextEdit, error) { + return source.FormatRange(ctx, ss, fh, s.formatOpts, params.Range) + }) } - -// blankLineBefore reports whether the line before the line starting at offset -// is blank (whitespace only) or offset is at the start of the file. diff --git a/lsp/highlight.go b/lsp/highlight.go index f8e022d..f0dd3ca 100644 --- a/lsp/highlight.go +++ b/lsp/highlight.go @@ -5,19 +5,12 @@ import ( "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/codejump" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) documentHighlight(ctx context.Context, params *protocol.DocumentHighlightParams) ([]protocol.DocumentHighlight, error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - return codejump.Highlight(ctx, ss, file, params.Position) + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.DocumentHighlight, error) { + return source.Highlight(ctx, ss, params.TextDocument.URI, params.Position) + }) } diff --git a/lsp/hover.go b/lsp/hover.go index c3bc466..b78078e 100644 --- a/lsp/hover.go +++ b/lsp/hover.go @@ -6,43 +6,36 @@ import ( "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/codejump" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) hover(ctx context.Context, params *protocol.HoverParams) (*protocol.Hover, error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - content, err := codejump.Hover(ctx, ss, params.TextDocument.URI, params.Position) - if err != nil { - return nil, err - } - - if content == "" { - return nil, nil - } - - markdown_prefix := "```thrift\n" - if strings.HasPrefix(content, "\n") { - markdown_prefix = "```thrift" - } - - markdown_suffix := "\n```" - if strings.HasSuffix(content, "\n") { - markdown_suffix = "```" - } - - return &protocol.Hover{ - Contents: &protocol.MarkupContent{ - Kind: protocol.MarkupKindMarkdown, - Value: markdown_prefix + content + markdown_suffix, - }, - }, nil + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.Hover, error) { + content, err := source.Hover(ctx, ss, params.TextDocument.URI, params.Position) + if err != nil { + return nil, err + } + + if content == "" { + return nil, nil + } + + markdownPrefix := "```thrift\n" + if strings.HasPrefix(content, "\n") { + markdownPrefix = "```thrift" + } + + markdownSuffix := "\n```" + if strings.HasSuffix(content, "\n") { + markdownSuffix = "```" + } + + return &protocol.Hover{ + Contents: &protocol.MarkupContent{ + Kind: protocol.MarkupKindMarkdown, + Value: markdownPrefix + content + markdownSuffix, + }, + }, nil + }) } diff --git a/lsp/impl.go b/lsp/impl.go index 3f6dbb8..b57e7b5 100644 --- a/lsp/impl.go +++ b/lsp/impl.go @@ -11,7 +11,7 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/completion" + "github.com/karitham/thrift-ls/lsp/source" "github.com/karitham/thrift-ls/lsp/types" ) @@ -190,28 +190,24 @@ func (s *Server) diagnose(ctx context.Context, ss *cache.Snapshot, affected []ur } func (s *Server) completion(ctx context.Context, params *protocol.CompletionParams) (*protocol.CompletionList, error) { - snapshot, release, fh, err := s.getFileContext(ctx, params.TextDocument.URI) - if err != nil { - return nil, err - } - defer release() - - items, rng, truncated, err := completion.DefaultTokenCompletion.Completion(ctx, snapshot, &completion.CompletionRequest{ - TriggerKind: 0, - Pos: types.Position{ - Line: params.Position.Line, - Character: params.Position.Character, - }, - Fh: fh, - }) - if err != nil { - return nil, err - } + return withFile(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot, fh cache.FileHandle) (*protocol.CompletionList, error) { + items, rng, truncated, err := source.DefaultTokenCompletion.Completion(ctx, ss, &source.CompletionRequest{ + TriggerKind: 0, + Pos: types.Position{ + Line: params.Position.Line, + Character: params.Position.Character, + }, + Fh: fh, + }) + if err != nil { + return nil, err + } - return toLspCompletionList(items, rng, truncated), nil + return toLspCompletionList(items, rng, truncated), nil + }) } -func toLspCompletionList(items []*completion.CompletionItem, rng protocol.Range, truncated bool) *protocol.CompletionList { +func toLspCompletionList(items []*source.CompletionItem, rng protocol.Range, truncated bool) *protocol.CompletionList { list := &protocol.CompletionList{ IsIncomplete: truncated, } @@ -237,23 +233,3 @@ func toLspCompletionList(items []*completion.CompletionItem, rng protocol.Range, return list } - -func (s *Server) getFileContext(ctx context.Context, uri uri.URI) (ss *cache.Snapshot, release func(), fh cache.FileHandle, err error) { - var view *cache.View - - view, err = s.session.ViewOf(uri) - if err != nil { - return ss, release, fh, err - } - - ss, release = view.Snapshot() - - fh, err = ss.ReadFile(ctx, uri) - if err != nil { - release() - - return ss, release, fh, err - } - - return ss, release, fh, err -} diff --git a/lsp/impl_test.go b/lsp/impl_test.go index 4ded1a2..06eb9e7 100644 --- a/lsp/impl_test.go +++ b/lsp/impl_test.go @@ -472,9 +472,9 @@ func symbolNames(syms protocol.SymbolInformationSlice) []string { } // 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. +// returns without blocking on the workspace, the walk runs asynchronously +// from initialize (not the Initialized notification), and registers every +// thrift file under the workspace folder. func Test_InitializeDefersTheWorkspaceWalk(t *testing.T) { synctest.Test(t, func(t *testing.T) { dir := t.TempDir() @@ -491,11 +491,6 @@ func Test_InitializeDefersTheWorkspaceWalk(t *testing.T) { }) 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 diff --git a/lsp/initialize.go b/lsp/initialize.go index cb74ee5..aeb1232 100644 --- a/lsp/initialize.go +++ b/lsp/initialize.go @@ -11,7 +11,7 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/semantic" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) initialize(ctx context.Context, params *protocol.InitializeParams) (result *protocol.InitializeResult, err error) { @@ -43,10 +43,20 @@ func (s *Server) initialize(ctx context.Context, params *protocol.InitializePara slog.Debug("initialized folders", "folders", folders) - // The walk happens on the Initialized notification; the session's - // once-guard keeps it from running twice. s.folders = folders + // Kick off the workspace walk immediately, off the request path, so + // the workspace is indexed by the time the client makes its first + // request. The walk is async (it parses every thrift file) and the + // once-guard keeps it from running twice. + s.workspaceWalkOnce.Do(func() { + go func() { + for _, folder := range s.folders { + s.walkFoldersThriftFile(folder) + } + }() + }) + return initializeResult(), nil } @@ -224,7 +234,7 @@ func initializeResult() *protocol.InitializeResult { WorkDoneProgress: new(true), }, Legend: protocol.SemanticTokensLegend{ - TokenTypes: semantic.Legend(), + TokenTypes: source.Legend(), TokenModifiers: []string{}, }, Full: &protocol.SemanticTokensFullDelta{ diff --git a/lsp/links.go b/lsp/links.go index d15a3e9..202ee35 100644 --- a/lsp/links.go +++ b/lsp/links.go @@ -5,19 +5,12 @@ import ( "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/links" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) documentLink(ctx context.Context, params *protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - return links.Links(ctx, ss, file), nil + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.DocumentLink, error) { + return source.Links(ctx, ss, params.TextDocument.URI), nil + }) } diff --git a/lsp/lsputils/utils.go b/lsp/lsputils/utils.go deleted file mode 100644 index d15d8c0..0000000 --- a/lsp/lsputils/utils.go +++ /dev/null @@ -1,146 +0,0 @@ -package lsputils - -import ( - "os" - "path/filepath" - "sort" - "strings" - - "go.lsp.dev/uri" - - "github.com/karitham/thrift-ls/syntax" -) - -// GetIncludeName return include name by file uri -// for example: file uri is file:///base.thrift, then `base` is include name -func GetIncludeName(file uri.URI) string { - fileName := file.Path() - - index := strings.LastIndexByte(fileName, filepath.Separator) - if index == -1 { - return fileName - } - - fileName = string(fileName[index+1:]) - - index = strings.LastIndexByte(fileName, '.') - if index == -1 { - return fileName - } - - return string(fileName[0:index]) -} - -// IncludePathText returns the include path of an include node without its -// quotes. The syntax token keeps the raw literal text, including quotes. -func IncludePathText(inc *syntax.Include) string { - if inc == nil || inc.Path == nil { - return "" - } - - return strings.Trim(inc.Path.Text, "\"'") -} - -// includeName: base.User. `base` is the includeName. returns ../../base.thrift -// if doesn't match, return empty string -func GetIncludePath(ast *syntax.Document, includeName string) string { - for _, include := range ast.Includes() { - path := IncludePathText(include) - if path == "" { - continue - } - - items := strings.Split(path, "/") - - path = items[len(items)-1] - if !strings.HasSuffix(path, ".thrift") { - continue - } - - name := strings.TrimSuffix(path, ".thrift") - if name == includeName { - return IncludePathText(include) - } - } - - return "" -} - -// cur is current file uri. for example file:///tmp/user.thrift -// includePath is include name used in code. for example: base.thrift -func IncludeURI(cur uri.URI, includePath string) uri.URI { - filePath := cur.Path() - items := strings.Split(filePath, string(filepath.Separator)) - basePath := strings.TrimSuffix(filePath, items[len(items)-1]) - - path := filepath.Join(basePath, includePath) - - return uri.File(path) -} - -// IncludeURIWithPaths resolves include path, first trying relative to current file, -// then trying each include path -func IncludeURIWithPaths(cur uri.URI, includePath string, includePaths []string) uri.URI { - filePath := cur.Path() - items := strings.Split(filePath, string(filepath.Separator)) - basePath := strings.TrimSuffix(filePath, items[len(items)-1]) - path := filepath.Join(basePath, includePath) - f := uri.File(path) - - // Check if file exists - if so, use it - if _, err := os.Stat(path); err == nil { - return f - } - - // Try each include path - for _, ip := range includePaths { - ipath := filepath.Join(ip, includePath) - if _, err := os.Stat(ipath); err == nil { - return uri.File(ipath) - } - } - - // Return the relative path as fallback - return f -} - -// ParseIdent parse an identifier. identifier format: -// 1. identifier -// 2. include.identifier -// -// it returns include, ident -func ParseIdent(cur uri.URI, includes []*syntax.Include, identifier string) (include, ident string) { - includeNames := IncludeNames(cur, includes) - // parse include from includeNames - - sort.SliceStable(includeNames, func(i, j int) bool { - // sort by string length, make sure longest include match early - // examples: - // user.extra - // user - return len(includeNames[i]) > len(includeNames[j]) - }) - - for _, incName := range includeNames { - prefix := incName + "." - if after, ok := strings.CutPrefix(identifier, prefix); ok { - return incName, after - } - } - - return "", identifier -} - -// includeNames returns include names from include ast nodes -func IncludeNames(cur uri.URI, includes []*syntax.Include) (includeNames []string) { - for _, inc := range includes { - path := IncludePathText(inc) - if path != "" { - u := IncludeURI(cur, path) - includeName := GetIncludeName(u) - includeNames = append(includeNames, includeName) - } - } - - return includeNames -} diff --git a/lsp/lsputils/utils_test.go b/lsp/lsputils/utils_test.go deleted file mode 100644 index 9e30991..0000000 --- a/lsp/lsputils/utils_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package lsputils - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "go.lsp.dev/uri" - - "github.com/karitham/thrift-ls/syntax" -) - -func Test_IncludeURI(t *testing.T) { - type args struct { - cur uri.URI - includePath string - } - - tests := []struct { - name string - args args - want uri.URI - }{ - { - name: "case1", - args: args{ - cur: uri.File("/tmp/workspace/app.thrift"), - includePath: "../user.thrift", - }, - want: uri.File("/tmp/user.thrift"), - }, - { - name: "case2", - args: args{ - cur: uri.File("/tmp/workspace/app.thrift"), - includePath: "user.thrift", - }, - want: uri.File("/tmp/workspace/user.thrift"), - }, - { - name: "case3", - args: args{ - cur: uri.URI("file:///c:/Users/Administrator/Downloads/whitebase-master/whitebase-master/zeon/Common.thrift"), - includePath: "Errors.thrift", - }, - want: uri.MustParse("file:///c%3A/Users/Administrator/Downloads/whitebase-master/whitebase-master/zeon/Errors.thrift"), - }, - { - name: "case4", - args: args{ - cur: uri.File("/tmp/workspace/app.subpath.thrift"), - includePath: "user.subpath.thrift", - }, - want: uri.File("/tmp/workspace/user.subpath.thrift"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, IncludeURI(tt.args.cur, tt.args.includePath)) - }) - } -} - -func TestGetIncludePath(t *testing.T) { - file := `include "../../user.thrift" -include "../../user.extra.thrift" -service Demo { - user.Test Api(1:user.Test2 arg1, 2:user.Test3 arg2) throws (1:user.Error1 err) -}` - - ast, errs := syntax.Parse([]byte(file)) - for _, e := range errs { - if e.Severity == syntax.SeverityError { - t.Fatal(e) - } - } - - type args struct { - ast *syntax.Document - includeName string - } - - tests := []struct { - name string - args args - want string - }{ - { - name: "case", - args: args{ - ast: ast, - includeName: "user", - }, - want: "../../user.thrift", - }, - { - name: "case", - args: args{ - ast: ast, - includeName: "user.extra", - }, - want: "../../user.extra.thrift", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, GetIncludePath(tt.args.ast, tt.args.includeName)) - }) - } -} - -func TestGetIncludeName(t *testing.T) { - type args struct { - file uri.URI - } - - tests := []struct { - name string - args args - want string - }{ - { - name: "file name", - args: args{ - file: uri.MustParse("base.thrift"), - }, - want: "base", - }, - { - name: "file name with dir", - args: args{ - file: uri.MustParse("/tmp/base.thrift"), - }, - want: "base", - }, - { - name: "file name with .", - args: args{ - file: uri.MustParse("/tmp/base.subpath.thrift"), - }, - want: "base.subpath", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, GetIncludeName(tt.args.file)) - }) - } -} - -func TestIncludeNames(t *testing.T) { - type args struct { - cur uri.URI - includes []*syntax.Include - } - - tests := []struct { - name string - args args - wantIncludeNames []string - }{ - { - name: "case 1", - args: args{ - cur: uri.MustParse("/tmp/app.thrift"), - includes: []*syntax.Include{ - { - Path: &syntax.Token{Text: "../../base.sub.thrift"}, - }, - { - Path: &syntax.Token{Text: "user.sub.thrift"}, - }, - { - Path: &syntax.Token{Text: "app.thrift"}, - }, - }, - }, - wantIncludeNames: []string{ - "base.sub", - "user.sub", - "app", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.wantIncludeNames, IncludeNames(tt.args.cur, tt.args.includes)) - }) - } -} - -func TestIncludeURIWithPaths(t *testing.T) { - // Create temp directory structure: - // /tmp/thrift-test/ - // base/ - // shared.thrift (exists) - // service/ - // order.thrift (exists) - tmpDir, err := os.MkdirTemp("", "thrift-test") - assert.NoError(t, err) - - defer func() { _ = os.RemoveAll(tmpDir) }() - - baseDir := filepath.Join(tmpDir, "base") - serviceDir := filepath.Join(tmpDir, "service") - err = os.MkdirAll(baseDir, 0o755) - assert.NoError(t, err) - err = os.MkdirAll(serviceDir, 0o755) - assert.NoError(t, err) - - // Create shared.thrift in base/ - sharedThrift := filepath.Join(baseDir, "shared.thrift") - err = os.WriteFile(sharedThrift, []byte(""), 0o644) - assert.NoError(t, err) - - // Create order.thrift in service/ - orderThrift := filepath.Join(serviceDir, "order.thrift") - err = os.WriteFile(orderThrift, []byte(""), 0o644) - assert.NoError(t, err) - - orderURI := uri.File(orderThrift) - - tests := []struct { - name string - cur uri.URI - includePath string - includePaths []string - expected uri.URI - }{ - { - name: "found in includePaths", - cur: orderURI, - includePath: "shared.thrift", - includePaths: []string{baseDir}, - expected: uri.File(sharedThrift), - }, - { - name: "not in includePaths falls back to relative path", - cur: orderURI, - includePath: "shared.thrift", - includePaths: []string{}, - expected: uri.File(filepath.Join(serviceDir, "shared.thrift")), - }, - { - name: "relative path works without includePaths", - cur: orderURI, - includePath: "../base/shared.thrift", - includePaths: []string{}, - expected: uri.File(sharedThrift), - }, - { - name: "non-existent file falls back to relative path", - cur: orderURI, - includePath: "other.thrift", - includePaths: []string{baseDir}, - expected: uri.File(filepath.Join(serviceDir, "other.thrift")), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IncludeURIWithPaths(tt.cur, tt.includePath, tt.includePaths) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestParseIdent(t *testing.T) { - type args struct { - cur uri.URI - includes []*syntax.Include - identifier string - } - - tests := []struct { - name string - args args - wantInclude string - wantIdent string - }{ - { - name: "case 1", - args: args{ - cur: uri.MustParse("/tmp/app.thrift"), - includes: []*syntax.Include{ - { - Path: &syntax.Token{Text: "user.sub.thrift"}, - }, - { - Path: &syntax.Token{Text: "user.thrift"}, - }, - }, - identifier: "user.Name", - }, - wantInclude: "user", - wantIdent: "Name", - }, - { - name: "case 2", - args: args{ - cur: uri.MustParse("/tmp/app.thrift"), - includes: []*syntax.Include{ - { - Path: &syntax.Token{Text: "user.sub.thrift"}, - }, - { - Path: &syntax.Token{Text: "user.thrift"}, - }, - }, - identifier: "user.sub.Name", - }, - wantInclude: "user.sub", - wantIdent: "Name", - }, - { - name: "case 3", - args: args{ - cur: uri.MustParse("/tmp/app.thrift"), - includes: []*syntax.Include{ - { - Path: &syntax.Token{Text: "user.thrift"}, - }, - }, - identifier: "user.sub.Name", - }, - wantInclude: "user", - wantIdent: "sub.Name", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotInclude, gotIdent := ParseIdent(tt.args.cur, tt.args.includes, tt.args.identifier) - assert.Equal(t, tt.wantInclude, gotInclude) - assert.Equal(t, tt.wantIdent, gotIdent) - }) - } -} diff --git a/lsp/rename.go b/lsp/rename.go index 9c3ed76..cea8ccf 100644 --- a/lsp/rename.go +++ b/lsp/rename.go @@ -5,33 +5,18 @@ import ( "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/codejump" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) prepareRename(ctx context.Context, params *protocol.PrepareRenameParams) (*protocol.Range, error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - return codejump.PrepareRename(ctx, ss, params.TextDocument.URI, params.Position) + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.Range, error) { + return source.PrepareRename(ctx, ss, params.TextDocument.URI, params.Position) + }) } func (s *Server) rename(ctx context.Context, params *protocol.RenameParams) (*protocol.WorkspaceEdit, error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - return codejump.Rename(ctx, ss, params.TextDocument.URI, params.Position, params.NewName) + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.WorkspaceEdit, error) { + return source.Rename(ctx, ss, params.TextDocument.URI, params.Position, params.NewName) + }) } diff --git a/lsp/semantic.go b/lsp/semantic.go index 80d63ce..ed2ef1a 100644 --- a/lsp/semantic.go +++ b/lsp/semantic.go @@ -5,24 +5,17 @@ import ( "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/semantic" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) semanticTokensFull(ctx context.Context, params *protocol.SemanticTokensParams) (*protocol.SemanticTokens, error) { - file := params.TextDocument.URI - - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } - - ss, release := view.Snapshot() - defer release() - - data, err := semantic.Tokens(ctx, ss, file) - if err != nil { - return nil, err - } - - return &protocol.SemanticTokens{Data: data}, nil + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.SemanticTokens, error) { + data, err := source.Tokens(ctx, ss, params.TextDocument.URI) + if err != nil { + return nil, err + } + + return &protocol.SemanticTokens{Data: data}, nil + }) } diff --git a/lsp/server.go b/lsp/server.go index 6289cd5..eed43d1 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -3,6 +3,8 @@ package lsp import ( "context" "log/slog" + "slices" + "strings" "sync" "go.lsp.dev/protocol" @@ -10,7 +12,7 @@ import ( "github.com/karitham/thrift-ls/formatter" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/symbols" + "github.com/karitham/thrift-ls/lsp/source" ) type Server struct { @@ -51,17 +53,11 @@ 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.workspaceWalkOnce.Do(func() { - go func() { - for _, folder := range s.folders { - s.walkFoldersThriftFile(folder) - } - }() - }) - + // The workspace walk starts at the end of initialize, not here: this + // method is a notification, which is fire-and-forget, while initialize + // is a request — a client that drops the notification (or never sends + // it) would otherwise leave the workspace unindexed until the first + // edit. return nil } @@ -251,7 +247,29 @@ func (s *Server) SignatureHelp(ctx context.Context, params *protocol.SignatureHe } func (s *Server) Symbols(ctx context.Context, params *protocol.WorkspaceSymbolParams) (result protocol.WorkspaceSymbolResult, err error) { - return protocol.SymbolInformationSlice(symbols.WorkspaceSymbols(ctx, s.session, params.Query, 1000)), nil + views := s.session.Views() + slices.SortFunc(views, func(a, b *cache.View) int { + return strings.Compare(string(a.Folder()), string(b.Folder())) + }) + + const maxResults = 1000 + + var res []protocol.SymbolInformation + + for _, view := range views { + ss, release := view.Snapshot() + + syms := source.WorkspaceSymbols(ctx, ss, view.KnownFiles(), params.Query, maxResults-len(res)) + + release() + + res = append(res, syms...) + if len(res) >= maxResults { + break + } + } + + return protocol.SymbolInformationSlice(res), nil } func (s *Server) TypeDefinition(ctx context.Context, params *protocol.TypeDefinitionParams) (result protocol.DefinitionResult, err error) { diff --git a/lsp/snapshot.go b/lsp/snapshot.go new file mode 100644 index 0000000..e675017 --- /dev/null +++ b/lsp/snapshot.go @@ -0,0 +1,40 @@ +package lsp + +import ( + "context" + + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/lsp/cache" +) + +// withSnapshot resolves file's view, acquires its snapshot, and runs fn +// while the snapshot is held. Every request handler funnels through this +// helper so the acquire/release discipline lives in one place. +func withSnapshot[T any](ctx context.Context, session *cache.Session, file uri.URI, fn func(*cache.Snapshot) (T, error)) (T, error) { + view, err := session.ViewOf(file) + if err != nil { + var zero T + + return zero, err + } + + ss, release := view.Snapshot() + defer release() + + return fn(ss) +} + +// withFile is withSnapshot plus the file handle for file. +func withFile[T any](ctx context.Context, session *cache.Session, file uri.URI, fn func(*cache.Snapshot, cache.FileHandle) (T, error)) (T, error) { + return withSnapshot(ctx, session, file, func(ss *cache.Snapshot) (T, error) { + fh, err := ss.ReadFile(ctx, file) + if err != nil { + var zero T + + return zero, err + } + + return fn(ss, fh) + }) +} diff --git a/lsp/completion/completion_test.go b/lsp/source/completion_test.go similarity index 98% rename from lsp/completion/completion_test.go rename to lsp/source/completion_test.go index c9af710..34a211a 100644 --- a/lsp/completion/completion_test.go +++ b/lsp/source/completion_test.go @@ -1,4 +1,4 @@ -package completion +package source import ( "testing" diff --git a/lsp/completion/utils.go b/lsp/source/completion_utils.go similarity index 98% rename from lsp/completion/utils.go rename to lsp/source/completion_utils.go index ec357f3..87ea4a6 100644 --- a/lsp/completion/utils.go +++ b/lsp/source/completion_utils.go @@ -1,4 +1,4 @@ -package completion +package source import ( "os" diff --git a/lsp/completion/context.go b/lsp/source/context.go similarity index 99% rename from lsp/completion/context.go rename to lsp/source/context.go index f2d206c..8f62c83 100644 --- a/lsp/completion/context.go +++ b/lsp/source/context.go @@ -1,4 +1,4 @@ -package completion +package source import ( "strings" diff --git a/lsp/completion/context_test.go b/lsp/source/context_test.go similarity index 99% rename from lsp/completion/context_test.go rename to lsp/source/context_test.go index ae99da7..2c0b5df 100644 --- a/lsp/completion/context_test.go +++ b/lsp/source/context_test.go @@ -1,4 +1,4 @@ -package completion +package source import ( "strings" diff --git a/lsp/codejump/cross_project_test.go b/lsp/source/cross_project_test.go similarity index 99% rename from lsp/codejump/cross_project_test.go rename to lsp/source/cross_project_test.go index e2fefea..ae0daa9 100644 --- a/lsp/codejump/cross_project_test.go +++ b/lsp/source/cross_project_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "path/filepath" diff --git a/lsp/codejump/cross_reference_test.go b/lsp/source/cross_reference_test.go similarity index 99% rename from lsp/codejump/cross_reference_test.go rename to lsp/source/cross_reference_test.go index 25f6a46..266b56d 100644 --- a/lsp/codejump/cross_reference_test.go +++ b/lsp/source/cross_reference_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "testing" diff --git a/lsp/diagnostic/cycle_detect.go b/lsp/source/cycle_detect.go similarity index 94% rename from lsp/diagnostic/cycle_detect.go rename to lsp/source/cycle_detect.go index 78bb900..721d0c8 100644 --- a/lsp/diagnostic/cycle_detect.go +++ b/lsp/source/cycle_detect.go @@ -1,4 +1,4 @@ -package diagnostic +package source import ( "context" @@ -9,7 +9,6 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/lsputils" "github.com/karitham/thrift-ls/syntax" ) @@ -102,7 +101,7 @@ func getIncludes(ctx context.Context, ss *cache.Snapshot, file uri.URI, includes continue } - includeURI := resolver.ResolveIncludeWithText(file, lsputils.IncludePathText(includes[i])) + includeURI := resolver.ResolveInclude(file, includes[i].PathText()) (*includesMap)[file] = append((*includesMap)[file], Include{ file: includeURI, include: includes[i], diff --git a/lsp/diagnostic/cycle_detect_test.go b/lsp/source/cycle_detect_test.go similarity index 57% rename from lsp/diagnostic/cycle_detect_test.go rename to lsp/source/cycle_detect_test.go index f2ab98e..42cb82e 100644 --- a/lsp/diagnostic/cycle_detect_test.go +++ b/lsp/source/cycle_detect_test.go @@ -1,4 +1,4 @@ -package diagnostic +package source import ( "context" @@ -82,6 +82,128 @@ func Test_cycleDetect(t *testing.T) { } } +// Test_cycleDetectN pins cycle detection on arbitrary-length cycles: an +// include edge X -> Y closes a cycle when Y transitively includes X, no +// matter the cycle length. The existing 2-cycle case is covered in +// Test_cycleDetect; these cases exercise longer cycles, self-includes, and +// acyclic graphs. +func Test_cycleDetectN(t *testing.T) { + // K-On themed include graph: the band's songs include each other's + // tabs, and the clubroom includes everything. + const ( + tea = "/songs/tea_time.thrift" + git = "/songs/gitah.thrift" + bass = "/songs/mio.thrift" + drum = "/songs/ritsu.thrift" + club = "/clubroom.thrift" + ) + + include := func(from uri.URI, tos ...uri.URI) map[uri.URI][]Include { + m := make(map[uri.URI][]Include, len(tos)) + for _, to := range tos { + m[from] = append(m[from], Include{file: to}) + } + + return m + } + + tests := []struct { + name string + graph map[uri.URI][]Include + want []CyclePair + }{ + { + name: "acyclic chain", + graph: map[uri.URI][]Include{ + tea: {Include{file: git}}, + git: {Include{file: bass}}, + bass: {}, + }, + want: nil, + }, + { + name: "2-cycle", + graph: map[uri.URI][]Include{ + tea: {Include{file: git}}, + git: {Include{file: tea}}, + }, + want: []CyclePair{ + {file: tea, include: Include{file: git}}, + {file: git, include: Include{file: tea}}, + }, + }, + { + name: "3-cycle", + graph: map[uri.URI][]Include{ + tea: {Include{file: git}}, + git: {Include{file: bass}}, + bass: {Include{file: tea}}, + }, + want: []CyclePair{ + {file: tea, include: Include{file: git}}, + {file: git, include: Include{file: bass}}, + {file: bass, include: Include{file: tea}}, + }, + }, + { + name: "4-cycle", + graph: map[uri.URI][]Include{ + tea: {Include{file: git}}, + git: {Include{file: bass}}, + bass: {Include{file: drum}}, + drum: {Include{file: tea}}, + }, + want: []CyclePair{ + {file: tea, include: Include{file: git}}, + {file: git, include: Include{file: bass}}, + {file: bass, include: Include{file: drum}}, + {file: drum, include: Include{file: tea}}, + }, + }, + { + name: "self-include", + graph: map[uri.URI][]Include{ + tea: {Include{file: tea}}, + }, + want: []CyclePair{ + {file: tea, include: Include{file: tea}}, + }, + }, + { + name: "diamond into a cycle", + graph: map[uri.URI][]Include{ + club: {Include{file: tea}, Include{file: git}}, + tea: {Include{file: bass}}, + git: {Include{file: drum}}, + bass: {Include{file: drum}, Include{file: club}}, + drum: {Include{file: tea}}, + }, + want: []CyclePair{ + {file: club, include: Include{file: tea}}, + {file: club, include: Include{file: git}}, + {file: tea, include: Include{file: bass}}, + {file: git, include: Include{file: drum}}, + {file: bass, include: Include{file: club}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cycleDetect(&tt.graph) + sort.SliceStable(got, func(i, j int) bool { + if got[i].file == got[j].file { + return got[i].include.file < got[j].include.file + } + + return got[i].file < got[j].file + }) + + assert.ElementsMatch(t, tt.want, got) + }) + } +} + func Test_getIncludes(t *testing.T) { file1 := `include "./test/goods.thrift" include "./test/address.thrift"` diff --git a/lsp/codejump/definition.go b/lsp/source/definition.go similarity index 95% rename from lsp/codejump/definition.go rename to lsp/source/definition.go index da98613..2cc1985 100644 --- a/lsp/codejump/definition.go +++ b/lsp/source/definition.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" @@ -8,7 +8,6 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/lsputils" "github.com/karitham/thrift-ls/syntax" ) @@ -42,7 +41,7 @@ func FindTypeDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, a return "", nil, DefinitionNone, nil } - _, identifier := lsputils.ParseIdent(file, ast.Includes(), name) + _, identifier := parseIdent(file, ast.Includes(), name) for _, astFile := range definitionFiles(ctx, ss, file, ast, name) { dstPf, err := parseDefinitionFile(ctx, ss, astFile) if err != nil { @@ -81,7 +80,7 @@ func FindConstValueDefinition(ctx context.Context, ss *cache.Snapshot, file uri. return "", nil, nil } - _, identifier := lsputils.ParseIdent(file, ast.Includes(), name) + _, identifier := parseIdent(file, ast.Includes(), name) identifier = bareName(identifier) for _, astFile := range definitionFiles(ctx, ss, file, ast, name) { @@ -109,7 +108,7 @@ func FindServiceDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI return "", nil, nil } - _, identifier := lsputils.ParseIdent(file, ast.Includes(), ident.Text) + _, identifier := parseIdent(file, ast.Includes(), ident.Text) for _, astFile := range definitionFiles(ctx, ss, file, ast, ident.Text) { dstPf, err := parseDefinitionFile(ctx, ss, astFile) if err != nil { diff --git a/lsp/codejump/definition_list_test.go b/lsp/source/definition_list_test.go similarity index 99% rename from lsp/codejump/definition_list_test.go rename to lsp/source/definition_list_test.go index f39b071..a0ce25a 100644 --- a/lsp/codejump/definition_list_test.go +++ b/lsp/source/definition_list_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" diff --git a/lsp/codejump/definition_test.go b/lsp/source/definition_test.go similarity index 99% rename from lsp/codejump/definition_test.go rename to lsp/source/definition_test.go index b0850bb..984d54e 100644 --- a/lsp/codejump/definition_test.go +++ b/lsp/source/definition_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" diff --git a/lsp/diagnostic/diagnostic.go b/lsp/source/diagnostic.go similarity index 78% rename from lsp/diagnostic/diagnostic.go rename to lsp/source/diagnostic.go index f7334ec..f3c91a0 100644 --- a/lsp/diagnostic/diagnostic.go +++ b/lsp/source/diagnostic.go @@ -1,4 +1,4 @@ -package diagnostic +package source import ( "context" @@ -12,10 +12,10 @@ import ( "github.com/karitham/thrift-ls/syntax" ) -var registry []Interface +var registry []Checker func init() { - registry = []Interface{ + registry = []Checker{ &CycleCheck{}, &Parse{}, &FieldIDCheck{}, @@ -23,14 +23,14 @@ func init() { } } -type Interface interface { +type Checker interface { Diagnostic(ctx context.Context, ss *cache.Snapshot, changeFiles []uri.URI) (DiagnosticResult, error) Name() string } type Diagnostic struct{} -func NewDiagnostic() Interface { +func NewDiagnostic() Checker { return &Diagnostic{} } @@ -86,22 +86,6 @@ func tokenRange(doc *syntax.Document, tok *syntax.Token) protocol.Range { } } -// nodeRange converts a node's span to an LSP range. -func nodeRange(doc *syntax.Document, node syntax.Node) protocol.Range { - start, end := doc.Range(node) - - return protocol.Range{ - Start: protocol.Position{ - Line: uint32(start.Line - 1), - Character: uint32(start.Col - 1), - }, - End: protocol.Position{ - Line: uint32(end.Line - 1), - Character: uint32(end.Col - 1), - }, - } -} - // tokIndex finds the index of a token pointer in the document's token // stream by its offset. func tokIndex(doc *syntax.Document, tok *syntax.Token) int { diff --git a/lsp/symbols/document.go b/lsp/source/document.go similarity index 99% rename from lsp/symbols/document.go rename to lsp/source/document.go index 18a4130..1a6c83e 100644 --- a/lsp/symbols/document.go +++ b/lsp/source/document.go @@ -1,4 +1,4 @@ -package symbols +package source import ( "context" diff --git a/lsp/diagnostic/fieldid_check.go b/lsp/source/fieldid_check.go similarity index 99% rename from lsp/diagnostic/fieldid_check.go rename to lsp/source/fieldid_check.go index 2179c55..cc25abb 100644 --- a/lsp/diagnostic/fieldid_check.go +++ b/lsp/source/fieldid_check.go @@ -1,4 +1,4 @@ -package diagnostic +package source import ( "context" diff --git a/lsp/diagnostic/fieldid_check_test.go b/lsp/source/fieldid_check_test.go similarity index 99% rename from lsp/diagnostic/fieldid_check_test.go rename to lsp/source/fieldid_check_test.go index 7433d6c..ba5b17e 100644 --- a/lsp/diagnostic/fieldid_check_test.go +++ b/lsp/source/fieldid_check_test.go @@ -1,4 +1,4 @@ -package diagnostic +package source import ( "context" diff --git a/lsp/folding/folding.go b/lsp/source/folding.go similarity index 99% rename from lsp/folding/folding.go rename to lsp/source/folding.go index 6efa70b..53564f2 100644 --- a/lsp/folding/folding.go +++ b/lsp/source/folding.go @@ -2,7 +2,7 @@ // (structs, enums, services), const list and map values, annotations, and // comment blocks. Pure over the snapshot: parsing and file I/O happen in // the caller. -package folding +package source import ( "context" diff --git a/lsp/folding/folding_test.go b/lsp/source/folding_test.go similarity index 99% rename from lsp/folding/folding_test.go rename to lsp/source/folding_test.go index 537d761..29df153 100644 --- a/lsp/folding/folding_test.go +++ b/lsp/source/folding_test.go @@ -1,4 +1,4 @@ -package folding +package source import ( "context" diff --git a/lsp/source/format.go b/lsp/source/format.go new file mode 100644 index 0000000..bedb8f4 --- /dev/null +++ b/lsp/source/format.go @@ -0,0 +1,306 @@ +package source + +import ( + "bytes" + "context" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/formatter" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/mapper" + "github.com/karitham/thrift-ls/lsp/types" +) + +// Format returns the whole-document formatting of fh's content. +func Format(ctx context.Context, ss *cache.Snapshot, fh cache.FileHandle, opts formatter.Options) (string, error) { + pf, err := ss.Parse(ctx, fh.URI()) + if err != nil { + return "", err + } + + if len(pf.Errors()) > 0 || pf.AST() == nil { + return "", pf.AggregatedError() + } + + return formatter.Format(pf.AST(), opts) +} + +// FormatDocument returns the single text edit replacing the whole document +// with its formatted content. It returns nil when the document is already +// formatted. +func FormatDocument(ctx context.Context, ss *cache.Snapshot, fh cache.FileHandle, opts formatter.Options) (*protocol.TextEdit, error) { + content, err := fh.Content() + if err != nil { + return nil, err + } + + formatted, err := Format(ctx, ss, fh, opts) + if err != nil { + return nil, err + } + + if string(content) == formatted { + return nil, nil + } + + mp := mapper.NewMapper(fh.URI(), content) + endPos := mp.GetLSPEndPosition() + + return &protocol.TextEdit{ + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{ + Line: endPos.Line, + Character: endPos.Character, + }, + }, + NewText: formatted, + }, nil +} + +// FormatRange implements textDocument/rangeFormatting. +// +// The formatter only knows how to print whole documents, so a range is +// formatted by formatting the whole document and diffing it against the +// original at the granularity of blank-line-separated blocks. Blank lines +// are preserved exactly by the formatter, so the blocks align one-to-one; +// every edit is bounded by blank lines or file edges, and any subset +// splices safely. Only the edits overlapping the selection are returned. +func FormatRange(ctx context.Context, ss *cache.Snapshot, fh cache.FileHandle, opts formatter.Options, rng protocol.Range) ([]protocol.TextEdit, error) { + content, err := fh.Content() + if err != nil { + return nil, err + } + + formatted, err := Format(ctx, ss, fh, opts) + if err != nil { + return nil, err + } + + if string(content) == formatted { + return nil, nil + } + + mp := mapper.NewMapper(fh.URI(), content) + + start, err := mp.LSPPosToParserPosition(lspPosition(rng.Start)) + if err != nil { + return nil, err + } + + end, err := mp.LSPPosToParserPosition(lspPosition(rng.End)) + if err != nil { + return nil, err + } + + // The selection expanded to whole lines. + selStart := lineStart(content, start.Offset) + selEnd := nextLineStart(content, lineStart(content, end.Offset)) + + var result []protocol.TextEdit + + for _, be := range blockDiff(content, []byte(formatted)) { + // Overlap test on byte offsets; adjacent edits touch at most. + if be.end <= selStart || be.start >= selEnd { + continue + } + + startPos, err := mp.OffsetToLSPPosition(be.start) + if err != nil { + return nil, err + } + + endPos, err := mp.OffsetToLSPPosition(be.end) + if err != nil { + return nil, err + } + + result = append(result, protocol.TextEdit{ + Range: protocol.Range{ + Start: protocolPosition(startPos), + End: protocolPosition(endPos), + }, + NewText: be.text, + }) + } + + return result, nil +} + +// FormatDocumentAction returns the source.fixAll code action that formats +// the document, mirroring the formatting request. It returns nil when the +// document is already formatted. +func FormatDocumentAction(ctx context.Context, ss *cache.Snapshot, fh cache.FileHandle, opts formatter.Options) (*protocol.CodeAction, error) { + edit, err := FormatDocument(ctx, ss, fh, opts) + if err != nil { + return nil, err + } + + if edit == nil { + return nil, nil + } + + file := fh.URI() + + return &protocol.CodeAction{ + Title: "Format document", + Kind: new(protocol.CodeActionKindSourceFixAll), + Edit: &protocol.WorkspaceEdit{ + Changes: map[uri.URI][]protocol.TextEdit{ + file: {*edit}, + }, + }, + }, nil +} + +// lspPosition converts a protocol position to the internal position type. +func lspPosition(p protocol.Position) types.Position { + return types.Position{ + Line: uint32(p.Line), + Character: uint32(p.Character), + } +} + +// protocolPosition converts an internal position to a protocol position. +func protocolPosition(p types.Position) protocol.Position { + return protocol.Position{ + Line: p.Line, + Character: p.Character, + } +} + +// blockEdit replaces content[start:end] with text. Every block edit is +// bounded by blank lines or file edges, so it splices safely. +type blockEdit struct { + start, end int + text string +} + +// blockDiff returns the edits turning old into new, one per changed +// segment: the blank-line runs and the blocks of non-blank lines between +// them. Blank lines are preserved structurally by the formatter (their +// whitespace may be trimmed), so old and new split into the same number of +// aligned blocks; every edit is bounded by blank lines or file edges, so +// any subset splices safely. +func blockDiff(old, new []byte) []blockEdit { + // CRLF input normalizes to LF everywhere, blank lines included: the + // block alignment no longer holds, so a single whole-document edit is + // the only safe splice. + if bytes.Contains(old, []byte("\r\n")) { + if string(old) == string(new) { + return nil + } + + return []blockEdit{{0, len(old), string(new)}} + } + + oldBlocks := blocks(old) + newBlocks := blocks(new) + + // No non-blank lines at all, or an unaligned block structure: fall + // back to a single whole-document edit. + if len(oldBlocks) == 0 || len(oldBlocks) != len(newBlocks) { + if string(old) == string(new) { + return nil + } + + return []blockEdit{{0, len(old), string(new)}} + } + + var edits []blockEdit + + prevOld, prevNew := 0, 0 + for i := range oldBlocks { + // The segment before the block: leading blanks, or the blank run + // between two blocks. + if !bytes.Equal(old[prevOld:oldBlocks[i].start], new[prevNew:newBlocks[i].start]) { + edits = append(edits, blockEdit{ + start: prevOld, + end: oldBlocks[i].start, + text: string(new[prevNew:newBlocks[i].start]), + }) + } + + // The block itself. + if !bytes.Equal(old[oldBlocks[i].start:oldBlocks[i].end], new[newBlocks[i].start:newBlocks[i].end]) { + edits = append(edits, blockEdit{ + start: oldBlocks[i].start, + end: oldBlocks[i].end, + text: string(new[newBlocks[i].start:newBlocks[i].end]), + }) + } + + prevOld, prevNew = oldBlocks[i].end, newBlocks[i].end + } + + // The trailing segment. + if !bytes.Equal(old[prevOld:], new[prevNew:]) { + edits = append(edits, blockEdit{prevOld, len(old), string(new[prevNew:])}) + } + + return edits +} + +// block is a maximal run of non-blank lines: the byte range from the first +// line's start to just after the last line's newline, with the exact text. +type block struct { + start, end int + text string +} + +// blocks splits content into runs of non-blank lines. +func blocks(content []byte) []block { + var out []block + + i := 0 + for i < len(content) { + // Skip blank lines. + for i < len(content) && len(bytes.TrimSpace(content[i:lineEnd(content, i)])) == 0 { + i = nextLineStart(content, i) + } + + if i >= len(content) { + break + } + + start := i + for i < len(content) && len(bytes.TrimSpace(content[i:lineEnd(content, i)])) > 0 { + i = nextLineStart(content, i) + } + + out = append(out, block{start: start, end: i, text: string(content[start:i])}) + } + + return out +} + +// nextLineStart returns the offset just after the newline ending the line +// containing offset, or len(content) for the last line. +func nextLineStart(content []byte, offset int) int { + if i := bytes.IndexByte(content[offset:], '\n'); i != -1 { + return offset + i + 1 + } + + return len(content) +} + +// lineStart returns the byte offset of the start of the line containing offset. +func lineStart(content []byte, offset int) int { + if i := bytes.LastIndexByte(content[:offset], '\n'); i != -1 { + return i + 1 + } + + return 0 +} + +// lineEnd returns the byte offset of the newline ending the line containing +// offset, or len(content) for the last line. +func lineEnd(content []byte, offset int) int { + if i := bytes.IndexByte(content[offset:], '\n'); i != -1 { + return offset + i + } + + return len(content) +} diff --git a/lsp/format_range_fuzz_test.go b/lsp/source/format_range_fuzz_test.go similarity index 99% rename from lsp/format_range_fuzz_test.go rename to lsp/source/format_range_fuzz_test.go index 1b85a05..4f1f1f7 100644 --- a/lsp/format_range_fuzz_test.go +++ b/lsp/source/format_range_fuzz_test.go @@ -1,4 +1,4 @@ -package lsp +package source import ( "strings" diff --git a/lsp/format_range_test.go b/lsp/source/format_range_test.go similarity index 99% rename from lsp/format_range_test.go rename to lsp/source/format_range_test.go index e6a2fc5..e4f7fd2 100644 --- a/lsp/format_range_test.go +++ b/lsp/source/format_range_test.go @@ -1,4 +1,4 @@ -package lsp +package source import ( "strings" diff --git a/lsp/codejump/highlight_test.go b/lsp/source/highlight_test.go similarity index 99% rename from lsp/codejump/highlight_test.go rename to lsp/source/highlight_test.go index e9b9ca4..af8f270 100644 --- a/lsp/codejump/highlight_test.go +++ b/lsp/source/highlight_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "testing" diff --git a/lsp/codejump/hits.go b/lsp/source/hits.go similarity index 95% rename from lsp/codejump/hits.go rename to lsp/source/hits.go index e6f8de7..f18acb0 100644 --- a/lsp/codejump/hits.go +++ b/lsp/source/hits.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "go.lsp.dev/protocol" diff --git a/lsp/codejump/hover.go b/lsp/source/hover.go similarity index 99% rename from lsp/codejump/hover.go rename to lsp/source/hover.go index 46e37ac..17cff8a 100644 --- a/lsp/codejump/hover.go +++ b/lsp/source/hover.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" diff --git a/lsp/codejump/hover_test.go b/lsp/source/hover_test.go similarity index 99% rename from lsp/codejump/hover_test.go rename to lsp/source/hover_test.go index e1b1030..2f249d9 100644 --- a/lsp/codejump/hover_test.go +++ b/lsp/source/hover_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "strings" diff --git a/lsp/links/links.go b/lsp/source/links.go similarity index 70% rename from lsp/links/links.go rename to lsp/source/links.go index a7f21df..6d76870 100644 --- a/lsp/links/links.go +++ b/lsp/source/links.go @@ -1,7 +1,7 @@ // Package links computes document links: include paths resolving to their // target files. Pure over the snapshot: parsing and file I/O happen in the // caller. -package links +package source import ( "context" @@ -54,14 +54,3 @@ func Links(ctx context.Context, ss *cache.Snapshot, file uri.URI) []protocol.Doc return out } - -// tokenRange converts a token's span into a protocol range. -func tokenRange(doc *syntax.Document, tok *syntax.Token) protocol.Range { - start := doc.TokenPosition(doc.TokenIndex(tok)) - end := doc.TokenEndPosition(doc.TokenIndex(tok)) - - return protocol.Range{ - Start: protocol.Position{Line: uint32(start.Line - 1), Character: uint32(start.Col - 1)}, - End: protocol.Position{Line: uint32(end.Line - 1), Character: uint32(end.Col - 1)}, - } -} diff --git a/lsp/links/links_test.go b/lsp/source/links_test.go similarity index 87% rename from lsp/links/links_test.go rename to lsp/source/links_test.go index 42e80c4..064f3d1 100644 --- a/lsp/links/links_test.go +++ b/lsp/source/links_test.go @@ -1,4 +1,4 @@ -package links +package source import ( "testing" @@ -11,7 +11,7 @@ import ( ) // buildSnapshot parses src as the file at URI and returns the snapshot. -func buildSnapshot(t *testing.T, file uri.URI, src string) *cache.Snapshot { +func buildLinksSnapshot(t *testing.T, file uri.URI, src string) *cache.Snapshot { t.Helper() ss := cache.BuildSnapshotForTest([]*cache.FileChange{ @@ -53,7 +53,7 @@ struct S {}`, for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ss := buildSnapshot(t, "file:///tmp/main.thrift", tt.src) + ss := buildLinksSnapshot(t, "file:///tmp/main.thrift", tt.src) got := Links(t.Context(), ss, "file:///tmp/main.thrift") @@ -76,7 +76,7 @@ struct S {}`, // TestLinksRange pins the link range to the include string literal. func TestLinksRange(t *testing.T) { - ss := buildSnapshot(t, "file:///tmp/main.thrift", "include \"base.thrift\"\n") + ss := buildLinksSnapshot(t, "file:///tmp/main.thrift", "include \"base.thrift\"\n") got := Links(t.Context(), ss, "file:///tmp/main.thrift") require.Len(t, got, 1) diff --git a/lsp/source/name.go b/lsp/source/name.go new file mode 100644 index 0000000..dd73b00 --- /dev/null +++ b/lsp/source/name.go @@ -0,0 +1,67 @@ +package source + +import ( + "path/filepath" + "sort" + "strings" + + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/syntax" +) + +// includeNameOf returns the include name of a file URI: the base name +// without extension. file:///base.thrift -> "base". +func includeNameOf(file uri.URI) string { + fileName := file.Path() + + index := strings.LastIndexByte(fileName, filepath.Separator) + if index != -1 { + fileName = string(fileName[index+1:]) + } + + index = strings.LastIndexByte(fileName, '.') + if index == -1 { + return fileName + } + + return string(fileName[0:index]) +} + +// parseIdent parses an identifier. identifier format: +// 1. identifier +// 2. include.identifier +// +// it returns include, ident +func parseIdent(cur uri.URI, includes []*syntax.Include, identifier string) (include, ident string) { + includeNames := includeNames(cur, includes) + + // sort by string length, make sure longest include match early + // examples: + // user.extra + // user + sort.SliceStable(includeNames, func(i, j int) bool { + return len(includeNames[i]) > len(includeNames[j]) + }) + + for _, incName := range includeNames { + prefix := incName + "." + if after, ok := strings.CutPrefix(identifier, prefix); ok { + return incName, after + } + } + + return "", identifier +} + +// includeNames returns include names from include ast nodes +func includeNames(cur uri.URI, includes []*syntax.Include) (includeNames []string) { + for _, inc := range includes { + if path := inc.PathText(); path != "" { + u := uri.File(filepath.Join(filepath.Dir(cur.Path()), path)) + includeNames = append(includeNames, includeNameOf(u)) + } + } + + return includeNames +} diff --git a/lsp/source/name_test.go b/lsp/source/name_test.go new file mode 100644 index 0000000..827e140 --- /dev/null +++ b/lsp/source/name_test.go @@ -0,0 +1,161 @@ +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/syntax" +) + +func TestIncludeNameOf(t *testing.T) { + type args struct { + file uri.URI + } + + tests := []struct { + name string + args args + want string + }{ + { + name: "file name", + args: args{ + file: uri.MustParse("base.thrift"), + }, + want: "base", + }, + { + name: "file name with dir", + args: args{ + file: uri.MustParse("/tmp/base.thrift"), + }, + want: "base", + }, + { + name: "file name with .", + args: args{ + file: uri.MustParse("/tmp/base.subpath.thrift"), + }, + want: "base.subpath", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, includeNameOf(tt.args.file)) + }) + } +} + +func TestIncludeNames(t *testing.T) { + type args struct { + cur uri.URI + includes []*syntax.Include + } + + tests := []struct { + name string + args args + wantIncludeNames []string + }{ + { + name: "case 1", + args: args{ + cur: uri.MustParse("/tmp/app.thrift"), + includes: []*syntax.Include{ + { + Path: &syntax.Token{Text: "../../base.sub.thrift"}, + }, + { + Path: &syntax.Token{Text: "user.sub.thrift"}, + }, + { + Path: &syntax.Token{Text: "app.thrift"}, + }, + }, + }, + wantIncludeNames: []string{ + "base.sub", + "user.sub", + "app", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantIncludeNames, includeNames(tt.args.cur, tt.args.includes)) + }) + } +} + +func TestParseIdent(t *testing.T) { + type args struct { + cur uri.URI + includes []*syntax.Include + identifier string + } + + tests := []struct { + name string + args args + wantInclude string + wantIdent string + }{ + { + name: "case 1", + args: args{ + cur: uri.MustParse("/tmp/app.thrift"), + includes: []*syntax.Include{ + { + Path: &syntax.Token{Text: "user.sub.thrift"}, + }, + { + Path: &syntax.Token{Text: "user.thrift"}, + }, + }, + identifier: "user.Name", + }, + wantInclude: "user", + wantIdent: "Name", + }, + { + name: "case 2", + args: args{ + cur: uri.MustParse("/tmp/app.thrift"), + includes: []*syntax.Include{ + { + Path: &syntax.Token{Text: "user.sub.thrift"}, + }, + { + Path: &syntax.Token{Text: "user.thrift"}, + }, + }, + identifier: "user.sub.Name", + }, + wantInclude: "user.sub", + wantIdent: "Name", + }, + { + name: "case 3", + args: args{ + cur: uri.MustParse("/tmp/app.thrift"), + includes: []*syntax.Include{ + { + Path: &syntax.Token{Text: "user.thrift"}, + }, + }, + identifier: "user.sub.Name", + }, + wantInclude: "user", + wantIdent: "sub.Name", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotInclude, gotIdent := parseIdent(tt.args.cur, tt.args.includes, tt.args.identifier) + assert.Equal(t, tt.wantInclude, gotInclude) + assert.Equal(t, tt.wantIdent, gotIdent) + }) + } +} diff --git a/lsp/diagnostic/parse.go b/lsp/source/parse.go similarity index 98% rename from lsp/diagnostic/parse.go rename to lsp/source/parse.go index 85f3eb2..7407653 100644 --- a/lsp/diagnostic/parse.go +++ b/lsp/source/parse.go @@ -1,4 +1,4 @@ -package diagnostic +package source import ( "context" diff --git a/lsp/completion/provider.go b/lsp/source/provider.go similarity index 99% rename from lsp/completion/provider.go rename to lsp/source/provider.go index c65df1a..d5857a8 100644 --- a/lsp/completion/provider.go +++ b/lsp/source/provider.go @@ -1,4 +1,4 @@ -package completion +package source import ( "context" diff --git a/lsp/codejump/reference.go b/lsp/source/reference.go similarity index 94% rename from lsp/codejump/reference.go rename to lsp/source/reference.go index f65462a..57844c1 100644 --- a/lsp/codejump/reference.go +++ b/lsp/source/reference.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" @@ -11,7 +11,6 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/lsputils" "github.com/karitham/thrift-ls/syntax" ) @@ -118,7 +117,7 @@ func searchDefinitionReferences(ctx context.Context, ss *cache.Snapshot, file ur parent := target.parent switch parent.(type) { case *syntax.Const: - typeName := fmt.Sprintf("%s.%s", lsputils.GetIncludeName(file), id.Text) + typeName := fmt.Sprintf("%s.%s", includeNameOf(file), id.Text) return searchConstValueIdentifierReferences(ctx, ss, file, typeName) case *syntax.EnumValue: @@ -127,20 +126,20 @@ func searchDefinitionReferences(ctx context.Context, ss *cache.Snapshot, file ur return res, err } - typeName := fmt.Sprintf("%s.%s.%s", lsputils.GetIncludeName(file), enum.Name.Text, id.Text) + typeName := fmt.Sprintf("%s.%s.%s", includeNameOf(file), enum.Name.Text, id.Text) return searchConstValueIdentifierReferences(ctx, ss, file, typeName) case *syntax.Service: svcName := id.Text if strings.Contains(svcName, ".") { - include, _ := lsputils.ParseIdent(file, pf.AST().Includes(), svcName) + include, _ := parseIdent(file, pf.AST().Includes(), svcName) resolver := ss.Resolver() if path := resolver.GetIncludePath(pf.AST(), include); path != "" { file = resolver.ResolveInclude(file, path) } } else { - svcName = fmt.Sprintf("%s.%s", lsputils.GetIncludeName(file), svcName) + svcName = fmt.Sprintf("%s.%s", includeNameOf(file), svcName) } return searchServiceReferences(ctx, ss, file, svcName) @@ -155,7 +154,7 @@ func searchDefinitionReferences(ctx context.Context, ss *cache.Snapshot, file ur return res, err } - typeName := fmt.Sprintf("%s.%s", lsputils.GetIncludeName(file), id.Text) + typeName := fmt.Sprintf("%s.%s", includeNameOf(file), id.Text) return searchIdentifierReferences(ctx, ss, file, typeName, kind) } @@ -233,7 +232,7 @@ func searchTypeNameReferences(ctx context.Context, ss *cache.Snapshot, file uri. func searchServiceReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, svcName string) (res []referenceHit, err error) { slog.Debug("searching service references", "file", file, "svcName", svcName) - locations, err := searchServiceDefinitionReferences(ctx, ss, file, strings.TrimPrefix(svcName, fmt.Sprintf("%s.", lsputils.GetIncludeName(file)))) + locations, err := searchServiceDefinitionReferences(ctx, ss, file, strings.TrimPrefix(svcName, fmt.Sprintf("%s.", includeNameOf(file)))) if err != nil { return nil, err } @@ -299,7 +298,7 @@ func searchIdentifierReferences(ctx context.Context, ss *cache.Snapshot, file ur slog.Debug("searching identifier references", "file", file, "typeName", typeName) locations, err := searchDefinitionIdentifierReferences(ctx, ss, file, - strings.TrimPrefix(typeName, fmt.Sprintf("%s.", lsputils.GetIncludeName(file))), definitionType) + strings.TrimPrefix(typeName, fmt.Sprintf("%s.", includeNameOf(file))), definitionType) if err != nil { return nil, err } @@ -445,7 +444,7 @@ func searchConstValueReferences(ctx context.Context, ss *cache.Snapshot, file ur // searchConstValueIdentifierReferences finds usages of a const or enum // value name: field default values and const values. func searchConstValueIdentifierReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, valueName string) (res []referenceHit, err error) { - locations, err := searchConstValueIdentifierReference(ctx, ss, file, strings.TrimPrefix(valueName, fmt.Sprintf("%s.", lsputils.GetIncludeName(file)))) + locations, err := searchConstValueIdentifierReference(ctx, ss, file, strings.TrimPrefix(valueName, fmt.Sprintf("%s.", includeNameOf(file)))) if err != nil { return nil, err } diff --git a/lsp/codejump/reference_test.go b/lsp/source/reference_test.go similarity index 99% rename from lsp/codejump/reference_test.go rename to lsp/source/reference_test.go index 88b0f3f..23a7ecf 100644 --- a/lsp/codejump/reference_test.go +++ b/lsp/source/reference_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" diff --git a/lsp/codejump/rename.go b/lsp/source/rename.go similarity index 94% rename from lsp/codejump/rename.go rename to lsp/source/rename.go index f6be1d1..4e5bfa3 100644 --- a/lsp/codejump/rename.go +++ b/lsp/source/rename.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" @@ -9,7 +9,6 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/lsputils" "github.com/karitham/thrift-ls/syntax" ) @@ -70,9 +69,9 @@ func Rename(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol. case TargetService: svcName := target.identifier().Text if !strings.Contains(svcName, ".") { - svcName = fmt.Sprintf("%s.%s", lsputils.GetIncludeName(file), svcName) + svcName = fmt.Sprintf("%s.%s", includeNameOf(file), svcName) } else { - include, _ := lsputils.ParseIdent(file, pf.AST().Includes(), svcName) + include, _ := parseIdent(file, pf.AST().Includes(), svcName) resolver := ss.Resolver() if path := resolver.GetIncludePath(pf.AST(), include); path != "" { diff --git a/lsp/codejump/rename_test.go b/lsp/source/rename_test.go similarity index 99% rename from lsp/codejump/rename_test.go rename to lsp/source/rename_test.go index 76dae90..b5b46de 100644 --- a/lsp/codejump/rename_test.go +++ b/lsp/source/rename_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" diff --git a/lsp/semantic/semantic.go b/lsp/source/semantic.go similarity index 97% rename from lsp/semantic/semantic.go rename to lsp/source/semantic.go index 83341db..64216c9 100644 --- a/lsp/semantic/semantic.go +++ b/lsp/source/semantic.go @@ -1,7 +1,7 @@ // Package semantic computes LSP semantic tokens for a thrift document: // keywords, types, definition names, comments, strings, and numbers. // Pure over the snapshot: parsing and file I/O happen in the caller. -package semantic +package source import ( "context" @@ -61,7 +61,7 @@ func Tokens(ctx context.Context, ss *cache.Snapshot, file uri.URI) ([]uint32, er prevLine, prevChar := 0, 0 for i, tok := range doc.Tokens { - typ, ok := classify(i, tok, names, types) + typ, ok := classifyToken(i, tok, names, types) if !ok { continue } @@ -91,7 +91,7 @@ func Tokens(ctx context.Context, ss *cache.Snapshot, file uri.URI) ([]uint32, er // classify maps a token to its semantic type. Definition names win over // type keywords so a field named "string" stays a property; type // references win over keywords so "string" in a type position is a type. -func classify(i int, tok syntax.Token, names map[int]int, types map[int]bool) (int, bool) { +func classifyToken(i int, tok syntax.Token, names map[int]int, types map[int]bool) (int, bool) { if syntax.IsComment(tok.Kind) { return tokComment, true } diff --git a/lsp/diagnostic/semantic_analysis.go b/lsp/source/semantic_analysis.go similarity index 97% rename from lsp/diagnostic/semantic_analysis.go rename to lsp/source/semantic_analysis.go index 292deed..9b0ca93 100644 --- a/lsp/diagnostic/semantic_analysis.go +++ b/lsp/source/semantic_analysis.go @@ -1,4 +1,4 @@ -package diagnostic +package source import ( "context" @@ -10,7 +10,6 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/codejump" "github.com/karitham/thrift-ls/syntax" ) @@ -214,7 +213,7 @@ func (s *SemanticAnalysis) checkConstValueExist(ctx context.Context, ss *cache.S return res } - _, id, err := codejump.FindConstValueDefinition(ctx, ss, file, pf.AST(), cst) + _, id, err := FindConstValueDefinition(ctx, ss, file, pf.AST(), cst) if err != nil || id == nil { res = append(res, protocol.Diagnostic{ Range: nodeRange(pf.AST(), cst), @@ -344,7 +343,7 @@ func (s *SemanticAnalysis) checkTypeExist(ctx context.Context, ss *cache.Snapsho case syntax.TypeBase: return nil case syntax.TypeIdent: - _, id, _, err := codejump.FindTypeDefinition(ctx, ss, file, pf.AST(), ft) + _, id, _, err := FindTypeDefinition(ctx, ss, file, pf.AST(), ft) if err != nil || id == nil { res = append(res, protocol.Diagnostic{ Range: nodeRange(pf.AST(), ft.Ident), diff --git a/lsp/diagnostic/semantic_analysis_test.go b/lsp/source/semantic_analysis_test.go similarity index 99% rename from lsp/diagnostic/semantic_analysis_test.go rename to lsp/source/semantic_analysis_test.go index 2c7cc90..385503d 100644 --- a/lsp/diagnostic/semantic_analysis_test.go +++ b/lsp/source/semantic_analysis_test.go @@ -1,4 +1,4 @@ -package diagnostic +package source import ( "context" diff --git a/lsp/completion/semantic_based_completion.go b/lsp/source/semantic_based_completion.go similarity index 97% rename from lsp/completion/semantic_based_completion.go rename to lsp/source/semantic_based_completion.go index a016716..8346c2f 100644 --- a/lsp/completion/semantic_based_completion.go +++ b/lsp/source/semantic_based_completion.go @@ -1,4 +1,4 @@ -package completion +package source import ( "context" diff --git a/lsp/completion/semantic_completion.go b/lsp/source/semantic_completion.go similarity index 99% rename from lsp/completion/semantic_completion.go rename to lsp/source/semantic_completion.go index 5c50a89..293beae 100644 --- a/lsp/completion/semantic_completion.go +++ b/lsp/source/semantic_completion.go @@ -1,4 +1,4 @@ -package completion +package source import ( "context" diff --git a/lsp/semantic/semantic_test.go b/lsp/source/semantic_test.go similarity index 99% rename from lsp/semantic/semantic_test.go rename to lsp/source/semantic_test.go index 0504c18..9e8cfa8 100644 --- a/lsp/semantic/semantic_test.go +++ b/lsp/source/semantic_test.go @@ -1,4 +1,4 @@ -package semantic +package source import ( "strings" diff --git a/lsp/completion/slot_completion_test.go b/lsp/source/slot_completion_test.go similarity index 99% rename from lsp/completion/slot_completion_test.go rename to lsp/source/slot_completion_test.go index 58cbfb1..46dcad4 100644 --- a/lsp/completion/slot_completion_test.go +++ b/lsp/source/slot_completion_test.go @@ -1,4 +1,4 @@ -package completion +package source import ( "os" diff --git a/lsp/codejump/target.go b/lsp/source/target.go similarity index 99% rename from lsp/codejump/target.go rename to lsp/source/target.go index 6d43ac3..44978a8 100644 --- a/lsp/codejump/target.go +++ b/lsp/source/target.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" diff --git a/lsp/codejump/target_test.go b/lsp/source/target_test.go similarity index 99% rename from lsp/codejump/target_test.go rename to lsp/source/target_test.go index e8ad4b2..9170b7c 100644 --- a/lsp/codejump/target_test.go +++ b/lsp/source/target_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "testing" diff --git a/lsp/completion/token_completion.go b/lsp/source/token_completion.go similarity index 99% rename from lsp/completion/token_completion.go rename to lsp/source/token_completion.go index 109c5c7..de17642 100644 --- a/lsp/completion/token_completion.go +++ b/lsp/source/token_completion.go @@ -1,4 +1,4 @@ -package completion +package source import ( "context" diff --git a/lsp/codejump/type_definition.go b/lsp/source/type_definition.go similarity index 99% rename from lsp/codejump/type_definition.go rename to lsp/source/type_definition.go index f32de48..b0706a8 100644 --- a/lsp/codejump/type_definition.go +++ b/lsp/source/type_definition.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" diff --git a/lsp/codejump/type_definition_test.go b/lsp/source/type_definition_test.go similarity index 99% rename from lsp/codejump/type_definition_test.go rename to lsp/source/type_definition_test.go index f9c03da..4d27dc6 100644 --- a/lsp/codejump/type_definition_test.go +++ b/lsp/source/type_definition_test.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" diff --git a/lsp/completion/types.go b/lsp/source/types.go similarity index 97% rename from lsp/completion/types.go rename to lsp/source/types.go index 47a8cd0..5940b2f 100644 --- a/lsp/completion/types.go +++ b/lsp/source/types.go @@ -1,4 +1,4 @@ -package completion +package source import ( "go.lsp.dev/protocol" diff --git a/lsp/codejump/utils.go b/lsp/source/utils.go similarity index 95% rename from lsp/codejump/utils.go rename to lsp/source/utils.go index 811c5da..ee76ec0 100644 --- a/lsp/codejump/utils.go +++ b/lsp/source/utils.go @@ -1,4 +1,4 @@ -package codejump +package source import ( "context" @@ -7,7 +7,6 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/lsputils" "github.com/karitham/thrift-ls/syntax" ) @@ -32,7 +31,7 @@ const ( // transitively included, so a type visible through a multi-hop include // chain (A includes B includes C) is found. func definitionFiles(ctx context.Context, ss *cache.Snapshot, file uri.URI, ast *syntax.Document, name string) []uri.URI { - include, _ := lsputils.ParseIdent(file, ast.Includes(), name) + include, _ := parseIdent(file, ast.Includes(), name) if include != "" { resolver := ss.Resolver() @@ -64,7 +63,7 @@ func definitionFiles(ctx context.Context, ss *cache.Snapshot, file uri.URI, ast } for _, inc := range doc.Includes() { - if path := lsputils.IncludePathText(inc); path != "" { + if path := inc.PathText(); path != "" { incFile := resolver.ResolveInclude(f, path) if seen[incFile] { continue diff --git a/lsp/symbols/workspace.go b/lsp/source/workspace.go similarity index 57% rename from lsp/symbols/workspace.go rename to lsp/source/workspace.go index 5410e2a..4e28e40 100644 --- a/lsp/symbols/workspace.go +++ b/lsp/source/workspace.go @@ -1,8 +1,7 @@ -package symbols +package source import ( "context" - "sort" "strings" "go.lsp.dev/protocol" @@ -11,37 +10,26 @@ import ( "github.com/karitham/thrift-ls/lsp/cache" ) -// WorkspaceSymbols returns the workspace symbols matching query: every -// top-level definition and its members across all workspace folders, -// folders and files ordered by URI, symbols in source order. An empty -// query matches everything; the result is capped at maxResults (0 means -// unlimited). Matching is case-insensitive substring on the symbol name. -func WorkspaceSymbols(ctx context.Context, session *cache.Session, query string, maxResults int) []protocol.SymbolInformation { +// WorkspaceSymbols returns the workspace symbols matching query among the +// given files: every top-level definition and its members, files ordered by +// URI, symbols in source order. An empty query matches everything; the +// result is capped at maxResults (0 means unlimited). Matching is +// case-insensitive substring on the symbol name. +func WorkspaceSymbols(ctx context.Context, ss *cache.Snapshot, files []uri.URI, query string, maxResults int) []protocol.SymbolInformation { res := make([]protocol.SymbolInformation, 0, 64) q := strings.ToLower(query) - views := session.Views() - sort.Slice(views, func(i, j int) bool { return views[i].Folder() < views[j].Folder() }) - - for _, view := range views { - snapshot, release := view.Snapshot() - - for _, file := range view.KnownFiles() { - for _, sym := range documentSymbolsFlat(ctx, snapshot, file) { - if q != "" && !strings.Contains(strings.ToLower(sym.Name), q) { - continue - } - - res = append(res, sym) - if maxResults > 0 && len(res) >= maxResults { - release() + for _, file := range files { + for _, sym := range documentSymbolsFlat(ctx, ss, file) { + if q != "" && !strings.Contains(strings.ToLower(sym.Name), q) { + continue + } - return res - } + res = append(res, sym) + if maxResults > 0 && len(res) >= maxResults { + return res } } - - release() } return res diff --git a/lsp/symbols/workspace_test.go b/lsp/source/workspace_test.go similarity index 89% rename from lsp/symbols/workspace_test.go rename to lsp/source/workspace_test.go index fbdd405..97856dd 100644 --- a/lsp/symbols/workspace_test.go +++ b/lsp/source/workspace_test.go @@ -1,8 +1,10 @@ -package symbols +package source import ( + "context" "os" "path/filepath" + "sort" "testing" "github.com/stretchr/testify/assert" @@ -59,6 +61,30 @@ func openTree(t *testing.T, session *cache.Session, dir string, only map[string] })) } +// allWorkspaceSymbols mirrors the server's Symbols handler: one snapshot +// per view (folders ordered by URI), querying each view's known files. +func allWorkspaceSymbols(ctx context.Context, session *cache.Session, query string, maxResults int) []protocol.SymbolInformation { + var res []protocol.SymbolInformation + + views := session.Views() + sort.Slice(views, func(i, j int) bool { return views[i].Folder() < views[j].Folder() }) + + for _, view := range views { + ss, release := view.Snapshot() + + syms := WorkspaceSymbols(ctx, ss, view.KnownFiles(), query, maxResults-len(res)) + + release() + + res = append(res, syms...) + if maxResults > 0 && len(res) >= maxResults { + break + } + } + + return res +} + func TestWorkspaceSymbols(t *testing.T) { twoFiles := map[string]string{ "mobile_suit.thrift": `struct MobileSuit { @@ -185,7 +211,7 @@ struct C { 1: string x }`, openTree(t, session, dir, tt.only) } - syms := WorkspaceSymbols(t.Context(), session, tt.query, tt.max) + syms := allWorkspaceSymbols(t.Context(), session, tt.query, tt.max) names := make([]string, len(syms)) for i, s := range syms { @@ -239,7 +265,7 @@ typedef string PilotName`, {"PilotName", protocol.SymbolKindTypeParameter, 14, 15}, } - syms := WorkspaceSymbols(t.Context(), session, "", 0) + syms := allWorkspaceSymbols(t.Context(), session, "", 0) byName := make(map[string]protocol.SymbolInformation, len(syms)) for _, s := range syms { @@ -289,7 +315,7 @@ service Federation { {"Deploy", new("Federation")}, } - syms := WorkspaceSymbols(t.Context(), session, "", 0) + syms := allWorkspaceSymbols(t.Context(), session, "", 0) byName := make(map[string]protocol.SymbolInformation, len(syms)) for _, s := range syms { @@ -333,7 +359,7 @@ exception BayFull { session := cache.NewSession(cache.New(nil)) openTree(t, session, dir, nil) - syms := WorkspaceSymbols(t.Context(), session, "", 0) + syms := allWorkspaceSymbols(t.Context(), session, "", 0) byName := make(map[string]protocol.SymbolInformation, len(syms)) for _, s := range syms { diff --git a/lsp/symbols.go b/lsp/symbols.go index 259a098..1438b70 100644 --- a/lsp/symbols.go +++ b/lsp/symbols.go @@ -5,25 +5,19 @@ import ( "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/symbols" + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/lsp/source" ) func (s *Server) documentSymbol(ctx context.Context, params *protocol.DocumentSymbolParams) (result protocol.DocumentSymbolSlice, err error) { - file := params.TextDocument.URI + return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (protocol.DocumentSymbolSlice, error) { + syms := source.DocumentSymbols(ctx, ss, params.TextDocument.URI) - view, err := s.session.ViewOf(file) - if err != nil { - return nil, err - } + result := make(protocol.DocumentSymbolSlice, 0, len(syms)) + for i := range syms { + result = append(result, *syms[i]) + } - ss, release := view.Snapshot() - defer release() - - syms := symbols.DocumentSymbols(ctx, ss, file) - - for i := range syms { - result = append(result, *syms[i]) - } - - return result, err + return result, nil + }) } diff --git a/syntax/ast.go b/syntax/ast.go index bb4ca82..adb67c3 100644 --- a/syntax/ast.go +++ b/syntax/ast.go @@ -1,5 +1,7 @@ package syntax +import "strings" + // This file defines the abstract syntax tree produced by the parser. // // Every node spans a contiguous range of tokens in the Document's token @@ -92,6 +94,16 @@ type Include struct { Path *Token // the path string literal } +// PathText returns the include path without its quotes. The token keeps +// the raw literal text, including the surrounding quotes. +func (i *Include) PathText() string { + if i == nil || i.Path == nil { + return "" + } + + return strings.Trim(i.Path.Text, "\"'") +} + // CPPInclude is a C++ include: cpp_include "path". type CPPInclude struct { nodeBase -- 2.51.2