From 7ca1e80397fae41ae76d6e4dc8615a803f87350c Mon Sep 17 00:00:00 2001 From: karitham Date: Fri, 7 Aug 2026 21:03:29 +0200 Subject: [PATCH] lsp: folding ranges --- lsp/folding.go | 23 ++++ lsp/folding/folding.go | 225 ++++++++++++++++++++++++++++++++++++ lsp/folding/folding_test.go | 161 ++++++++++++++++++++++++++ lsp/initialize.go | 3 +- lsp/server.go | 2 +- 5 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 lsp/folding.go create mode 100644 lsp/folding/folding.go create mode 100644 lsp/folding/folding_test.go diff --git a/lsp/folding.go b/lsp/folding.go new file mode 100644 index 0000000..b26946b --- /dev/null +++ b/lsp/folding.go @@ -0,0 +1,23 @@ +package lsp + +import ( + "context" + + "go.lsp.dev/protocol" + + "github.com/karitham/thrift-ls/lsp/folding" +) + +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 +} diff --git a/lsp/folding/folding.go b/lsp/folding/folding.go new file mode 100644 index 0000000..6efa70b --- /dev/null +++ b/lsp/folding/folding.go @@ -0,0 +1,225 @@ +// Package folding computes document folding ranges: braced bodies +// (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 + +import ( + "context" + "sort" + "strings" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/syntax" +) + +// Ranges returns the folding ranges of a file, in source order. Degenerate +// single-line ranges are omitted. +func Ranges(ctx context.Context, ss *cache.Snapshot, file uri.URI) []protocol.FoldingRange { + pf, err := ss.Parse(ctx, file) + if err != nil || pf.AST() == nil { + return nil + } + + doc := pf.AST() + ranges := make([]protocol.FoldingRange, 0, 16) + + for _, node := range doc.Nodes { + switch v := node.(type) { + case *syntax.Struct, *syntax.Enum, *syntax.Service: + if r, ok := bracedRange(doc, node); ok { + ranges = append(ranges, r) + } + case *syntax.Const: + if v.Value != nil { + switch v.Value.Kind { + case syntax.ValueList, syntax.ValueMap: + if r, ok := spanRange(doc, v.Value.TokStart(), v.Value.TokEnd()); ok { + ranges = append(ranges, r) + } + } + } + } + } + + if ann := nodeAnnotations(doc, doc.Nodes); len(ann) > 0 { + for _, a := range ann { + if r, ok := spanRange(doc, a.TokStart(), a.TokEnd()); ok { + ranges = append(ranges, r) + } + } + } + + ranges = append(ranges, commentBlocks(doc)...) + + sort.Slice(ranges, func(i, j int) bool { + if ranges[i].StartLine != ranges[j].StartLine { + return ranges[i].StartLine < ranges[j].StartLine + } + + return startChar(ranges[i]) < startChar(ranges[j]) + }) + + return ranges +} + +// startChar returns the start character of a range, defaulting to 0. +func startChar(r protocol.FoldingRange) uint32 { + if r.StartCharacter == nil { + return 0 + } + + return *r.StartCharacter +} + +// bracedRange returns the fold range of a brace-delimited body: from the +// opening brace to the closing one. +func bracedRange(doc *syntax.Document, n syntax.Node) (protocol.FoldingRange, bool) { + open := -1 + + for i := n.TokStart(); i <= n.TokEnd(); i++ { + if doc.Tokens[i].Kind == syntax.TokenLBrace { + open = i + + break + } + } + + if open < 0 { + return protocol.FoldingRange{}, false + } + + close := open + for i := n.TokEnd(); i > open; i-- { + if doc.Tokens[i].Kind == syntax.TokenRBrace { + close = i + + break + } + } + + return spanRange(doc, open, close) +} + +// nodeAnnotations collects the annotations of every top-level node, in +// source order. +func nodeAnnotations(doc *syntax.Document, nodes []syntax.Node) []*syntax.Annotations { + var anns []*syntax.Annotations + + for _, n := range nodes { + if ann := nodeAnnotation(n); ann != nil { + anns = append(anns, ann) + } + } + + return anns +} + +func nodeAnnotation(n syntax.Node) *syntax.Annotations { + switch v := n.(type) { + case *syntax.Struct: + return v.Annotations + case *syntax.Enum: + return v.Annotations + case *syntax.Service: + return v.Annotations + case *syntax.Const: + return nil + case *syntax.Typedef: + return v.Annotations + case *syntax.Namespace: + return v.Annotations + } + + return nil +} + +// spanRange converts the token span [start, end] into a folding range. +// Degenerate single-line spans yield no range. +func spanRange(doc *syntax.Document, start, end int) (protocol.FoldingRange, bool) { + s := doc.TokenPosition(start) + e := doc.TokenEndPosition(end) + + if s.Line == e.Line { + return protocol.FoldingRange{}, false + } + + return protocol.FoldingRange{ + StartLine: uint32(s.Line - 1), + StartCharacter: new(uint32(s.Col - 1)), + EndLine: uint32(e.Line - 1), + EndCharacter: new(uint32(e.Col - 1)), + }, true +} + +// commentSpanRange is spanRange for comment folds. +func commentSpanRange(doc *syntax.Document, start, end int) (protocol.FoldingRange, bool) { + r, ok := spanRange(doc, start, end) + if ok { + r.Kind = protocol.FoldingRangeKindComment + } + + return r, ok +} + +// blockCommentSpan folds a multi-line block comment. The token records +// only its start position, so the end line is derived from the text. +func blockCommentSpan(doc *syntax.Document, idx int) (protocol.FoldingRange, bool) { + tok := doc.Tokens[idx] + start := doc.TokenPosition(idx) + + lines := strings.Count(tok.Text, "\n") + if lines == 0 { + return protocol.FoldingRange{}, false + } + + last := tok.Text[strings.LastIndex(tok.Text, "\n")+1:] + + return protocol.FoldingRange{ + StartLine: uint32(start.Line - 1), + StartCharacter: new(uint32(start.Col - 1)), + EndLine: uint32(start.Line - 1 + lines), + EndCharacter: new(uint32(len(last))), + Kind: protocol.FoldingRangeKindComment, + }, true +} + +// commentBlocks folds consecutive same-line comments on consecutive +// source lines, and multi-line block comments, into comment fold ranges. +func commentBlocks(doc *syntax.Document) []protocol.FoldingRange { + var ranges []protocol.FoldingRange + + for i := 0; i < len(doc.Tokens); i++ { + tok := doc.Tokens[i] + if !isLineComment(tok.Kind) { + if tok.Kind == syntax.TokenBlockComment || tok.Kind == syntax.TokenDocComment { + if r, ok := blockCommentSpan(doc, i); ok { + ranges = append(ranges, r) + } + } + + continue + } + + // A run of line comments on consecutive lines folds as a block. + start := i + for i+1 < len(doc.Tokens) && isLineComment(doc.Tokens[i+1].Kind) && doc.Tokens[i+1].Line == doc.Tokens[i].Line+1 { + i++ + } + + if i > start { + if r, ok := commentSpanRange(doc, start, i); ok { + ranges = append(ranges, r) + } + } + } + + return ranges +} + +func isLineComment(k syntax.TokenKind) bool { + return k == syntax.TokenLineComment || k == syntax.TokenAnnotation +} diff --git a/lsp/folding/folding_test.go b/lsp/folding/folding_test.go new file mode 100644 index 0000000..537d761 --- /dev/null +++ b/lsp/folding/folding_test.go @@ -0,0 +1,161 @@ +package folding + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/lsp/cache" +) + +// foldCase is one folding range expectation: the range of lines it covers. +type foldCase struct { + startLine uint32 + endLine uint32 + kind protocol.FoldingRangeKind +} + +func foldingRanges(t *testing.T, src string) []protocol.FoldingRange { + t.Helper() + + dir := t.TempDir() + file := uri.File(filepath.Join(dir, "test.thrift")) + require.NoError(t, os.WriteFile(filepath.Join(dir, "test.thrift"), []byte(src), 0o644)) + + view := cache.NewView("test", uri.File(dir), cache.NewOverlayFS(cache.New(nil)), nil) + view.FileChange(t.Context(), []*cache.FileChange{{ + URI: file, + Version: 0, + Content: []byte(src), + From: cache.FileChangeTypeInitialize, + }}) + + ss, release := view.Snapshot() + defer release() + + return Ranges(t.Context(), ss, file) +} + +func TestFoldingRanges(t *testing.T) { + tests := []struct { + name string + src string + want []foldCase + }{ + { + name: "empty file", + src: "", + want: []foldCase{}, + }, + { + name: "struct body", + src: "struct S {\n 1: i32 a\n}", + want: []foldCase{{0, 2, ""}}, + }, + { + name: "struct on one line folds nothing", + src: "struct S { 1: i32 a }", + want: []foldCase{}, + }, + { + name: "enum and service bodies", + src: "enum E {\n A,\n}\n\nservice S {\n void f(),\n}", + want: []foldCase{{0, 2, ""}, {4, 6, ""}}, + }, + { + name: "union and exception bodies", + src: "union U {\n 1: i32 a,\n}\n\nexception X {\n 1: string m,\n}", + want: []foldCase{{0, 2, ""}, {4, 6, ""}}, + }, + { + name: "const list and map values", + src: "const list l = [\n 1,\n 2,\n]\nconst map m = {\n \"a\": 1,\n}", + want: []foldCase{{0, 3, ""}, {4, 6, ""}}, + }, + { + name: "annotations fold", + src: "struct S {\n}\n(\n a = \"1\",\n)", + want: []foldCase{{0, 1, ""}, {2, 4, ""}}, + }, + { + name: "comment block folds", + src: "// one\n// two\n// three\nstruct S {}", + want: []foldCase{{0, 2, protocol.FoldingRangeKindComment}}, + }, + { + name: "comment run broken by a blank line does not fold", + src: "// one\n\n// two\nstruct S {}", + want: []foldCase{}, + }, + { + name: "multi-line doc comment folds", + src: "/**\n * doc\n */\nstruct S {}", + want: []foldCase{{0, 2, protocol.FoldingRangeKindComment}}, + }, + { + name: "annotation lines fold as comments", + src: "@deprecation.Deprecated{}\n@naming.X{'a': 'b'}\nstruct S {}", + want: []foldCase{{0, 1, protocol.FoldingRangeKindComment}}, + }, + { + name: "mixed bodies, values, and comments", + src: `// header +// comments +struct S { + 1: i32 a, +} + +const list l = [ + 1, +]`, + want: []foldCase{ + {0, 1, protocol.FoldingRangeKindComment}, + {2, 4, ""}, + {6, 8, ""}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ranges := foldingRanges(t, tt.src) + + got := make([]foldCase, len(ranges)) + for i, r := range ranges { + kind := r.Kind + got[i] = foldCase{r.StartLine, r.EndLine, kind} + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFoldingRangesPositions(t *testing.T) { + src := "struct S {\n 1: i32 a\n}" + + ranges := foldingRanges(t, src) + require.Len(t, ranges, 1) + + // The range spans from the opening brace to the closing one. + require.NotNil(t, ranges[0].StartCharacter) + require.NotNil(t, ranges[0].EndCharacter) + assert.Equal(t, uint32(9), *ranges[0].StartCharacter) + assert.Equal(t, uint32(1), *ranges[0].EndCharacter) +} + +// TestFoldingRangesParseErrors ensures a broken document yields no ranges +// instead of panicking. +func TestFoldingRangesParseErrors(t *testing.T) { + assert.NotPanics(t, func() { + _ = foldingRanges(t, "struct S {") + }) +} + +var _ = context.Background diff --git a/lsp/initialize.go b/lsp/initialize.go index b27a1f4..41bc5ec 100644 --- a/lsp/initialize.go +++ b/lsp/initialize.go @@ -186,7 +186,8 @@ func initializeResult() *protocol.InitializeResult { DocumentLinkProvider: &protocol.DocumentLinkOptions{ ResolveProvider: new(false), }, - ColorProvider: protocol.Boolean(false), + ColorProvider: protocol.Boolean(false), + FoldingRangeProvider: protocol.Boolean(true), WorkspaceSymbolProvider: &protocol.WorkspaceSymbolOptions{ WorkDoneProgressOptions: protocol.WorkDoneProgressOptions{ WorkDoneProgress: new(true), diff --git a/lsp/server.go b/lsp/server.go index 918db18..cc5e350 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -170,7 +170,7 @@ func (s *Server) ExecuteCommand(ctx context.Context, params *protocol.ExecuteCom } func (s *Server) FoldingRanges(ctx context.Context, params *protocol.FoldingRangeParams) (result []protocol.FoldingRange, err error) { - return []protocol.FoldingRange{}, nil + return s.foldingRanges(ctx, params) } func (s *Server) Formatting(ctx context.Context, params *protocol.DocumentFormattingParams) (result []protocol.TextEdit, err error) { -- 2.51.2