diff --git a/internal/ls/folding.go b/internal/ls/folding.go new file mode 100644 index 0000000000..f2b8affe7f --- /dev/null +++ b/internal/ls/folding.go @@ -0,0 +1,541 @@ +package ls + +import ( + "cmp" + "context" + "regexp" + "slices" + "strings" + "unicode" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/astnav" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/printer" + "github.com/microsoft/typescript-go/internal/scanner" +) + +func (l *LanguageService) ProvideFoldingRange(ctx context.Context, documentURI lsproto.DocumentUri) []*lsproto.FoldingRange { + _, sourceFile := l.getProgramAndFile(documentURI) + res := l.addNodeOutliningSpans(sourceFile) + res = append(res, l.addRegionOutliningSpans(sourceFile)...) + slices.SortFunc(res, func(a, b *lsproto.FoldingRange) int { + if a.StartLine != b.StartLine { + return cmp.Compare(a.StartLine, b.StartLine) + } + if a.StartCharacter != nil && b.StartCharacter != nil { + return cmp.Compare(*a.StartCharacter, *b.StartCharacter) + } + if a.EndLine != b.EndLine { + return cmp.Compare(a.EndLine, b.EndLine) + } + if a.EndCharacter != nil && b.EndCharacter != nil { + return cmp.Compare(*a.EndCharacter, *b.EndCharacter) + } + return 0 + }) + return res +} + +func (l *LanguageService) addNodeOutliningSpans(sourceFile *ast.SourceFile) []*lsproto.FoldingRange { + depthRemaining := 40 + current := 0 + + statements := sourceFile.Statements + n := len(statements.Nodes) + var foldingRange []*lsproto.FoldingRange + for current < n { + for current < n && !ast.IsAnyImportSyntax(statements.Nodes[current]) { + foldingRange = append(foldingRange, visitNode(statements.Nodes[current], depthRemaining, sourceFile, l)...) + current++ + } + if current == n { + break + } + firstImport := current + for current < n && ast.IsAnyImportSyntax(statements.Nodes[current]) { + foldingRange = append(foldingRange, visitNode(statements.Nodes[current], depthRemaining, sourceFile, l)...) + current++ + } + lastImport := current - 1 + if lastImport != firstImport { + foldingRangeKind := lsproto.FoldingRangeKindImports + foldingRange = append(foldingRange, createFoldingRangeFromBounds( + astnav.GetStartOfNode(findChildOfKind(statements.Nodes[firstImport], + ast.KindImportKeyword, sourceFile), sourceFile, false /*includeJSDoc*/), + statements.Nodes[lastImport].End(), + foldingRangeKind, + sourceFile, + l)) + } + } + + // Visit the EOF Token so that comments which aren't attached to statements are included. + foldingRange = append(foldingRange, visitNode(sourceFile.EndOfFileToken, depthRemaining, sourceFile, l)...) + return foldingRange +} + +func (l *LanguageService) addRegionOutliningSpans(sourceFile *ast.SourceFile) []*lsproto.FoldingRange { + var regions []*lsproto.FoldingRange + var out []*lsproto.FoldingRange + lineStarts := scanner.GetLineStarts(sourceFile) + for _, currentLineStart := range lineStarts { + lineEnd := scanner.GetLineEndOfPosition(sourceFile, int(currentLineStart)) + lineText := sourceFile.Text()[currentLineStart:lineEnd] + result := parseRegionDelimiter(lineText) + if result == nil || isInComment(sourceFile, int(currentLineStart), nil) != nil { + continue + } + + if result.isStart { + commentStart := l.createLspPosition(strings.Index(sourceFile.Text()[currentLineStart:lineEnd], "//")+int(currentLineStart), sourceFile) + foldingRangeKindRegion := lsproto.FoldingRangeKindRegion + collapsedText := "#region" + if result.name != "" { + collapsedText = result.name + } + // Our spans start out with some initial data. + // On every `#endregion`, we'll come back to these `FoldingRange`s + // and fill in their EndLine/EndCharacter. + regions = append(regions, &lsproto.FoldingRange{ + StartLine: commentStart.Line, + StartCharacter: &commentStart.Character, + Kind: &foldingRangeKindRegion, + CollapsedText: &collapsedText, + }) + } else { + if len(regions) > 0 { + region := regions[len(regions)-1] + regions = regions[:len(regions)-1] + endingPosition := l.createLspPosition(lineEnd, sourceFile) + region.EndLine = endingPosition.Line + region.EndCharacter = &endingPosition.Character + out = append(out, region) + } + } + } + return out +} + +func visitNode(n *ast.Node, depthRemaining int, sourceFile *ast.SourceFile, l *LanguageService) []*lsproto.FoldingRange { + if depthRemaining == 0 { + return nil + } + // cancellationToken.throwIfCancellationRequested(); + var foldingRange []*lsproto.FoldingRange + // !!! remove !ast.IsBinaryExpression(n) after JSDoc implementation + if (!ast.IsBinaryExpression(n) && ast.IsDeclaration(n)) || ast.IsVariableStatement(n) || ast.IsReturnStatement(n) || ast.IsCallOrNewExpression(n) || n.Kind == ast.KindEndOfFile { + foldingRange = append(foldingRange, addOutliningForLeadingCommentsForNode(n, sourceFile, l)...) + } + if ast.IsFunctionLike(n) && n.Parent != nil && ast.IsBinaryExpression(n.Parent) && n.Parent.AsBinaryExpression().Left != nil && ast.IsPropertyAccessExpression(n.Parent.AsBinaryExpression().Left) { + foldingRange = append(foldingRange, addOutliningForLeadingCommentsForNode(n.Parent.AsBinaryExpression().Left, sourceFile, l)...) + } + if ast.IsBlock(n) { + statements := n.AsBlock().Statements + if statements != nil { + foldingRange = append(foldingRange, addOutliningForLeadingCommentsForPos(statements.End(), sourceFile, l)...) + } + } + if ast.IsModuleBlock(n) { + statements := n.AsModuleBlock().Statements + if statements != nil { + foldingRange = append(foldingRange, addOutliningForLeadingCommentsForPos(statements.End(), sourceFile, l)...) + } + } + if ast.IsClassLike(n) || ast.IsInterfaceDeclaration(n) { + var members *ast.NodeList + if ast.IsClassDeclaration(n) { + members = n.AsClassDeclaration().Members + } else if ast.IsClassExpression(n) { + members = n.AsClassExpression().Members + } else { + members = n.AsInterfaceDeclaration().Members + } + if members != nil { + foldingRange = append(foldingRange, addOutliningForLeadingCommentsForPos(members.End(), sourceFile, l)...) + } + } + + span := getOutliningSpanForNode(n, sourceFile, l) + if span != nil { + foldingRange = append(foldingRange, span) + } + + depthRemaining-- + if ast.IsCallExpression(n) { + depthRemaining++ + expressionNodes := visitNode(n.Expression(), depthRemaining, sourceFile, l) + if expressionNodes != nil { + foldingRange = append(foldingRange, expressionNodes...) + } + depthRemaining-- + for _, arg := range n.Arguments() { + if arg != nil { + foldingRange = append(foldingRange, visitNode(arg, depthRemaining, sourceFile, l)...) + } + } + typeArguments := n.TypeArguments() + for _, typeArg := range typeArguments { + if typeArg != nil { + foldingRange = append(foldingRange, visitNode(typeArg, depthRemaining, sourceFile, l)...) + } + } + } else if ast.IsIfStatement(n) && n.AsIfStatement().ElseStatement != nil && ast.IsIfStatement(n.AsIfStatement().ElseStatement) { + // Consider an 'else if' to be on the same depth as the 'if'. + ifStatement := n.AsIfStatement() + expressionNodes := visitNode(n.Expression(), depthRemaining, sourceFile, l) + if expressionNodes != nil { + foldingRange = append(foldingRange, expressionNodes...) + } + thenNode := visitNode(ifStatement.ThenStatement, depthRemaining, sourceFile, l) + if thenNode != nil { + foldingRange = append(foldingRange, thenNode...) + } + depthRemaining++ + elseNode := visitNode(ifStatement.ElseStatement, depthRemaining, sourceFile, l) + if elseNode != nil { + foldingRange = append(foldingRange, elseNode...) + } + depthRemaining-- + } else { + visit := func(node *ast.Node) bool { + childNode := visitNode(node, depthRemaining, sourceFile, l) + if childNode != nil { + foldingRange = append(foldingRange, childNode...) + } + return false + } + n.ForEachChild(visit) + } + depthRemaining++ + return foldingRange +} + +func addOutliningForLeadingCommentsForNode(n *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) []*lsproto.FoldingRange { + if ast.IsJsxText(n) { + return nil + } + return addOutliningForLeadingCommentsForPos(n.Pos(), sourceFile, l) +} + +func addOutliningForLeadingCommentsForPos(pos int, sourceFile *ast.SourceFile, l *LanguageService) []*lsproto.FoldingRange { + p := &printer.EmitContext{} + comments := scanner.GetLeadingCommentRanges(&printer.NewNodeFactory(p).NodeFactory, sourceFile.Text(), pos) + + var foldingRange []*lsproto.FoldingRange + firstSingleLineCommentStart := -1 + lastSingleLineCommentEnd := -1 + singleLineCommentCount := 0 + foldingRangeKindComment := lsproto.FoldingRangeKindComment + + combineAndAddMultipleSingleLineComments := func() *lsproto.FoldingRange { + // Only outline spans of two or more consecutive single line comments + if singleLineCommentCount > 1 { + return createFoldingRangeFromBounds(firstSingleLineCommentStart, lastSingleLineCommentEnd, foldingRangeKindComment, sourceFile, l) + } + return nil + } + + sourceText := sourceFile.Text() + comments(func(comment ast.CommentRange) bool { + commentPos := comment.Pos() + commentEnd := comment.End() + // cancellationToken.throwIfCancellationRequested(); + switch comment.Kind { + case ast.KindSingleLineCommentTrivia: + // never fold region delimiters into single-line comment regions + commentText := sourceText[commentPos:commentEnd] + if parseRegionDelimiter(commentText) != nil { + comments := combineAndAddMultipleSingleLineComments() + if comments != nil { + foldingRange = append(foldingRange, comments) + } + singleLineCommentCount = 0 + break + } + + // For single line comments, combine consecutive ones (2 or more) into + // a single span from the start of the first till the end of the last + if singleLineCommentCount == 0 { + firstSingleLineCommentStart = commentPos + } + lastSingleLineCommentEnd = commentEnd + singleLineCommentCount++ + break + case ast.KindMultiLineCommentTrivia: + comments := combineAndAddMultipleSingleLineComments() + if comments != nil { + foldingRange = append(foldingRange, comments) + } + foldingRange = append(foldingRange, createFoldingRangeFromBounds(commentPos, commentEnd, foldingRangeKindComment, sourceFile, l)) + singleLineCommentCount = 0 + break + default: + // Debug.assertNever(kind); + } + return true + }) + addedComments := combineAndAddMultipleSingleLineComments() + if addedComments != nil { + foldingRange = append(foldingRange, addedComments) + } + return foldingRange +} + +var regionDelimiterRegExp = regexp.MustCompile(`^#(end)?region(.*)\r?$`) + +type regionDelimiterResult struct { + isStart bool + name string +} + +func parseRegionDelimiter(lineText string) *regionDelimiterResult { + // We trim the leading whitespace and // without the regex since the + // multiple potential whitespace matches can make for some gnarly backtracking behavior + lineText = strings.TrimLeftFunc(lineText, unicode.IsSpace) + if !strings.HasPrefix(lineText, "//") { + return nil + } + lineText = strings.TrimSpace(lineText[2:]) + result := regionDelimiterRegExp.FindStringSubmatch(lineText) + if result != nil { + return ®ionDelimiterResult{ + isStart: result[1] == "", + name: strings.TrimSpace(result[2]), + } + } + return nil +} + +func getOutliningSpanForNode(n *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + switch n.Kind { + case ast.KindBlock: + if ast.IsFunctionLike(n.Parent) { + return functionSpan(n.Parent, n, sourceFile, l) + } + // Check if the block is standalone, or 'attached' to some parent statement. + // If the latter, we want to collapse the block, but consider its hint span + // to be the entire span of the parent. + switch n.Parent.Kind { + case ast.KindDoStatement, ast.KindForInStatement, ast.KindForOfStatement, ast.KindForStatement, ast.KindIfStatement, ast.KindWhileStatement, ast.KindWithStatement, ast.KindCatchClause: + return spanForNode(n, ast.KindOpenBraceToken, true /*useFullStart */, sourceFile, l) + case ast.KindTryStatement: + // Could be the try-block, or the finally-block. + tryStatement := n.Parent.AsTryStatement() + if tryStatement.TryBlock == n { + return spanForNode(n, ast.KindOpenBraceToken, true /*useFullStart */, sourceFile, l) + } else if tryStatement.FinallyBlock == n { + node := findChildOfKind(n.Parent, ast.KindFinallyKeyword, sourceFile) + if node != nil { + return spanForNode(n, ast.KindOpenBraceToken, true /*useFullStart */, sourceFile, l) + } + } + default: + // Block was a standalone block. In this case we want to only collapse + // the span of the block, independent of any parent span. + return createFoldingRange(l.createLspRangeFromNode(n, sourceFile), "", "") + } + case ast.KindModuleBlock: + return spanForNode(n, ast.KindOpenBraceToken, true /*useFullStart */, sourceFile, l) + case ast.KindClassDeclaration, ast.KindClassExpression, ast.KindInterfaceDeclaration, ast.KindEnumDeclaration, ast.KindCaseBlock, ast.KindTypeLiteral, ast.KindObjectBindingPattern: + return spanForNode(n, ast.KindOpenBraceToken, true /*useFullStart */, sourceFile, l) + case ast.KindTupleType: + return spanForNode(n, ast.KindOpenBracketToken, !ast.IsTupleTypeNode(n.Parent) /*useFullStart */, sourceFile, l) + case ast.KindCaseClause, ast.KindDefaultClause: + return spanForNodeArray(n.AsCaseOrDefaultClause().Statements, sourceFile, l) + case ast.KindObjectLiteralExpression: + return spanForNode(n, ast.KindOpenBraceToken, !ast.IsArrayLiteralExpression(n.Parent) && !ast.IsCallExpression(n.Parent) /*useFullStart */, sourceFile, l) + case ast.KindArrayLiteralExpression: + return spanForNode(n, ast.KindOpenBracketToken, !ast.IsArrayLiteralExpression(n.Parent) && !ast.IsCallExpression(n.Parent) /*useFullStart */, sourceFile, l) + case ast.KindJsxElement, ast.KindJsxFragment: + return spanForJSXElement(n, sourceFile, l) + case ast.KindJsxSelfClosingElement, ast.KindJsxOpeningElement: + return spanForJSXAttributes(n, sourceFile, l) + case ast.KindTemplateExpression, ast.KindNoSubstitutionTemplateLiteral: + return spanForTemplateLiteral(n, sourceFile, l) + case ast.KindArrayBindingPattern: + return spanForNode(n, ast.KindOpenBracketToken, !ast.IsBindingElement(n.Parent) /*useFullStart */, sourceFile, l) + case ast.KindArrowFunction: + return spanForArrowFunction(n, sourceFile, l) + case ast.KindCallExpression: + return spanForCallExpression(n, sourceFile, l) + case ast.KindParenthesizedExpression: + return spanForParenthesizedExpression(n, sourceFile, l) + case ast.KindNamedImports, ast.KindNamedExports, ast.KindImportAttributes: + return spanForImportExportElements(n, sourceFile, l) + } + return nil +} + +func spanForImportExportElements(node *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + var elements *ast.NodeList + if node.Kind == ast.KindNamedImports { + elements = node.AsNamedImports().Elements + } else if node.Kind == ast.KindNamedExports { + elements = node.AsNamedExports().Elements + } else if node.Kind == ast.KindImportAttributes { + elements = node.AsImportAttributes().Attributes + } + if elements == nil { + return nil + } + openToken := findChildOfKind(node, ast.KindOpenBraceToken, sourceFile) + closeToken := findChildOfKind(node, ast.KindCloseBraceToken, sourceFile) + if openToken == nil || closeToken == nil || printer.PositionsAreOnSameLine(openToken.Pos(), closeToken.Pos(), sourceFile) { + return nil + } + return rangeBetweenTokens(openToken, closeToken, sourceFile, false /*useFullStart*/, l) +} + +func spanForParenthesizedExpression(node *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + start := astnav.GetStartOfNode(node, sourceFile, false /*includeJSDoc*/) + if printer.PositionsAreOnSameLine(start, node.End(), sourceFile) { + return nil + } + textRange := l.createLspRangeFromBounds(start, node.End(), sourceFile) + return createFoldingRange(textRange, "", "") +} + +func spanForCallExpression(node *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + if node.AsCallExpression().Arguments == nil { + return nil + } + openToken := findChildOfKind(node, ast.KindOpenParenToken, sourceFile) + closeToken := findChildOfKind(node, ast.KindCloseParenToken, sourceFile) + if openToken == nil || closeToken == nil || printer.PositionsAreOnSameLine(openToken.Pos(), closeToken.Pos(), sourceFile) { + return nil + } + + return rangeBetweenTokens(openToken, closeToken, sourceFile, true /*useFullStart*/, l) +} + +func spanForArrowFunction(node *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + arrowFunctionNode := node.AsArrowFunction() + if ast.IsBlock(arrowFunctionNode.Body) || ast.IsParenthesizedExpression(arrowFunctionNode.Body) || printer.PositionsAreOnSameLine(arrowFunctionNode.Body.Pos(), arrowFunctionNode.Body.End(), sourceFile) { + return nil + } + textRange := l.createLspRangeFromBounds(arrowFunctionNode.Body.Pos(), arrowFunctionNode.Body.End(), sourceFile) + return createFoldingRange(textRange, "", "") +} + +func spanForTemplateLiteral(node *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + if node.Kind == ast.KindNoSubstitutionTemplateLiteral && len(node.Text()) == 0 { + return nil + } + return createFoldingRangeFromBounds(astnav.GetStartOfNode(node, sourceFile, false /*includeJSDoc*/), node.End(), "", sourceFile, l) +} + +func spanForJSXElement(node *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + var openingElement *ast.Node + if node.Kind == ast.KindJsxElement { + openingElement = node.AsJsxElement().OpeningElement + } else { + openingElement = node.AsJsxFragment().OpeningFragment + } + textRange := l.createLspRangeFromBounds(astnav.GetStartOfNode(openingElement, sourceFile, false /*includeJSDoc*/), openingElement.End(), sourceFile) + tagName := openingElement.TagName().Text() + var bannerText strings.Builder + if node.Kind == ast.KindJsxElement { + bannerText.WriteString("<") + bannerText.WriteString(tagName) + bannerText.WriteString(">...") + } else { + bannerText.WriteString("<>...") + } + + return createFoldingRange(textRange, "", bannerText.String()) +} + +func spanForJSXAttributes(node *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + var attributes *ast.JsxAttributesNode + if node.Kind == ast.KindJsxSelfClosingElement { + attributes = node.AsJsxSelfClosingElement().Attributes + } else { + attributes = node.AsJsxOpeningElement().Attributes + } + if len(attributes.Properties()) == 0 { + return nil + } + return createFoldingRangeFromBounds(astnav.GetStartOfNode(node, sourceFile, false /*includeJSDoc*/), node.End(), "", sourceFile, l) +} + +func spanForNodeArray(statements *ast.NodeList, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + if statements != nil && len(statements.Nodes) != 0 { + return createFoldingRange(l.createLspRangeFromBounds(statements.Pos(), statements.End(), sourceFile), "", "") + } + return nil +} + +func spanForNode(node *ast.Node, open ast.Kind, useFullStart bool, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + closeBrace := ast.KindCloseBraceToken + if open != ast.KindOpenBraceToken { + closeBrace = ast.KindCloseBracketToken + } + openToken := findChildOfKind(node, open, sourceFile) + closeToken := findChildOfKind(node, closeBrace, sourceFile) + if openToken != nil && closeToken != nil { + return rangeBetweenTokens(openToken, closeToken, sourceFile, useFullStart, l) + } + return nil +} + +func rangeBetweenTokens(openToken *ast.Node, closeToken *ast.Node, sourceFile *ast.SourceFile, useFullStart bool, l *LanguageService) *lsproto.FoldingRange { + var textRange *lsproto.Range + if useFullStart { + textRange = l.createLspRangeFromBounds(openToken.Pos(), closeToken.End(), sourceFile) + } else { + textRange = l.createLspRangeFromBounds(astnav.GetStartOfNode(openToken, sourceFile, false /*includeJSDoc*/), closeToken.End(), sourceFile) + } + return createFoldingRange(textRange, "", "") +} + +func createFoldingRange(textRange *lsproto.Range, foldingRangeKind lsproto.FoldingRangeKind, collapsedText string) *lsproto.FoldingRange { + if collapsedText == "" { + defaultText := "..." + collapsedText = defaultText + } + var kind *lsproto.FoldingRangeKind + if foldingRangeKind != "" { + kind = &foldingRangeKind + } + return &lsproto.FoldingRange{ + StartLine: textRange.Start.Line, + StartCharacter: &textRange.Start.Character, + EndLine: textRange.End.Line, + EndCharacter: &textRange.End.Character, + Kind: kind, + CollapsedText: &collapsedText, + } +} + +func createFoldingRangeFromBounds(pos int, end int, foldingRangeKind lsproto.FoldingRangeKind, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + return createFoldingRange(l.createLspRangeFromBounds(pos, end, sourceFile), foldingRangeKind, "") +} + +func functionSpan(node *ast.Node, body *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { + openToken := tryGetFunctionOpenToken(node, body, sourceFile) + closeToken := findChildOfKind(body, ast.KindCloseBraceToken, sourceFile) + if openToken != nil && closeToken != nil { + return rangeBetweenTokens(openToken, closeToken, sourceFile, true /*useFullStart*/, l) + } + return nil +} + +func tryGetFunctionOpenToken(node *ast.SignatureDeclaration, body *ast.Node, sourceFile *ast.SourceFile) *ast.Node { + if isNodeArrayMultiLine(node.Parameters(), sourceFile) { + openParenToken := findChildOfKind(node, ast.KindOpenParenToken, sourceFile) + if openParenToken != nil { + return openParenToken + } + } + return findChildOfKind(body, ast.KindOpenBraceToken, sourceFile) +} + +func isNodeArrayMultiLine(list []*ast.Node, sourceFile *ast.SourceFile) bool { + if len(list) == 0 { + return false + } + return !printer.PositionsAreOnSameLine(list[0].Pos(), list[len(list)-1].End(), sourceFile) +} diff --git a/internal/ls/folding_test.go b/internal/ls/folding_test.go new file mode 100644 index 0000000000..1bb7222624 --- /dev/null +++ b/internal/ls/folding_test.go @@ -0,0 +1,1445 @@ +package ls_test + +import ( + "cmp" + "slices" + "testing" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/ls" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +func runFoldingRangeTest(t *testing.T, input string) { + testData := fourslash.ParseTestData(t, input, "/file1.ts") + markerPositions := testData.Ranges + ctx := projecttestutil.WithRequestID(t.Context()) + service, done := createLanguageService(ctx, testData.Files[0].FileName(), map[string]string{ + testData.Files[0].FileName(): testData.Files[0].Content, + }) + defer done() + + foldingRanges := service.ProvideFoldingRange(ctx, ls.FileNameToDocumentURI("/file1.ts")) + if len(foldingRanges) != len(markerPositions) { + t.Fatalf("Expected %d folding ranges, got %d", len(markerPositions), len(foldingRanges)) + } + slices.SortFunc(markerPositions, func(a, b *fourslash.RangeMarker) int { + if a.LSRange.Start.Line != b.LSRange.Start.Line { + return cmp.Compare(a.LSRange.Start.Line, b.LSRange.Start.Line) + } else if a.LSRange.Start.Character != b.LSRange.Start.Character { + return cmp.Compare(a.LSRange.Start.Character, b.LSRange.Start.Character) + } else if a.LSRange.End.Line != b.LSRange.End.Line { + return cmp.Compare(a.LSRange.End.Line, b.LSRange.End.Line) + } + return cmp.Compare(a.LSRange.End.Character, b.LSRange.End.Character) + }) + for i, marker := range markerPositions { + assert.DeepEqual(t, marker.LSRange.Start.Line, foldingRanges[i].StartLine) + assert.DeepEqual(t, marker.LSRange.End.Line, foldingRanges[i].EndLine) + assert.DeepEqual(t, marker.LSRange.Start.Character, *foldingRanges[i].StartCharacter) + assert.DeepEqual(t, marker.LSRange.End.Character, *foldingRanges[i].EndCharacter) + } +} + +func TestFolding(t *testing.T) { + t.Parallel() + + testCases := []struct { + title string + input string + expectedLocations map[string]*collections.Set[string] + }{ + { + title: "getOutliningSpansForRegionsNoSingleLineFolds", + input: `[|//#region +function foo()[| { + +}|] +[|//these +//should|] +//#endregion not you|] +[|// be +// together|] + +[|//#region bla bla bla + +function bar()[| { }|] + +//#endregion|]`, + }, + { + title: "getOutliningSpansForComments", + input: `[|/* + Block comment at the beginning of the file before module: + line one of the comment + line two of the comment + line three + line four + line five +*/|] +declare module "m"; +[|// Single line comments at the start of the file +// line 2 +// line 3 +// line 4|] +declare module "n";`, + }, + { + title: "getOutliningSpansForRegions", + input: `// region without label + [|// #region + + // #endregion|] + + // region without label with trailing spaces + [|// #region + + // #endregion|] + + // region with label + [|// #region label1 + + // #endregion|] + + // region with extra whitespace in all valid locations + [|// #region label2 label3 + + // #endregion|] + + // No space before directive + [|//#region label4 + + //#endregion|] + + // Nested regions + [|// #region outer + + [|// #region inner + + // #endregion inner|] + + // #endregion outer|] + + // region delimiters not valid when there is preceding text on line + test // #region invalid1 + + test // #endregion`, + }, + { + title: "outliningSpansSwitchCases", + input: `switch (undefined)[| { +case 0:[| + console.log(1) + console.log(2) + break; + console.log(3);|] +case 1:[| + break;|] +case 2:[| + break; + console.log(3);|] +case 3:[| + console.log(4);|] + +case 4: +case 5: +case 6:[| + + + console.log(5);|] + +case 7:[| console.log(6);|] + +case 8:[| [|{ + console.log(8); + break; +}|] +console.log(8);|] + +default:[| + console.log(7); + console.log(8);|] +}|]`, + }, + { + title: "outliningSpansForParenthesizedExpression", + input: `const a = [|( + true + ? true + : false + ? true + : false +)|]; + +const b = ( 1 ); + +const c = [|( + 1 +)|]; + +( 1 ); + +[|( + [|( + [|( + 1 + )|] + )|] +)|]; + +[|( + [|( + ( 1 ) + )|] +)|];`, + }, + { + title: "outliningSpansForInportsAndExports", + input: `import { a1, a2 } from "a"; +; +import { +} from "a"; +; +import [|{ + b1, + b2, +}|] from "b"; +; +import j1 from "./j" assert { type: "json" }; +; +import j2 from "./j" assert { +}; +; +import j3 from "./j" assert [|{ + type: "json" +}|]; +; +[|import { a5, a6 } from "a"; +import [|{ + a7, + a8, +}|] from "a";|] +export { a1, a2 }; +; +export { a3, a4 } from "a"; +; +export { +}; +; +export [|{ + b1, + b2, +}|]; +; +export { +} from "b"; +; +export [|{ + b3, + b4, +}|] from "b"; +;`, + }, + { + title: "outliningSpansForImportAndExportAttributes", + input: `import { a1, a2 } from "a"; +; +import { +} from "a"; +; +import [|{ + b1, + b2, +}|] from "b"; +; +import j1 from "./j" with { type: "json" }; +; +import j2 from "./j" with { +}; +; +import j3 from "./j" with [|{ + type: "json" +}|]; +; +[|import { a5, a6 } from "a"; +import [|{ + a7, + a8, +}|] from "a";|] +export { a1, a2 }; +; +export { a3, a4 } from "a"; +; +export { +}; +; +export [|{ + b1, + b2, +}|]; +; +export { +} from "b"; +; +export [|{ + b3, + b4, +}|] from "b"; +;`, + }, + { + title: "outliningSpansForFunction", + input: `[|( + a: number, + b: number +) => { + return a + b; +}|]; + +(a: number, b: number) =>[| { + return a + b; +}|] + +const f1 = function[| ( + a: number + b: number +) { + return a + b; +}|] + +const f2 = function (a: number, b: number)[| { + return a + b; +}|] + +function f3[| ( + a: number + b: number +) { + return a + b; +}|] + +function f4(a: number, b: number)[| { + return a + b; +}|] + +class Foo[| { + constructor[|( + a: number, + b: number + ) { + this.a = a; + this.b = b; + }|] + + m1[|( + a: number, + b: number + ) { + return a + b; + }|] + + m1(a: number, b: number)[| { + return a + b; + }|] +}|] + +declare function foo(props: any): void; +foo[|( + a =>[| { + + }|] +)|] + +foo[|( + (a) =>[| { + + }|] +)|] + +foo[|( + (a, b, c) =>[| { + + }|] +)|] + +foo[|([| + (a, + b, + c) => { + + }|] +)|]`, + }, + { + title: "outliningSpansForArrowFunctionBody", + input: `() => 42; +() => ( 42 ); +() =>[| { + 42 +}|]; +() => [|( + 42 +)|]; +() =>[| "foo" + + "bar" + + "baz"|];`, + }, + { + title: "outliningSpansForArguments", + input: `console.log(123, 456); +console.log( +); +console.log[|( + 123, 456 +)|]; +console.log[|( + 123, + 456 +)|]; +() =>[| console.log[|( + 123, + 456 +)|]|];`, + }, + { + title: "outliningForNonCompleteInterfaceDeclaration", + input: `interface I`, + }, + { + title: "incrementalParsingWithJsDoc", + input: `[|import a from 'a/aaaaaaa/aaaaaaa/aaaaaa/aaaaaaa'; +import b from 'b'; +import c from 'c';|] + +[|/** @internal */|] +export class LanguageIdentifier[| { }|]`, + }, + { + title: "incrementalParsingWithJsDoc_2", + input: `[|import a from 'a/aaaaaaa/aaaaaaa/aaaaaa/aaaaaaa'; +/**/import b from 'b'; +import c from 'c';|] + +[|/** @internal */|] +export class LanguageIdentifier[| { }|]`, + }, + { + title: "getOutliningSpansForUnbalancedRegion", + input: `// top-heavy region balance +// #region unmatched + +[|// #region matched + +// #endregion matched|]`, + }, + { + title: "getOutliningSpansForTemplateLiteral", + input: "declare function tag(...args: any[]): void\nconst a = [|`signal line`|]\nconst b = [|`multi\nline`|]\nconst c = tag[|`signal line`|]\nconst d = tag[|`multi\nline`|]\nconst e = [|`signal ${1} line`|]\nconst f = [|`multi\n${1}\nline`|]\nconst g = tag[|`signal ${1} line`|]\nconst h = tag[|`multi\n${1}\nline`|]\nconst i = ``", + }, + { + title: "getOutliningSpansForImports", + input: `[|import * as ns from "mod"; + +import d from "mod"; +import { a, b, c } from "mod"; + +import r = require("mod");|] + +// statement +var x = 0; + +// another set of imports +[|import * as ns from "mod"; +import d from "mod"; +import { a, b, c } from "mod"; +import r = require("mod");|]`, + }, + { + title: "getOutliningSpansDepthElseIf", + input: `if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else if (1)[| { + 1; +}|] else[| { + 1; +}|]`, + }, + { + title: "getOutliningSpansDepthChainedCalls", + input: `declare var router: any; +router + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|] + .get[|("/", async(ctx) =>[|{ + ctx.body = "base"; + }|])|] + .post[|("/a", async(ctx) =>[|{ + //a + }|])|]`, + }, + { + title: "getOutliningSpans", + input: `// interface +interface IFoo[| { + getDist(): number; +}|] + +// class members +class Foo[| { + constructor()[| { + }|] + + public foo(): number[| { + return 0; + }|] + + public get X()[| { + return 1; + }|] + + public set X(v: number)[| { + }|] + + public member = function f()[| { + + }|] +}|] +// class expressions +[|(new class[| { + bla()[| { + + }|] +}|])|] +switch(1)[| { +case 1:[| break;|] +}|] + +var array =[| [ + 1, + 2 +]|] + +// modules +module m1[| { + module m2[| { }|] + module m3[| { + function foo()[| { + + }|] + + interface IFoo2[| { + + }|] + + class foo2 implements IFoo2[| { + + }|] + }|] +}|] + +// function declaration +function foo(): number[| { + return 0; +}|] + +// function expressions +[|(function f()[| { + +}|])|] + +// trivia handeling +class ClassFooWithTrivia[| /* some comments */ + /* more trivia */ { + + + [|/*some trailing trivia */|] +}|] /* even more */ + +// object literals +var x =[|{ + a:1, + b:2, + get foo()[| { + return 1; + }|] +}|] +//outline with deep nesting +var nest =[| [[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[[|[ + [|[ + [ + [ + [ + [ + 1,2,3 + ] + ] + ] + ] + ]|] +]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]]|]; + +//outline after a deeply nested node +class AfterNestedNodes[| { +}|] +// function arguments +function f(x: number[], y: number[])[| { + return 3; +}|] +f[|( +// single line array literal span won't render in VS + [|[0]|], + [|[ + 1, + 2 + ]|] +)|]; + +class C[| { + foo: T; +}|] + +class D extends C[| { + constructor(x)[| { + super(x); + }|] +}|]`, + }, + { + title: "getOutliningForTypeLiteral", + input: `type A =[| { + a: number; +}|] + +type B =[| { + a:[| { + a1:[| { + a2:[| { + x: number; + y: number; + }|] + }|] + }|], + b:[| { + x: number; + }|], + c:[| { + x: number; + }|] +}|]`, + }, + { + title: "getOutliningForTupleType", + input: `type A =[| [ + number, + number, + number +]|] + +type B =[| [ + [|[ + [|[ + number, + number, + number + ]|] + ]|] +]|]`, + }, + { + title: "getOutliningForSingleLineComments", + input: `[|// Single line comments at the start of the file + // line 2 + // line 3 + // line 4|] + module Sayings[| { + + [|/* + */|] + [|// A sequence of + // single line|] + [|/* + and block + */|] + [|// comments + //|] + export class Sample[| { + }|] + }|] + + interface IFoo[| { + [|// all consecutive single line comments should be in one block regardless of their number or empty lines/spaces in between + + // comment 2 + // comment 3 + + //comment 4 + /// comment 5 + ///// comment 6 + + //comment 7 + ///comment 8 + // comment 9 + // //comment 10 + + // // //comment 11 + // comment 12 + // comment 13 + // comment 14 + // comment 15 + + // comment 16 + // comment 17 + // comment 18 + // comment 19 + // comment 20 + // comment 21|] + + getDist(): number; // One single line comment should not be collapsed + }|] + + // One single line comment should not be collapsed + class WithOneSingleLineComment[| { + }|] + + function Foo()[| { + [|// comment 1 + // comment 2|] + this.method = function (param)[| { + }|] + + [|// comment 1 + // comment 2|] + function method(param)[| { + }|] + }|]`, + }, + { + title: "getOutliningForObjectsInArray", + input: `// objects in x should generate outlining spans that do not render in VS +const x =[| [ + [|{ a: 0 }|], + [|{ b: 1 }|], + [|{ c: 2 }|] +]|]; +// objects in y should generate outlining spans that render as expected +const y =[| [ + [|{ + a: 0 + }|], + [|{ + b: 1 + }|], + [|{ + c: 2 + }|] +]|]; +// same behavior for nested arrays +const w =[| [ + [|[ 0 ]|], + [|[ 1 ]|], + [|[ 2 ]|] +]|]; + +const z =[| [ + [|[ + 0 + ]|], + [|[ + 1 + ]|], + [|[ + 2 + ]|] +]|]; +// multiple levels of nesting work as expected +const z =[| [ + [|[ + [|{ hello: 0 }|] + ]|], + [|[ + [|{ hello: 3 }|] + ]|], + [|[ + [|{ hello: 5 }|], + [|{ hello: 7 }|] + ]|] +]|];`, + }, + { + title: "getOutliningForObjectDestructuring", + input: `const[| { + a, + b, + c +}|] =[| { + a: 1, + b: 2, + c: 3 +}|] + +const[| { + a:[| { + a_1, + a_2, + a_3:[| { + a_3_1, + a_3_2, + a_3_3, + }|], + }|], + b, + c +}|] =[| { + a:[| { + a_1: 1, + a_2: 2, + a_3:[| { + a_3_1: 1, + a_3_2: 1, + a_3_3: 1 + }|], + }|], + b: 2, + c: 3 +}|]`, + }, + { + title: "getOutliningForBlockComments", + input: `[|/* + Block comment at the beginning of the file before module: + line one of the comment + line two of the comment + line three + line four + line five + */|] + module Sayings[| { + [|/* + Comment before class: + line one of the comment + line two of the comment + line three + line four + line five + */|] + export class Greeter[| { + [|/* + Comment before a string identifier + line two of the comment + */|] + greeting: string; + [|/* + constructor + parameter message as a string + */|] + + [|/* + Multiple comments should be collapsed individually + */|] + constructor(message: string /* do not collapse this */)[| { + this.greeting = message; + }|] + [|/* + method of a class + */|] + greet()[| { + return "Hello, " + this.greeting; + }|] + }|] + }|] + + [|/* + Block comment for interface. The ending can be on the same line as the declaration. + */|]interface IFoo[| { + [|/* + Multiple block comments + */|] + + [|/* + should be collapsed + */|] + + [|/* + individually + */|] + + [|/* + this comment has trailing space before /* and after *-/ signs + */|] + + [|/** + * + * + * + */|] + + [|/* + */|] + + [|/* + */|] + // single line comments in the middle should not have an effect + [|/* + */|] + + [|/* + */|] + + [|/* + this block comment ends + on the same line */|] [|/* where the following comment starts + should be collapsed separately + */|] + + getDist(): number; + }|] + + var x =[|{ + a:1, + b: 2, + [|/* + Over a function in an object literal + */|] + get foo()[| { + return 1; + }|] + }|] + + // Over a function expression assigned to a variable + [|/** + * Return a sum + * @param {Number} y + * @param {Number} z + * @returns {Number} the sum of y and z + */|] + const sum2 = (y, z) =>[| { + return y + z; + }|]; + + // Over a variable + [|/** + * foo + */|] + const foo = null; + + function Foo()[| { + [|/** + * Description + * + * @param {string} param + * @returns + */|] + this.method = function (param)[| { + }|] + + [|/** + * Description + * + * @param {string} param + * @returns + */|] + function method(param)[| { + }|] + }|] + + function fn1()[| { + [|/** + * comment + */|] + }|] + function fn2()[| { + [|/** + * comment + */|] + + [|/** + * comment + */|] + }|] + function fn3()[| { + const x = 1; + + [|/** + * comment + */|] + + [|/** + * comment + */|] + }|] + function fn4()[| { + [|/** + * comment + */|] + const x = 1; + + [|/** + * comment + */|] + }|] + function fn5()[| { + [|/** + * comment + */|] + + [|/** + * comment + */|] + return 1; + }|] + function fn6()[| { + [|/** + * comment + */|] + + [|/** + * comment + */|] + const x = 1; + }|] + + [|/* + comment + */|] + + f6(); + + class C1[| { + [|/** + * comment + */|] + + [|/** + * comment + */|] + }|] + class C2[| { + private prop = 1; + [|/** + * comment + */|] + + [|/** + * comment + */|] + }|] + class C3[| { + [|/** + * comment + */|] + + private prop = 1; + [|/** + * comment + */|] + }|] + class C4[| { + [|/** + * comment + */|] + + [|/** + * comment + */|] + private prop = 1; + }|] + + [|/* + comment + */|] + new C4(); + + module M1[| { + [|/** + * comment + */|] + + [|/** + * comment + */|] + }|] + module M2[| { + export const a = 1; + [|/** + * comment + */|] + + [|/** + * comment + */|] + }|] + module M3[| { + [|/** + * comment + */|] + export const a = 1; + + [|/** + * comment + */|] + }|] + module M4[| { + [|/** + * comment + */|] + + [|/** + * comment + */|] + export const a = 1; + }|] + interface I1[| { + [|/** + * comment + */|] + + [|/** + * comment + */|] + }|] + interface I2[| { + x: number; + [|/** + * comment + */|] + + [|/** + * comment + */|] + }|] + interface I3[| { + [|/** + * comment + */|] + x: number; + + [|/** + * comment + */|] + }|] + interface I4[| { + [|/** + * comment + */|] + + [|/** + * comment + */|] + x: number; + }|] + [|{ + [|/** + * comment + */|] + + [|/** + * comment + */|] + }|]`, + }, + { + title: "getOutliningForArrayDestructuring", + input: `const[| [ + a, + b, + c +]|] =[| [ + 1, + 2, + 3 +]|]; + +const[| [ + [|[ + [|[ + [|[ + a, + b, + c + ]|] + ]|] + ]|], + [|[ + a1, + b1, + c1 + ]|] +]|] =[| [ + [|[ + [|[ + [|[ + 1, + 2, + 3 + ]|] + ]|] + ]|], + [|[ + 1, + 2, + 3 + ]|] +]|]`, + }, + // { + // title: "getJSXOutliningSpans", + // input: `import React, { Component } from 'react'; + + // export class Home extends Component[| { + // render()[| { + // return [|( + // [|
+ // [|

Hello, world!

|] + // [||] + //
+ // + // [|<> + // text + // |] + //
|] + // )|]; + // }|] + // }|]`, + // }, + { + title: "corruptedTryExpressionsDontCrashGettingOutlineSpans", + input: `try[| { + var x = [ + {% try %}|]{% except %} + ] +} catch (e)[| { + +}|]`, + }, + { + title: "outliningSpansForFunctions", + input: `namespace NS[| { + function f(x: number, y: number)[| { + return x + y; + }|] + + function g[|( + x: number, + y: number, + ): number { + return x + y; + }|] +}|]`, + }, + { + title: "outliningSpansTrailingBlockCmmentsAfterStatements", + input: `console.log(0); +[|/* +/ * Some text + */|]`, + }, + { + title: "outlineSpansBlockCommentsWithoutStatements", + input: `[|/* +/ * Some text + */|]`, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.title, func(t *testing.T) { + t.Parallel() + runFoldingRangeTest(t, testCase.input) + }) + } +} diff --git a/internal/ls/utilities.go b/internal/ls/utilities.go index 9f82ce42f9..3e91b1a809 100644 --- a/internal/ls/utilities.go +++ b/internal/ls/utilities.go @@ -393,7 +393,7 @@ func isInRightSideOfInternalImportEqualsDeclaration(node *ast.Node) bool { } func (l *LanguageService) createLspRangeFromNode(node *ast.Node, file *ast.SourceFile) *lsproto.Range { - return l.createLspRangeFromBounds(node.Pos(), node.End(), file) + return l.createLspRangeFromBounds(astnav.GetStartOfNode(node, file, false /*includeJSDoc*/), node.End(), file) } func (l *LanguageService) createLspRangeFromBounds(start, end int, file *ast.SourceFile) *lsproto.Range { diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 5143728de7..6faff868b0 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -492,6 +492,9 @@ func (s *Server) handleRequestOrNotification(ctx context.Context, req *lsproto.R return s.handleWorkspaceSymbol(ctx, req) case *lsproto.DocumentSymbolParams: return s.handleDocumentSymbol(ctx, req) + case *lsproto.FoldingRangeParams: + return s.handleFoldingRange(ctx, req) + default: switch req.Method { case lsproto.MethodShutdown: @@ -575,6 +578,9 @@ func (s *Server) handleInitialize(req *lsproto.RequestMessage) { DocumentSymbolProvider: &lsproto.BooleanOrDocumentSymbolOptions{ Boolean: ptrTo(true), }, + FoldingRangeProvider: &lsproto.BooleanOrFoldingRangeOptionsOrFoldingRangeRegistrationOptions{ + Boolean: ptrTo(true), + }, }, }) } @@ -674,6 +680,16 @@ func (s *Server) handleSignatureHelp(ctx context.Context, req *lsproto.RequestMe return nil } +func (s *Server) handleFoldingRange(ctx context.Context, req *lsproto.RequestMessage) error { + params := req.Params.(*lsproto.FoldingRangeParams) + project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) + languageService, done := project.GetLanguageServiceForRequest(ctx) + defer done() + foldingRanges := languageService.ProvideFoldingRange(ctx, params.TextDocument.Uri) + s.sendResult(req.ID, foldingRanges) + return nil +} + func (s *Server) handleDefinition(ctx context.Context, req *lsproto.RequestMessage) error { params := req.Params.(*lsproto.DefinitionParams) project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) diff --git a/internal/printer/printer.go b/internal/printer/printer.go index 9f078e44e8..583b1e8105 100644 --- a/internal/printer/printer.go +++ b/internal/printer/printer.go @@ -5044,7 +5044,7 @@ func (p *Printer) emitCommentsBeforeToken(token ast.Kind, pos int, contextNode * if contextNode.Pos() != startPos { indentLeading := flags&tefIndentLeadingComments != 0 - needsIndent := indentLeading && p.currentSourceFile != nil && !positionsAreOnSameLine(startPos, pos, p.currentSourceFile) + needsIndent := indentLeading && p.currentSourceFile != nil && !PositionsAreOnSameLine(startPos, pos, p.currentSourceFile) p.increaseIndentIf(needsIndent) p.emitLeadingComments(startPos, false /*elided*/) p.decreaseIndentIf(needsIndent) diff --git a/internal/printer/utilities.go b/internal/printer/utilities.go index 9eac7f1b89..2936cd2bd8 100644 --- a/internal/printer/utilities.go +++ b/internal/printer/utilities.go @@ -335,7 +335,7 @@ func rangeIsOnSingleLine(r core.TextRange, sourceFile *ast.SourceFile) bool { } func rangeStartPositionsAreOnSameLine(range1 core.TextRange, range2 core.TextRange, sourceFile *ast.SourceFile) bool { - return positionsAreOnSameLine( + return PositionsAreOnSameLine( getStartPositionOfRange(range1, sourceFile, false /*includeComments*/), getStartPositionOfRange(range2, sourceFile, false /*includeComments*/), sourceFile, @@ -343,15 +343,15 @@ func rangeStartPositionsAreOnSameLine(range1 core.TextRange, range2 core.TextRan } func rangeEndPositionsAreOnSameLine(range1 core.TextRange, range2 core.TextRange, sourceFile *ast.SourceFile) bool { - return positionsAreOnSameLine(range1.End(), range2.End(), sourceFile) + return PositionsAreOnSameLine(range1.End(), range2.End(), sourceFile) } func rangeStartIsOnSameLineAsRangeEnd(range1 core.TextRange, range2 core.TextRange, sourceFile *ast.SourceFile) bool { - return positionsAreOnSameLine(getStartPositionOfRange(range1, sourceFile, false /*includeComments*/), range2.End(), sourceFile) + return PositionsAreOnSameLine(getStartPositionOfRange(range1, sourceFile, false /*includeComments*/), range2.End(), sourceFile) } func rangeEndIsOnSameLineAsRangeStart(range1 core.TextRange, range2 core.TextRange, sourceFile *ast.SourceFile) bool { - return positionsAreOnSameLine(range1.End(), getStartPositionOfRange(range2, sourceFile, false /*includeComments*/), sourceFile) + return PositionsAreOnSameLine(range1.End(), getStartPositionOfRange(range2, sourceFile, false /*includeComments*/), sourceFile) } func getStartPositionOfRange(r core.TextRange, sourceFile *ast.SourceFile, includeComments bool) int { @@ -361,7 +361,7 @@ func getStartPositionOfRange(r core.TextRange, sourceFile *ast.SourceFile, inclu return scanner.SkipTriviaEx(sourceFile.Text(), r.Pos(), &scanner.SkipTriviaOptions{StopAtComments: includeComments}) } -func positionsAreOnSameLine(pos1 int, pos2 int, sourceFile *ast.SourceFile) bool { +func PositionsAreOnSameLine(pos1 int, pos2 int, sourceFile *ast.SourceFile) bool { return getLinesBetweenPositions(sourceFile, pos1, pos2) == 0 } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 967c44f10f..43b5475baf 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -2297,6 +2297,26 @@ func GetLineAndCharacterOfPosition(sourceFile ast.SourceFileLike, pos int) (line return } +func GetLineEndOfPosition(sourceFile ast.SourceFileLike, pos int) int { + line, _ := GetLineAndCharacterOfPosition(sourceFile, pos) + lineStarts := GetLineStarts(sourceFile) + + var lastCharPos int + if line+1 >= len(lineStarts) { + lastCharPos = len(sourceFile.Text()) + } else { + lastCharPos = int(lineStarts[line+1] - 1) + } + + fullText := sourceFile.Text() + // if the new line is "\r\n", we should return the last non-new-line-character position + if len(fullText) > 0 && len(fullText) != lastCharPos && fullText[lastCharPos] == '\n' && fullText[lastCharPos-1] == '\r' { + return lastCharPos - 1 + } else { + return lastCharPos + } +} + func GetEndLinePosition(sourceFile *ast.SourceFile, line int) int { pos := int(GetLineStarts(sourceFile)[line]) for {