Skip to content

Instantly share code, notes, and snippets.

@ckipp01
Last active July 15, 2021 06:10
Show Gist options
  • Save ckipp01/2cb86d0896312062637f73d65a323e14 to your computer and use it in GitHub Desktop.
Save ckipp01/2cb86d0896312062637f73d65a323e14 to your computer and use it in GitHub Desktop.
The confusion around `textDocument/selectionRange`.

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.

The Code

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

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:

coc.nvim selection range

VS Code

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:

VS Code selection range

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment