diff --git a/Cargo.toml b/Cargo.toml index 4d913cc..c2ef980 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,3 +47,7 @@ path = "tests/concurrency_cap.rs" [[test]] name = "documents" path = "tests/documents.rs" + +[[test]] +name = "position_encoding" +path = "tests/position_encoding.rs" diff --git a/src/server.rs b/src/server.rs index 79c5942..77ed4e3 100644 --- a/src/server.rs +++ b/src/server.rs @@ -7,9 +7,47 @@ use lsp_types::{ use tokio_util::sync::CancellationToken; use crate::context::Context; -use crate::documents::Documents; +use crate::documents::{Documents, PositionEncoding}; use crate::error::LspError; +/// Intersect the client's offered `positionEncodings` with lspf's preference +/// order (`utf-8` then `utf-16`), write the choice into the document store, +/// and return the LSP kind to advertise (ADR 0016). +/// +/// If the client offers nothing, nothing supported, or omits the field +/// entirely, the encoding defaults to UTF-16. +fn negotiate_position_encoding( + documents: &Documents, + params: &InitializeParams, +) -> lsp_types::PositionEncodingKind { + let offered = params + .capabilities + .general + .as_ref() + .and_then(|g| g.position_encodings.as_deref()); + + let preferred = [ + lsp_types::PositionEncodingKind::UTF8, + lsp_types::PositionEncodingKind::UTF16, + ]; + let chosen = offered + .and_then(|encodings| { + preferred + .iter() + .find(|kind| encodings.contains(kind)) + .cloned() + }) + .unwrap_or(lsp_types::PositionEncodingKind::UTF16); + + documents.set_position_encoding(if chosen == lsp_types::PositionEncodingKind::UTF8 { + PositionEncoding::Utf8 + } else { + PositionEncoding::Utf16 + }); + + chosen +} + /// The user's language server (see ADR 0003, 0004, 0006, 0007, 0009). /// /// Methods mirror LSP wire names (`textDocument/hover` → @@ -37,9 +75,11 @@ pub trait LanguageServer: Send + Sync + 'static { /// to clone. fn documents(&self) -> &Documents; - fn server_capabilities(&self) -> ServerCapabilities { + fn server_capabilities(&self, params: &InitializeParams) -> ServerCapabilities { + let position_encoding = negotiate_position_encoding(self.documents(), params); ServerCapabilities { text_document_sync: Some(TextDocumentSyncCapability::Kind(Self::TEXT_DOCUMENT_SYNC)), + position_encoding: Some(position_encoding), ..ServerCapabilities::default() } } @@ -47,12 +87,12 @@ pub trait LanguageServer: Send + Sync + 'static { fn initialize( &self, _ctx: &Context, - _params: InitializeParams, + params: InitializeParams, _ct: CancellationToken, ) -> impl Future> + Send { - async { + async move { Ok(InitializeResult { - capabilities: self.server_capabilities(), + capabilities: self.server_capabilities(¶ms), server_info: None, }) } diff --git a/tests/position_encoding.rs b/tests/position_encoding.rs new file mode 100644 index 0000000..ededb4e --- /dev/null +++ b/tests/position_encoding.rs @@ -0,0 +1,111 @@ +use lspf::types::{GeneralClientCapabilities, InitializeParams, PositionEncodingKind}; +use lspf::{Documents, LanguageServer, PositionEncoding}; + +struct TestServer { + documents: Documents, +} + +impl LanguageServer for TestServer { + fn documents(&self) -> &Documents { + &self.documents + } +} + +#[test] +fn defaults_to_utf16_when_client_offers_no_encodings() { + let server = TestServer { + documents: Documents::new(), + }; + let params = InitializeParams::default(); + + let caps = server.server_capabilities(¶ms); + + assert_eq!(caps.position_encoding, Some(PositionEncodingKind::UTF16)); + assert_eq!( + server.documents().position_encoding(), + PositionEncoding::Utf16 + ); +} + +#[test] +fn picks_utf8_when_client_offers_it() { + let server = TestServer { + documents: Documents::new(), + }; + let mut params = InitializeParams::default(); + params.capabilities.general = Some(GeneralClientCapabilities { + position_encodings: Some(vec![PositionEncodingKind::UTF8]), + ..GeneralClientCapabilities::default() + }); + + let caps = server.server_capabilities(¶ms); + + assert_eq!(caps.position_encoding, Some(PositionEncodingKind::UTF8)); + assert_eq!( + server.documents().position_encoding(), + PositionEncoding::Utf8 + ); +} + +#[test] +fn falls_back_to_utf16_when_client_only_offers_utf16() { + let server = TestServer { + documents: Documents::new(), + }; + let mut params = InitializeParams::default(); + params.capabilities.general = Some(GeneralClientCapabilities { + position_encodings: Some(vec![PositionEncodingKind::UTF16]), + ..GeneralClientCapabilities::default() + }); + + let caps = server.server_capabilities(¶ms); + + assert_eq!(caps.position_encoding, Some(PositionEncodingKind::UTF16)); + assert_eq!( + server.documents().position_encoding(), + PositionEncoding::Utf16 + ); +} + +#[test] +fn falls_back_to_utf16_when_client_offers_only_unsupported_encodings() { + let server = TestServer { + documents: Documents::new(), + }; + let mut params = InitializeParams::default(); + params.capabilities.general = Some(GeneralClientCapabilities { + position_encodings: Some(vec![PositionEncodingKind::UTF32]), + ..GeneralClientCapabilities::default() + }); + + let caps = server.server_capabilities(¶ms); + + assert_eq!(caps.position_encoding, Some(PositionEncodingKind::UTF16)); + assert_eq!( + server.documents().position_encoding(), + PositionEncoding::Utf16 + ); +} + +#[test] +fn prefers_utf8_over_utf16_when_client_offers_both() { + let server = TestServer { + documents: Documents::new(), + }; + let mut params = InitializeParams::default(); + params.capabilities.general = Some(GeneralClientCapabilities { + position_encodings: Some(vec![ + PositionEncodingKind::UTF16, + PositionEncodingKind::UTF8, + ]), + ..GeneralClientCapabilities::default() + }); + + let caps = server.server_capabilities(¶ms); + + assert_eq!(caps.position_encoding, Some(PositionEncodingKind::UTF8)); + assert_eq!( + server.documents().position_encoding(), + PositionEncoding::Utf8 + ); +}