-
Notifications
You must be signed in to change notification settings - Fork 1
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
Rewrite the language server on top of a ruff_server
fork
#126
base: feature/air-toml
Are you sure you want to change the base?
Conversation
Because it is really nice to bind the `contents` to its `index` so you dont have to pass them around separately
Allowing us to remove our special casing code in logging/messaging
To be able to even more cheaply create a `DocumentQuery`
…tings Allowing us to show useful toast notifications when something goes wrong
Since our client tests are now synchronized through a mutex
The `server` crate is a fork of `ruff_server`: | ||
|
||
- URL: https://github.com/astral-sh/ruff/tree/main/crates/ruff_server | ||
- Commit: aa429b413f8a7de9eeee14f8e54fdcb3b199b7b7 | ||
- License: MIT | ||
|
||
MIT License | ||
|
||
Copyright (c) 2022 Charles Marsh | ||
|
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 tried the idea of putting some kind of header info in each file but it was not maintainable with the amount of changes I was also making to those files (deleting some, merging others, completely rewriting some, etc)
|
||
use crate::server::ClientSender; | ||
|
||
static MESSENGER: OnceLock<ClientSender> = OnceLock::new(); |
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.
In addition to logging.rs
, this is the one other piece of global state we now have
This allows us to have free functions like show_err_msg!()
that sends ShowMessage
notifications to the client to pop up toast notifications for the user when something massive has gone wrong
pub(crate) struct DidChange; | ||
|
||
impl super::NotificationHandler for DidChange { | ||
type NotificationType = notif::DidChangeTextDocument; | ||
} | ||
|
||
impl super::SyncNotificationHandler for DidChange { | ||
fn run( | ||
session: &mut Session, | ||
_notifier: Notifier, | ||
_requester: &mut Requester, | ||
types::DidChangeTextDocumentParams { | ||
text_document: | ||
types::VersionedTextDocumentIdentifier { | ||
uri, | ||
version: new_version, | ||
}, | ||
content_changes, | ||
}: types::DidChangeTextDocumentParams, | ||
) -> Result<()> { | ||
let key = session.key_from_url(uri); | ||
|
||
session | ||
.update_text_document(&key, content_changes, new_version) | ||
.with_failure_code(ErrorCode::InternalError)?; | ||
|
||
Ok(()) | ||
} | ||
} |
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 is what "implementing" a sync handler looks like. We implement a super trait of NotificationHandler
that gets the notification type, and then a more specific SyncNotificationHandler
(or BackgroundNotificationHandler
) trait that actually handles the request/notif.
In this case, since this is a sync request we have full mutable access to the server Session
, so we just forward the information on to session.update_text_document()
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] | ||
#[serde(rename_all = "camelCase")] | ||
pub(crate) struct ViewFileParams { | ||
/// From `lsp_types::TextDocumentPositionParams` | ||
pub(crate) text_document: lsp_types::TextDocumentIdentifier, | ||
pub(crate) position: lsp_types::Position, | ||
|
||
/// Viewer type | ||
pub(crate) kind: ViewFileKind, | ||
} | ||
|
||
#[derive(Debug)] | ||
pub(crate) enum ViewFile {} | ||
|
||
impl Request for ViewFile { | ||
type Params = ViewFileParams; | ||
type Result = String; | ||
const METHOD: &'static str = "air/viewFile"; | ||
} | ||
|
||
impl super::RequestHandler for ViewFile { | ||
type RequestType = ViewFile; | ||
} | ||
|
||
impl super::BackgroundDocumentRequestHandler for ViewFile { |
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 is what implementing a custom request looks like. We have to implement lsp_types::request::Request
for our custom request and supply the params/result types, but it's pretty easy!
/// Sends a request of kind `R` to the client, with associated parameters. | ||
/// The task provided by `response_handler` will be dispatched as soon as the response | ||
/// comes back from the client. | ||
pub(crate) fn request<R>( |
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.
Typically the server receives requests/notifs from the client, but in a few places we will also send requests/notifs to the client, possibly in the middle of some other request.
That is handled too! We have access to a client.requester
that we can send along a request through. When the response comes back, a handler that we provided is immediately run. The response is matched up to our handler through a RequestId
that is created during the request.
We use this to register dynamic capabilities, like didChangeWatchedFiles
server support
/// The global state for the LSP | ||
pub(crate) struct Session { | ||
/// Used to retrieve information about open documents and settings. | ||
index: index::Index, | ||
/// The global position encoding, negotiated during LSP initialization. | ||
position_encoding: PositionEncoding, | ||
/// Tracks what LSP features the client supports and doesn't support. | ||
resolved_client_capabilities: ResolvedClientCapabilities, | ||
} | ||
|
||
/// An immutable snapshot of `Session` that references | ||
/// a specific document. | ||
pub(crate) struct DocumentSnapshot { | ||
document_ref: index::DocumentQuery, | ||
position_encoding: PositionEncoding, | ||
} |
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.
The Session
is like our LspState
and the DocumentSnapshot
is like our WorldState
We can expand on these as needed going forward, but should be careful about what we put in DocumentSnapshot
/// Global test client used by all unit tests | ||
/// | ||
/// The language [Server] has per-process global state, such as the global `tracing` | ||
/// subscriber and a global `MESSENGER` to send `ShowMessage` notifications to the client. | ||
/// Because of this, we cannot just repeatedly call [test_client()] to start up a new | ||
/// client/server pair per unit test. Instead, unit tests use [with_client()] to access | ||
/// the global test client, which they can then manipulate. Synchronization is managed | ||
/// through a [Mutex], ensuring that multiple unit tests that need to mutate the client | ||
/// can't run simultaneously (while still allowing other unit tests to run in parallel). | ||
/// Unit tests should be careful not to put the client/server pair into a state that | ||
/// prevents other unit tests from running successfully. | ||
/// | ||
/// If you need to modify a client/server pair in such a way that no other unit tests will | ||
/// be able to use it, create an integration test instead, which runs in its own process. | ||
static TEST_CLIENT: LazyLock<Mutex<TestClient>> = LazyLock::new(|| Mutex::new(test_client())); | ||
|
||
pub(crate) fn with_client<F>(f: F) | ||
where | ||
F: FnOnce(&mut server_test::TestClient), | ||
{ | ||
let mut client = TEST_CLIENT.lock().unwrap(); | ||
f(&mut client) | ||
} |
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.
Our global test client
This PR is a huge move away from tower-lsp, instead implementing our language server on top of lsp-types and lsp-server directly. I believe this has some really nice advantages, and is something we have been discussing for some time now.
In particular, I've implemented this by effectively forking
ruff_server
, dropping everything we don't need, and layering in all of our own additions (viewFile
, test clients, minimal text edits in format responses, etc).Some huge benefits:
Drops all async code from the language server. We now use a scheduler that handles requests/notifications in one of 3 ways:
Drops tower-lsp, which is currently not very well maintained and has given us some issues in the past (which we have successfully worked around, but we knew eventually we'd have to switch away)
Completely non-locking implementation. Background threads get a
DocumentSnapshot
(this could expand to include more things) containing anArc<TextDocument>
andArc<Settings>
that they are free to use without having to, say, lock aMutex
. If the main thread gets a Sync request/notif that needs to update aTextDocument
orSettings
while a background thread is working with it, the main thread will clone thatTextDocument
before modifying it. We should try extremely hard to avoidMutex
es in background threads going forward.More control over request/notification handling, including some nice ergonomics improvements where
Initialize
andInitialized
are handled before you enter the actual main loop, since those are "one time" requests/notifs. We also have full control over shutdown/exit.A very solid code base to build from and reference going forward. The ruff code base itself seems inspired by rust-analyzer, so we are inheriting a combination of the two, and can reference both going forward as potential sources of inspiration. Both also use lsp-server, which is good news for us in terms of maintainability of that crate.
Some other improvements:
Mutex
, which forces unit tests that require the client to run sequentially, while still allowing all other tests to run in parallel without issue. You usewith_client(|client| { })
in a unit test to access it. You just have to be thoughtful to not do something in your unit test that puts the client in a state that affects other unit tests. If you need to destructively modify the client for a test, you create an integration test instead (seetests/initialization.rs
which tests start up and shut down).-- --nocapture
actually makes sense now even without forcing 1 thread!