diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 4f1d83e..4b3b65a 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -3247,6 +3247,25 @@ func (s *Server) FoldingRanges(ctx context.Context, params *protocol.FoldingRang var stack []blockStart depth := 0 + // Track multi-line bracket pairs ({}, [], (), <<>>) + type bracketFrame struct { + line int + open parser.TokenKind + } + var brackets []bracketFrame + popBracket := func(open parser.TokenKind, line int) { + if n := len(brackets); n > 0 && brackets[n-1].open == open { + top := brackets[n-1] + brackets = brackets[:n-1] + if line > top.line { + ranges = append(ranges, protocol.FoldingRange{ + StartLine: uint32(top.line - 1), // convert to 0-based + EndLine: uint32(line - 1), + }) + } + } + } + for i := 0; i < n; i++ { tok := tokens[i] @@ -3285,6 +3304,17 @@ func (s *Server) FoldingRanges(ctx context.Context, params *protocol.FoldingRang }) } } + + case parser.TokOpenBrace, parser.TokOpenBracket, parser.TokOpenParen, parser.TokOpenAngle: + brackets = append(brackets, bracketFrame{line: tok.Line, open: tok.Kind}) + case parser.TokCloseBrace: + popBracket(parser.TokOpenBrace, tok.Line) + case parser.TokCloseBracket: + popBracket(parser.TokOpenBracket, tok.Line) + case parser.TokCloseParen: + popBracket(parser.TokOpenParen, tok.Line) + case parser.TokCloseAngle: + popBracket(parser.TokOpenAngle, tok.Line) } } diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 48eced8..e6d185d 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -4895,6 +4895,162 @@ end`) } } +func runFoldingRanges(t *testing.T, source string) []protocol.FoldingRange { + t.Helper() + server, cleanup := setupTestServer(t) + defer cleanup() + uri := "file:///test.ex" + server.docs.Set(uri, source) + result, err := server.FoldingRanges(context.Background(), &protocol.FoldingRangeParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(uri)}, + }, + }) + if err != nil { + t.Fatal(err) + } + return result +} + +func hasRange(ranges []protocol.FoldingRange, start, end uint32) bool { + for _, r := range ranges { + if r.StartLine == start && r.EndLine == end { + return true + } + } + return false +} + +func TestFoldingRanges_Map(t *testing.T) { + result := runFoldingRanges(t, `foo = %{ + a: 1, + b: 2 +}`) + if !hasRange(result, 0, 3) { + t.Errorf("expected map fold (0-3), got %+v", result) + } +} + +func TestFoldingRanges_NestedMaps(t *testing.T) { + result := runFoldingRanges(t, `%{ + outer: %{ + inner: 1 + } +}`) + if !hasRange(result, 0, 4) { + t.Errorf("expected outer map fold (0-4), got %+v", result) + } + if !hasRange(result, 1, 3) { + t.Errorf("expected inner map fold (1-3), got %+v", result) + } +} + +func TestFoldingRanges_List(t *testing.T) { + result := runFoldingRanges(t, `[ + 1, + 2 +]`) + if !hasRange(result, 0, 3) { + t.Errorf("expected list fold (0-3), got %+v", result) + } +} + +func TestFoldingRanges_Tuple(t *testing.T) { + result := runFoldingRanges(t, `{:ok, + :result +}`) + if !hasRange(result, 0, 2) { + t.Errorf("expected tuple fold (0-2), got %+v", result) + } +} + +func TestFoldingRanges_FunctionCall(t *testing.T) { + result := runFoldingRanges(t, `foo( + arg1, + arg2 +)`) + if !hasRange(result, 0, 3) { + t.Errorf("expected function-call fold (0-3), got %+v", result) + } +} + +func TestFoldingRanges_Binary(t *testing.T) { + result := runFoldingRanges(t, `<< + 1, 2, + 3 +>>`) + if !hasRange(result, 0, 3) { + t.Errorf("expected binary fold (0-3), got %+v", result) + } +} + +func TestFoldingRanges_SingleLineMapDoesNotFold(t *testing.T) { + result := runFoldingRanges(t, `foo = %{a: 1, b: 2}`) + if len(result) != 0 { + t.Errorf("expected no folds for single-line map, got %+v", result) + } +} + +func TestFoldingRanges_BracketsInsideStringsAreIgnored(t *testing.T) { + result := runFoldingRanges(t, `foo = "open { brace" +bar = "close } brace"`) + if len(result) != 0 { + t.Errorf("expected no folds when brackets are inside strings, got %+v", result) + } +} + +func TestFoldingRanges_BracketsInsideCommentsAreIgnored(t *testing.T) { + result := runFoldingRanges(t, `foo = 1 # open { +bar = 2 # close }`) + if len(result) != 0 { + t.Errorf("expected no folds when brackets are inside comments, got %+v", result) + } +} + +func TestFoldingRanges_BracketsSpanningHeredoc(t *testing.T) { + result := runFoldingRanges(t, `foo = %{ + doc: """ + a } looking like a closer + """, + other: 1 +}`) + if !hasRange(result, 0, 5) { + t.Errorf("expected map fold (0-5) across heredoc, got %+v", result) + } + if !hasRange(result, 1, 3) { + t.Errorf("expected heredoc fold (1-3), got %+v", result) + } +} + +func TestFoldingRanges_StrayCloserDoesNotPopDoFrame(t *testing.T) { + result := runFoldingRanges(t, `def foo do + } + :ok +end`) + if !hasRange(result, 0, 3) { + t.Errorf("expected def fold (0-3) preserved despite stray }, got %+v", result) + } +} + +func TestFoldingRanges_DoBlockWithMapBody(t *testing.T) { + result := runFoldingRanges(t, `defmodule M do + def list do + %{ + a: 1 + } + end +end`) + if !hasRange(result, 0, 6) { + t.Errorf("expected defmodule fold (0-6), got %+v", result) + } + if !hasRange(result, 1, 5) { + t.Errorf("expected def fold (1-5), got %+v", result) + } + if !hasRange(result, 2, 4) { + t.Errorf("expected map fold (2-4), got %+v", result) + } +} + // === CodeAction === func TestCodeAction_AddAlias(t *testing.T) {