-
Notifications
You must be signed in to change notification settings - Fork 659
Folding Ranges implementation #1326
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Folding Ranges implementation #1326
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This pull request implements folding ranges support for the LSP by combining tests from tsserver and LSP, adding several utility functions and test cases to handle folding functionality in the compiler codebase. Key changes include:
- Introducing new folding range support in the language service (internal/ls/folding.go) and integrating it via the LSP server (internal/lsp/server.go).
- Adding a new helper function (GetLineEndOfPosition) in the scanner and refactoring certain utility functions in the printer.
- Expanding the test coverage for outlining and folding with extensive tests in internal/ls/folding_test.go.
Reviewed Changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
File | Description |
---|---|
internal/scanner/scanner.go | Added GetLineEndOfPosition to compute the end position of a line. |
internal/printer/utilities.go | Updated and renamed helper functions to use consistent naming conventions. |
internal/lsp/server.go | Added folding range handling in LSP server. |
internal/ls/utilities.go | Adjusted LSP range creation using astnav.GetStartOfNode. |
internal/ls/folding_test.go | Introduced comprehensive tests for folding range functionality. |
internal/ls/folding.go | Implemented folding range creation and management functions. |
internal/ast/utilities.go | Added helper isDeclarationKind to classify declaration nodes. |
Comments suppressed due to low confidence (1)
internal/scanner/scanner.go:2300
- [nitpick] The naming of GetLineEndOfPosition is very similar to GetEndLinePosition, which could be confusing. Consider standardizing the naming to more clearly distinguish their roles (e.g., by using a consistent verb pattern).
func GetLineEndOfPosition(sourceFile ast.SourceFileLike, pos int) int {
internal/ls/folding_test.go
Outdated
}, | ||
{ | ||
title: "outliningSpansForArguments", | ||
input: `console.log(123, 456)l; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There appears to be an extra 'l' at the end of the console.log statement; please verify if this was intended or if it is a typo.
input: `console.log(123, 456)l; | |
input: `console.log(123, 456); |
Copilot uses AI. Check for mistakes.
internal/ls/folding_test.go
Outdated
[|/** | ||
* Description | ||
* | ||
* @param {string} param | ||
* @returns | ||
*/|] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this crash, and that's why it's being removed? Can we leave the comment in and make sure we don't crash?
If we do crash, the editor will pop up an error message.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't crash, the range is just returned twice because of the IsDeclarationNode(node)
change. So, in Strada this range would've returned once because we checked for isDeclarationKind(node.Kind)
instead of IsDeclarationNode(node)
returning true here. The behavior still remains the same in the editor even if it is returned twice, I tested it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha; that seems fine, though it seems like visitNode
could just skip any JSDoc nodes too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same reason for the other change in that same commit.
Btw, why is it that we're testing IsDeclarationNode(node)
and not isDeclarationKind(node.Kind)
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the new reparsed JS AST, JSDoc nodes are not actually declaration nodes anymore, as we should be inserting into the tree real nodes instead. But, that all is in flux.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I compared the debugging here with Strada, it's not exactly JSDoc, what's happening is that when the node is a BinaryExpression
, then it returns true for IsDeclarationNode(node)
which did not use to be the case in Strada for isDeclarationKind(node.Kind)
. This is causing the duplication of that range.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, bleh, because BinaryExpressions are currently Declarations for JSDoc reasons. Can you easily special case that for now until that is resolved?
@@ -390,7 +390,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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems weird; do we actually want to do that for all nodes? What happens if we need to make a range out of a JSDoc range itself? (Should your calls be using the other helper below?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is actually correct. In Strada this is equivalent to createTextSpanFromNode
, which uses node.getStart()
as the start of the span.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that's why I changed it here.
internal/ls/folding.go
Outdated
currentTokenEnd = curr.End() | ||
} | ||
scanner := scanner.GetScannerForSourceFile(sourceFile, currentTokenEnd) | ||
statements.Nodes = append(statements.Nodes, sourceFile.GetOrCreateToken(scanner.Token(), scanner.TokenFullStart(), scanner.TokenEnd(), curr)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doesn't this mutate the SourceFile's contents? Did this not cause issues when running the language server for you?
In any case, I don't think allocating an entirely new array was ever necessary. Just call visitNode
at the end on the EOF. I just sent a change in Strada to do this. microsoft/TypeScript#61987
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
@@ -0,0 +1,1445 @@ | |||
package ls_test |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason why those are unit tests and not fourslash tests? I think eventually we want to drop the services unit tests in favor of fourslash tests, so it would be nice to have them either in this PR or in a follow-up. I think all of the existing fourslash basics are in place for implementing verify.outliningSpansInCurrentFile
, but if there's something missing, let me know.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, I'll send that as a follow-up PR
internal/ls/folding.go
Outdated
// Includes the EOF Token so that comments which aren't attached to statements are included | ||
var curr *ast.Node | ||
currentTokenEnd := 0 | ||
if statements != nil && statements.Nodes != nil { | ||
curr = statements.Nodes[len(statements.Nodes)-1] | ||
currentTokenEnd = curr.End() | ||
} | ||
scanner := scanner.GetScannerForSourceFile(sourceFile, currentTokenEnd) | ||
foldingRange = append(foldingRange, visitNode(sourceFile.GetOrCreateToken(scanner.Token(), scanner.TokenFullStart(), scanner.TokenEnd(), curr), depthRemaining, sourceFile, l)...) | ||
return foldingRange |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks to #1257 from @Andarist, you can now just write:
// Includes the EOF Token so that comments which aren't attached to statements are included | |
var curr *ast.Node | |
currentTokenEnd := 0 | |
if statements != nil && statements.Nodes != nil { | |
curr = statements.Nodes[len(statements.Nodes)-1] | |
currentTokenEnd = curr.End() | |
} | |
scanner := scanner.GetScannerForSourceFile(sourceFile, currentTokenEnd) | |
foldingRange = append(foldingRange, visitNode(sourceFile.GetOrCreateToken(scanner.Token(), scanner.TokenFullStart(), scanner.TokenEnd(), curr), depthRemaining, sourceFile, l)...) | |
return foldingRange | |
// 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 |
} else if res[i].StartCharacter != nil && res[j].StartCharacter != nil { | ||
return *res[i].StartCharacter < *res[j].StartCharacter | ||
} | ||
return *res[i].EndCharacter < *res[j].EndCharacter |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't you need to check that EndCharacter
not also possibly nil
? And only check this if StartCharacter
EndLine
differs?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On top of that, the sorting here is off in a different way.
The original code sorts only by span starts. We don't sort by ends/length (and I guess we have no tests that are affected by that - so I don't have a strong opinion about sorting by the End
s at all).
But the main issue - we don't have span starts here. Instead we have only StartLine
and StartCharacter
. Those two are the primary things we need to sort by, before anything about the End
.
We likely don't have any examples that will be affected - but we should get the sorting right here.
collapsedTest := "#region" | ||
if result.name != "" { | ||
collapsedTest = result.name | ||
} | ||
regions = append(regions, &lsproto.FoldingRange{ | ||
StartLine: span.Line, | ||
StartCharacter: &span.Character, | ||
Kind: &foldingRangeKindRegion, | ||
CollapsedText: &collapsedTest, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
collapsedTest := "#region" | |
if result.name != "" { | |
collapsedTest = result.name | |
} | |
regions = append(regions, &lsproto.FoldingRange{ | |
StartLine: span.Line, | |
StartCharacter: &span.Character, | |
Kind: &foldingRangeKindRegion, | |
CollapsedText: &collapsedTest, | |
collapsedText := "#region" | |
if result.name != "" { | |
collapsedText = result.name | |
} | |
regions = append(regions, &lsproto.FoldingRange{ | |
StartLine: span.Line, | |
StartCharacter: &span.Character, | |
Kind: &foldingRangeKindRegion, | |
CollapsedText: &collapsedText, |
if out == nil { | ||
out = []*lsproto.FoldingRange{} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think you need to do this, append
will just handle this.
if out == nil { | |
out = []*lsproto.FoldingRange{} | |
} |
if result == nil || isInComment(sourceFile, int(currentLineStart), nil) != nil { | ||
continue | ||
} | ||
if result.isStart { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if result.isStart { | |
if result.isStart { |
if len(regions) > 0 { | ||
region := regions[len(regions)-1] | ||
regions = regions[:len(regions)-1] | ||
if region != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't need the nil
check here, the code would have crashed if you indexed out-of-bounds but you had the len(regions) > 0
check above.
continue | ||
} | ||
if result.isStart { | ||
span := l.createLspPosition(strings.Index(sourceFile.Text()[currentLineStart:lineEnd], "//")+int(currentLineStart), sourceFile) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't a span
though, this is a position. I would call it commentStart
or something like that
if result.name != "" { | ||
collapsedTest = result.name | ||
} | ||
regions = append(regions, &lsproto.FoldingRange{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, there is a subtlety both here and in the original code about how the end position isn't filled in yet.
regions = append(regions, &lsproto.FoldingRange{ | |
// 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{ |
This PR implements folding ranges in the lsp. tsserver also had hint spans while lsp doesn't so I have combined those tests into folding tests.