While implementing
textDocument/selectionRange
support in Metals, the Scala Language
Server I found that one of the hardest
parts of implementing it was simply understanding how a client is correctly
supposed to use it. If you look at the spec you'll notice that the confusion
starts on the client side with the initial request is sent it. The spec allows
for more than a single position to be sent in, but doesn't specify in what
scenarios multiple positions should be sent it. This wouldn't be the worse if
there was consensus about how clients handle this, but from my initial tests
with VS Code and
coc.nvim I found them wildly different.
I'll outline the two different approaches below to demonstrate how they differ.
We'll use the following snippet of code as very minimal example. The @@
denotes the cursor position that the request will be sent from.
object Example {
def doubleIt(a: Int) = {
a@@ * 2
}
}
coc.nvim starts the request off as you'd expect. It sends in a single position
in the initial textDocument/selectionRange
request:
[Trace - 05:45:22 PM] Received request 'textDocument/selectionRange - (38)'
Params: {
"textDocument": {
"uri": "file:///Users/ckipp/Documents/scala-workspace/sanity/src/main/scala/Example.scala"
},
"positions": [
{
"line": 2,
"character": 5
}
]
}
The response that is sent back from will actually contain all necessary ranges to expand from the nearest enclosing tree to the actual object definition itself. NOTE there is actually an existing bug that I'm currently working on where odd duplicated node ranges are present. You'll notice it right at the end of the ranges. However, this shouldn't affect our example.
[Trace - 05:45:22 PM] Sending response 'textDocument/selectionRange - (38)'. Processing request took 42ms
Result: [
{
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 5
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 7
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
}
}
}
]
Now at this point is where things get a little interesting and where the two
clients diverge from one another. When I trigger it again via coc.nvim
it now
sends in two positions:
[Trace - 05:50:15 PM] Received request 'textDocument/selectionRange - (41)'
Params: {
"textDocument": {
"uri": "file:///Users/ckipp/Documents/scala-workspace/sanity/src/main/scala/Example.scala"
},
"positions": [
{
"line": 2,
"character": 4
},
{
"line": 2,
"character": 5
}
]
}
You'll notice that the two positions here correspond with the actual range that was returned previously. This continues every time you expand until the end. I'll include the full trace from the first request until the end below:
Full coc.nvim trace
[Trace - 05:54:11 PM] Received request 'textDocument/selectionRange - (43)'
Params: {
"textDocument": {
"uri": "file:///Users/ckipp/Documents/scala-workspace/sanity/src/main/scala/Example.scala"
},
"positions": [
{
"line": 2,
"character": 5
}
]
}
[Trace - 05:54:11 PM] Sending response 'textDocument/selectionRange - (43)'. Processing request took 2ms
Result: [
{
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 5
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 7
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
}
}
}
]
[Trace - 05:54:11 PM] Received request 'textDocument/selectionRange - (44)'
Params: {
"textDocument": {
"uri": "file:///Users/ckipp/Documents/scala-workspace/sanity/src/main/scala/Example.scala"
},
"positions": [
{
"line": 2,
"character": 4
},
{
"line": 2,
"character": 5
}
]
}
[Trace - 05:54:11 PM] Sending response 'textDocument/selectionRange - (44)'. Processing request took 3ms
Result: [
{
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 5
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 7
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
}
}
},
{
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 5
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 7
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
}
}
}
]
[Trace - 05:54:12 PM] Received request 'textDocument/selectionRange - (45)'
Params: {
"textDocument": {
"uri": "file:///Users/ckipp/Documents/scala-workspace/sanity/src/main/scala/Example.scala"
},
"positions": [
{
"line": 2,
"character": 4
},
{
"line": 2,
"character": 7
}
]
}
[Trace - 05:54:12 PM] Sending response 'textDocument/selectionRange - (45)'. Processing request took 3ms
Result: [
{
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 5
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 7
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
}
}
},
{
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 7
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
}
}
]
[Trace - 05:54:12 PM] Received request 'textDocument/selectionRange - (46)'
Params: {
"textDocument": {
"uri": "file:///Users/ckipp/Documents/scala-workspace/sanity/src/main/scala/Example.scala"
},
"positions": [
{
"line": 2,
"character": 4
},
{
"line": 2,
"character": 9
}
]
}
[Trace - 05:54:12 PM] Sending response 'textDocument/selectionRange - (46)'. Processing request took 3ms
Result: [
{
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 5
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 7
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
}
}
},
{
"range": {
"start": {
"line": 2,
"character": 8
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
}
}
]
[Trace - 05:54:13 PM] Received request 'textDocument/selectionRange - (47)'
Params: {
"textDocument": {
"uri": "file:///Users/ckipp/Documents/scala-workspace/sanity/src/main/scala/Example.scala"
},
"positions": [
{
"line": 1,
"character": 2
},
{
"line": 3,
"character": 3
}
]
}
[Trace - 05:54:13 PM] Sending response 'textDocument/selectionRange - (47)'. Processing request took 3ms
Result: [
{
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
},
{
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
]
[Trace - 05:54:13 PM] Received request 'textDocument/selectionRange - (48)'
Params: {
"textDocument": {
"uri": "file:///Users/ckipp/Documents/scala-workspace/sanity/src/main/scala/Example.scala"
},
"positions": [
{
"line": 0,
"character": 15
},
{
"line": 4,
"character": 1
}
]
}
[Trace - 05:54:13 PM] Sending response 'textDocument/selectionRange - (48)'. Processing request took 3ms
Result: [
{
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
},
{
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
]
One of the interesting things here is that on the Metals side the closest
enclosing tree to the position sent in is the actual range we already returned.
So we return the new selection range with the same beginning. However,
coc.nvim
seems to correctly account for this and just continue to expand as
you'd expect. Here is a gif showing the full expansion:
VS Code starts off with the same exact call as you saw above, and it gets the same response back:
Trace - 06:02:08 PM] Received request 'textDocument/selectionRange - (31)'
Params: {
"textDocument": {
"uri": "file:///Users/ckipp/Documents/scala-workspace/sanity/src/main/scala/Example.scala"
},
"positions": [
{
"line": 2,
"character": 5
}
]
}
[Trace - 06:02:08 PM] Sending response 'textDocument/selectionRange - (31)'. Processing request took 4ms
Result: [
{
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 5
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 7
}
},
"parent": {
"range": {
"start": {
"line": 2,
"character": 4
},
"end": {
"line": 2,
"character": 9
}
},
"parent": {
"range": {
"start": {
"line": 1,
"character": 2
},
"end": {
"line": 3,
"character": 3
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 15
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
},
"parent": {
"range": {
"start": {
"line": 0,
"character": 0
},
"end": {
"line": 4,
"character": 1
}
}
}
}
}
}
}
}
}
]
However, this is the only call that is made. It seems that VS Code takes that full range, and just grabs everything it needs from it since it has all the ranges. It's actuall a good idea since you do have the full tree there with all the ranges. However, VS Code seems to try to be "smart" about it and adds in some extra ranges. Notice how it adds extra rangesin the gif that extends to the beginning of line for each of the ranges we gave it even though that range isn't in the return we gave it. Here is the gif:
If you then use multiple cursors with VS Code, then you'll finally see that the array is actually used to send in multiple positions to get multiple selection range groups back.
Hopefully this helps someone else impliment this in another server if they don't yet have it. You can find the pr that added this to Metals here.