apollo_language_server/
server.rs

1use crate::{
2    custom_requests::{
3        ApolloCanComposeNotification, ApolloCanComposeNotificationParams,
4        ApolloComposeServicesParams, ApolloComposeServicesRequest, ApolloComposeServicesResponse,
5        ApolloConfigureAutoCompositionNotification, ApolloConfigureAutoCompositionParams,
6        APOLLO_COMPOSITION_PROGRESS_TOKEN,
7    },
8    graph::{
9        supergraph::{KnownSubgraphs, Supergraph},
10        Graph, GraphConfig,
11    },
12    semantic_tokens::{incomplete_tokens_to_deltas, LEGEND_TYPE},
13    telemetry::{AnalyticsEvent, CompositionTiming, TelemetryEvent},
14};
15use apollo_federation_types::{
16    composition::Issue, config::SchemaSource, javascript::SubgraphDefinition,
17};
18use debounced::debounced;
19use futures::{
20    channel::mpsc::{channel, Receiver, Sender},
21    channel::oneshot,
22    future::join_all,
23    lock::Mutex,
24    SinkExt, StreamExt,
25};
26use serde::{Deserialize, Serialize};
27use std::path::PathBuf;
28use std::{borrow::Cow, collections::HashMap};
29use std::{sync::Arc, time::Duration};
30use tower_lsp::{
31    async_trait, jsonrpc,
32    lsp_types::{self as lsp, notification::Notification, request::Request},
33    Client, ClientSocket, LanguageServer, LspService,
34};
35
36#[cfg(all(feature = "wasm", not(test)))]
37use wasm_bindgen_futures::spawn_local as spawn;
38
39#[cfg(any(feature = "tokio", test))]
40use tokio::spawn;
41
42pub const LANGUAGE_IDS: [&str; 2] = ["apollo-graphql", "graphql"];
43
44#[derive(Debug, Clone, Deserialize, Serialize, Default)]
45pub struct MaxSpecVersions {
46    pub connect: Option<semver::Version>,
47    pub federation: Option<semver::Version>,
48}
49
50/// Configuration options for the Apollo Language Server.
51#[derive(Debug, Clone, Deserialize, Serialize)]
52#[serde(rename_all = "camelCase", default)]
53pub struct Config {
54    /// The root URI for the workspace.
55    pub root_uri: String,
56    /// Whether to enable auto composition. This can also be configured at runtime by the client via the `apollo/configureAutoComposition` notification (with params `{ "enabled": boolean }`).
57    pub enable_auto_composition: bool,
58    /// Forces the server to treat all graphs as subgraphs
59    pub force_federation: bool,
60    /// Whether to disable telemetry. The language server does not submit telemetry itself, it only sends telemetry events to the client. The client is responsible for submitting telemetry to the telemetry service.
61    pub disable_telemetry: bool,
62    /// The maximum supported versions for the `federation` and `connect` specs.
63    /// This is used to determine which `@link` completions to show.
64    pub max_spec_versions: MaxSpecVersions,
65}
66
67impl Default for Config {
68    fn default() -> Self {
69        Self {
70            root_uri: "/".to_string(),
71            enable_auto_composition: false,
72            force_federation: false,
73            disable_telemetry: false,
74            max_spec_versions: MaxSpecVersions {
75                connect: None,
76                federation: None,
77            },
78        }
79    }
80}
81
82/// The Apollo Language Server.
83#[derive(Clone)]
84pub struct ApolloLanguageServer {
85    state: Arc<State>,
86}
87
88pub type SpecLookup = Sender<(PathBuf, oneshot::Sender<Option<String>>)>;
89
90#[derive(Debug)]
91struct State {
92    graph: Mutex<Option<Graph>>,
93    client: Mutex<Client>,
94    request_composition: Mutex<Sender<Vec<SubgraphDefinition>>>,
95    config: Mutex<Config>,
96    document_change_queue_sender: Mutex<Sender<lsp::DidChangeTextDocumentParams>>,
97    spec_lookup: Option<Mutex<SpecLookup>>,
98}
99
100const DOCUMENT_SIZE_DEBOUNCE_THRESHOLD: usize = 50_000;
101const DOCUMENT_UPDATE_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(150);
102
103impl ApolloLanguageServer {
104    fn new(
105        client: Client,
106        request_composition: Sender<Vec<SubgraphDefinition>>,
107        config: Config,
108        known_subgraphs: KnownSubgraphs,
109        spec_lookup: Option<SpecLookup>,
110    ) -> Self {
111        let (document_change_queue_sender, document_change_queue_receiver) =
112            channel::<lsp::DidChangeTextDocumentParams>(1);
113
114        // If we have known subgraphs, we should initialize a supergraph
115        let graph = (!known_subgraphs.by_name.is_empty() || config.force_federation).then_some(
116            Graph::Supergraph(Box::new(Supergraph::new(known_subgraphs))),
117        );
118
119        let state = State {
120            graph: Mutex::new(graph),
121            client: Mutex::new(client),
122            request_composition: Mutex::new(request_composition),
123            config: Mutex::new(config),
124            document_change_queue_sender: Mutex::new(document_change_queue_sender),
125            spec_lookup: spec_lookup.map(Mutex::new),
126        };
127        let state = Arc::new(state);
128        State::initialize_document_update_debouncer(state.clone(), document_change_queue_receiver);
129
130        Self { state }
131    }
132
133    /// Construct a new `LspService<ApolloLanguageServer>` with related channels
134    /// given the provided `Config`. This returns a tuple of:
135    /// - The built tower-lsp `LspService` instance.
136    /// - The `ClientSocket` instance.
137    /// - The `Receiver` for the composition request channel.
138    ///
139    /// Example:
140    /// ```no_run
141    /// use std::collections::HashMap;
142    /// use apollo_language_server::{ApolloLanguageServer, Config};
143    /// use tower_lsp::Server;
144    ///
145    /// let (service, client_socket, composition_request_receiver) = ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
146    /// let input = tokio::io::stdin();
147    /// let output = tokio::io::stdout();
148    /// Server::new(input, output, client_socket)
149    ///     .serve(service);
150    /// ```
151    pub fn build_service(
152        config: Config,
153        known_subgraphs: HashMap<String, SchemaSource>,
154        spec_lookup: Option<SpecLookup>,
155    ) -> (
156        LspService<Self>,
157        ClientSocket,
158        Receiver<Vec<SubgraphDefinition>>,
159    ) {
160        let (composition_request_sender, composition_request_receiver) =
161            channel::<Vec<SubgraphDefinition>>(1);
162
163        #[cfg(any(not(feature = "wasm"), test))]
164        let known_subgraphs = KnownSubgraphs {
165            root_uri: config.root_uri.clone(),
166            by_name: known_subgraphs
167                .iter()
168                .map(|(name, source)| (name.clone(), source.clone()))
169                .collect(),
170            by_uri: known_subgraphs
171                .iter()
172                .filter_map(|(name, source)| match source {
173                    SchemaSource::File { file } => {
174                        let uri = if file.is_relative() {
175                            // build a uri from config.root_uri and file path
176                            let url = lsp::Url::from_directory_path(&config.root_uri)
177                                .expect("Failed to parse URL");
178                            url.join(file.to_str().expect("Failed to convert path to string"))
179                                .expect("Failed to join URL")
180                        } else {
181                            lsp::Url::from_file_path(file).expect("Failed to convert path to URL")
182                        };
183
184                        Some((uri, name.clone()))
185                    }
186                    _ => None,
187                })
188                .collect(),
189        };
190
191        #[cfg(all(feature = "wasm", not(test)))]
192        let known_subgraphs = KnownSubgraphs::default();
193
194        let (service, client_socket) = LspService::build(|client| {
195            ApolloLanguageServer::new(
196                client,
197                composition_request_sender,
198                config,
199                known_subgraphs,
200                spec_lookup,
201            )
202        })
203        .custom_method(
204            ApolloConfigureAutoCompositionNotification::METHOD,
205            ApolloLanguageServer::configure_auto_composition,
206        )
207        .custom_method(
208            ApolloComposeServicesRequest::METHOD,
209            ApolloLanguageServer::request_recompose,
210        )
211        .finish();
212        (service, client_socket, composition_request_receiver)
213    }
214
215    fn capabilities() -> lsp::InitializeResult {
216        lsp::InitializeResult {
217            capabilities: lsp::ServerCapabilities {
218                text_document_sync: Some(lsp::TextDocumentSyncCapability::Options(
219                    lsp::TextDocumentSyncOptions {
220                        change: Some(lsp::TextDocumentSyncKind::FULL),
221                        open_close: Some(true),
222                        ..Default::default()
223                    },
224                )),
225                completion_provider: Some(lsp::CompletionOptions {
226                    // Trigger characters force the completions prompt to show as soon as that character is typed in the case
227                    // that we need to show items when the user types a special character.
228                    trigger_characters: Some(vec![
229                        // `@` in order to properly show completions for directives.
230                        "@".to_string(),
231                        // `|` in order to properly show completions for directive locations.
232                        "|".to_string(),
233                        // `&` in order to properly show completions for implementing interfaces.
234                        "&".to_string(),
235                        // `"`  in order to properly show completions for particular string values (i.e. spec import directives).
236                        "\"".to_string(),
237                    ]),
238                    ..Default::default()
239                }),
240                semantic_tokens_provider: Some(
241                    lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(
242                        lsp::SemanticTokensOptions {
243                            work_done_progress_options: lsp::WorkDoneProgressOptions {
244                                work_done_progress: None,
245                            },
246                            legend: lsp::SemanticTokensLegend {
247                                token_types: LEGEND_TYPE.into(),
248                                token_modifiers: vec![],
249                            },
250                            range: Some(false),
251                            full: Some(lsp::SemanticTokensFullOptions::Delta { delta: Some(true) }),
252                        },
253                    ),
254                ),
255                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
256                definition_provider: Some(lsp::OneOf::Left(true)),
257                ..Default::default()
258            },
259            ..Default::default()
260        }
261    }
262
263    async fn request_recompose(
264        &self,
265        _params: ApolloComposeServicesParams,
266    ) -> jsonrpc::Result<ApolloComposeServicesResponse> {
267        let did_compose = self.state.maybe_recompose().await;
268        Ok(ApolloComposeServicesResponse { did_compose })
269    }
270
271    async fn configure_auto_composition(&self, params: ApolloConfigureAutoCompositionParams) {
272        {
273            let mut config = self.state.config.lock().await;
274            config.enable_auto_composition = params.enabled;
275        }
276        self.state.maybe_auto_recompose().await;
277    }
278
279    /// This method must be called by the host when composition begins in order
280    /// to notify the language client with a `WorkDoneProgressBegin` event. The
281    /// corresponding `WorkDoneProgressEnd` event will be sent by the language
282    /// server in `composition_did_update`.
283    pub async fn composition_did_start(&self) {
284        self.state
285            .client
286            .lock()
287            .await
288            .send_notification::<lsp::notification::Progress>(lsp::ProgressParams {
289                token: lsp::ProgressToken::String(APOLLO_COMPOSITION_PROGRESS_TOKEN.to_string()),
290                value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Begin(
291                    lsp::WorkDoneProgressBegin {
292                        title: "Composing services".to_string(),
293                        cancellable: Some(false),
294                        message: None,
295                        percentage: None,
296                    },
297                )),
298            })
299            .await;
300    }
301
302    /// This method must be called by the host of the language server after
303    /// completing a composition request. It will update the supergraph and
304    /// subsequently publish diagnostics for each subgraph.
305    /// > Note: A `WorkDoneProgressEnd` event will be sent after this completes.
306    /// > The `composition_did_start` method must be called before this method
307    /// > in order to send the matching `WorkDoneProgressBegin` event.
308    pub async fn composition_did_update(
309        &self,
310        _supergraph_sdl: Option<String>,
311        issues: Vec<Issue>,
312        timing: Option<Duration>,
313    ) {
314        self.state.composition_did_update(issues).await;
315        if let Some(timing) = timing {
316            self.send_telemetry_if_enabled(TelemetryEvent::Analytics(
317                AnalyticsEvent::CompositionTimeInMs(CompositionTiming {
318                    value: timing.as_millis() as u64,
319                }),
320            ))
321            .await;
322        }
323    }
324
325    /// This method must be called by the host of the language server when a new
326    /// subgraph is added to the supergraph. Concretely, when a new subgraph is
327    /// added to the supergraph.yaml, this method should be called.
328    #[cfg(any(not(feature = "wasm"), test))]
329    pub async fn add_subgraph(&self, name: String, source: SchemaSource) {
330        let Some(Graph::Supergraph(supergraph)) = &mut *self.state.graph.lock().await else {
331            panic!("Graph is unexpectedly not a supergraph");
332        };
333
334        supergraph.add_known_subgraph(name, source).await;
335    }
336
337    /// This method must be called by the host of the language server when a
338    /// subgraph is removed from the supergraph. Concretely, when a subgraph is
339    /// removed from the supergraph.yaml, this method should be called.
340    #[cfg(any(not(feature = "wasm"), test))]
341    pub async fn remove_subgraph(&self, name: &str) {
342        let Some(Graph::Supergraph(supergraph)) = &mut *self.state.graph.lock().await else {
343            panic!("Graph is unexpectedly not a supergraph");
344        };
345
346        if let Some(uri) = supergraph.uri_for_name(name) {
347            self.state
348                .client
349                .lock()
350                .await
351                .publish_diagnostics(uri.clone(), vec![], None)
352                .await;
353        }
354
355        supergraph.remove_known_subgraph(name).await;
356    }
357
358    /// The host can call this method to publish diagnostics for a given URI.
359    pub async fn publish_diagnostics(&self, uri: lsp::Url, diagnostics: Vec<lsp::Diagnostic>) {
360        let graph = self.state.graph.lock().await;
361        let version = graph.as_ref().and_then(|graph| graph.version_for_uri(&uri));
362
363        self.state
364            .client
365            .lock()
366            .await
367            .publish_diagnostics(uri, diagnostics, version)
368            .await;
369    }
370
371    async fn send_telemetry_if_enabled(&self, event: TelemetryEvent) {
372        if !self.state.config.lock().await.disable_telemetry {
373            self.state.client.lock().await.telemetry_event(event).await;
374        }
375    }
376
377    #[cfg(test)]
378    async fn config(&self) -> Config {
379        self.state.config.lock().await.clone()
380    }
381}
382
383impl State {
384    async fn maybe_auto_recompose(&self) {
385        if !self.config.lock().await.enable_auto_composition {
386            return;
387        }
388        self.maybe_recompose().await;
389    }
390
391    async fn maybe_send_can_compose_notification(&self) {
392        let invalid_subgraph_names = if let Some(Some(supergraph)) =
393            self.graph.lock().await.as_ref().map(Graph::supergraph)
394        {
395            // We only want to allow recomposition if none of the subgraphs have
396            // any parse/validation errors. We know those will need to be
397            // addressed before we can attempt to compose.
398            supergraph.get_invalid_subgraph_uris()
399        } else {
400            return;
401        };
402
403        self.client
404            .lock()
405            .await
406            .send_notification::<ApolloCanComposeNotification>(ApolloCanComposeNotificationParams {
407                can_compose: invalid_subgraph_names.is_empty(),
408                subgraphs_with_errors: invalid_subgraph_names,
409            })
410            .await;
411    }
412
413    async fn maybe_recompose(&self) -> bool {
414        let subgraph_definitions = if let Some(Some(supergraph)) =
415            self.graph.lock().await.as_ref().map(Graph::supergraph)
416        {
417            // We only want to recompose if none of the subgraphs have any
418            // parse/validation errors. We know those will need to be addressed
419            // before we can attempt to compose.
420            if !supergraph.subgraphs_are_invalid() {
421                supergraph.subgraph_definitions()
422            } else {
423                return false;
424            }
425        } else {
426            return false;
427        };
428
429        self.request_composition
430            .lock()
431            .await
432            .send(subgraph_definitions)
433            .await
434            .expect("Failed to send message");
435        true
436    }
437
438    async fn composition_did_update(&self, issues: Vec<Issue>) {
439        let (diagnostics_by_subgraph, unattributed_diagnostics) = {
440            let graph = self.graph.lock().await;
441            match graph.as_ref() {
442                Some(Graph::Supergraph(supergraph)) => supergraph.diagnostics_for_composition(issues),
443                _ => panic!("Programming error: called `composition_did_update` when graph is not a supergraph."),
444            }
445        };
446
447        let client = self.client.lock().await;
448        join_all(
449            diagnostics_by_subgraph
450                .into_iter()
451                .map(|(url, (diagnostics, version))| {
452                    client.publish_diagnostics(url, diagnostics, Some(version))
453                }),
454        )
455        .await;
456
457        client
458            .publish_diagnostics(
459                lsp::Url::parse(&self.config.lock().await.root_uri).expect("Failed to parse URL"),
460                unattributed_diagnostics,
461                0.into(),
462            )
463            .await;
464
465        client
466            .send_notification::<lsp::notification::Progress>(lsp::ProgressParams {
467                token: lsp::ProgressToken::String(APOLLO_COMPOSITION_PROGRESS_TOKEN.to_string()),
468                value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::End(
469                    lsp::WorkDoneProgressEnd { message: None },
470                )),
471            })
472            .await;
473    }
474
475    fn initialize_document_update_debouncer(
476        state: Arc<State>,
477        document_change_queue_receiver: Receiver<lsp::DidChangeTextDocumentParams>,
478    ) {
479        spawn(async move {
480            let mut debounced_document_change_queue = debounced(
481                document_change_queue_receiver,
482                DOCUMENT_UPDATE_DEBOUNCE_INTERVAL,
483            );
484
485            while let Some(data) = debounced_document_change_queue.next().await {
486                state.handle_document_update(data).await;
487            }
488        });
489    }
490
491    async fn get_fed_spec_for_uri(&self, uri: &lsp::Url) -> Option<String> {
492        let lookup = self.spec_lookup.as_ref()?;
493        let (sender, receiver) = oneshot::channel();
494        let mut guard = lookup.lock().await;
495        guard.send((PathBuf::from(uri.path()), sender)).await.ok();
496        receiver.await.ok().flatten()
497    }
498
499    async fn handle_document_update(&self, data: lsp::DidChangeTextDocumentParams) {
500        let lsp::DidChangeTextDocumentParams {
501            text_document,
502            content_changes,
503        } = data;
504        let lsp::VersionedTextDocumentIdentifier { uri, version } = text_document;
505        let text = content_changes
506            .into_iter()
507            .next()
508            .expect("Expected at least one content change")
509            .text;
510
511        let graph_config = {
512            let federation_spec = self.get_fed_spec_for_uri(&uri).await;
513            let config = self.config.lock().await;
514
515            GraphConfig {
516                force_federation: config.force_federation,
517                federation_spec,
518            }
519        };
520
521        let (diagnostics, version) = {
522            let mut graph = self.graph.lock().await;
523
524            let Some(graph) = graph.as_mut() else {
525                panic!("Attempted to change a document that hasn't been opened");
526            };
527            graph.update(uri.clone(), text, version, graph_config);
528
529            graph.diagnostics_for_uri(&uri)
530        };
531
532        self.client
533            .lock()
534            .await
535            .publish_diagnostics(uri.clone(), diagnostics, Some(version))
536            .await;
537
538        self.maybe_auto_recompose().await;
539        self.maybe_send_can_compose_notification().await;
540    }
541}
542
543#[async_trait]
544impl LanguageServer for ApolloLanguageServer {
545    async fn initialize(
546        &self,
547        initialize_params: lsp::InitializeParams,
548    ) -> jsonrpc::Result<lsp::InitializeResult> {
549        let options: Option<Config> = initialize_params
550            .initialization_options
551            .map(|options| {
552                serde_json::from_value(options).map_err(|err| jsonrpc::Error {
553                    message: Cow::from(err.to_string()),
554                    code: jsonrpc::ErrorCode::InvalidParams,
555                    data: None,
556                })
557            })
558            .transpose()?;
559
560        let mut config = self.state.config.lock().await;
561        if let Some(options) = options {
562            *config = options;
563        }
564        config.root_uri = initialize_params
565            .root_uri
566            .map(|uri| uri.to_string())
567            .unwrap_or("inmemory://".to_string());
568
569        Ok(ApolloLanguageServer::capabilities())
570    }
571
572    async fn initialized(&self, _: lsp::InitializedParams) {
573        self.send_telemetry_if_enabled(TelemetryEvent::Analytics(AnalyticsEvent::Initialized))
574            .await;
575    }
576
577    async fn shutdown(&self) -> jsonrpc::Result<()> {
578        Ok(())
579    }
580
581    async fn did_open(&self, params: lsp::DidOpenTextDocumentParams) {
582        let lsp::TextDocumentItem {
583            uri,
584            version,
585            text,
586            language_id,
587        } = params.text_document;
588
589        if !LANGUAGE_IDS.contains(&language_id.as_str()) {
590            return;
591        }
592
593        let graph_config = {
594            let federation_spec = self.state.get_fed_spec_for_uri(&uri).await;
595            let config = self.state.config.lock().await;
596            GraphConfig {
597                force_federation: config.force_federation,
598                federation_spec,
599            }
600        };
601
602        // This step applies to both monolith and supergraph. Capture any
603        // diagnostics for the new document. We'll publish them after the block
604        // so as not to hold the lock across an `await`.
605        let (diagnostics_for_uri, _) = {
606            let mut graph = self.state.graph.lock().await;
607            if graph.is_some() {
608                graph
609                    .as_mut()
610                    .unwrap()
611                    .update(uri.clone(), text, version, graph_config);
612            } else {
613                let new_graph = Graph::new(
614                    uri.clone(),
615                    text,
616                    version,
617                    KnownSubgraphs::default(),
618                    graph_config,
619                );
620                *graph = Some(new_graph);
621            };
622
623            graph.as_ref().unwrap().diagnostics_for_uri(&uri)
624        };
625
626        if !diagnostics_for_uri.is_empty() {
627            self.state
628                .client
629                .lock()
630                .await
631                .publish_diagnostics(uri, diagnostics_for_uri, Some(version))
632                .await;
633        }
634
635        self.state.maybe_auto_recompose().await;
636        self.state.maybe_send_can_compose_notification().await;
637    }
638
639    async fn did_change(&self, params: lsp::DidChangeTextDocumentParams) {
640        if params.content_changes[0].text.len() < DOCUMENT_SIZE_DEBOUNCE_THRESHOLD {
641            self.state.handle_document_update(params).await;
642        } else {
643            self.state
644                .document_change_queue_sender
645                .lock()
646                .await
647                .send(params.clone())
648                .await
649                .expect("Failed to send message")
650        }
651    }
652
653    async fn did_close(&self, params: lsp::DidCloseTextDocumentParams) {
654        {
655            let mut graph = self.state.graph.lock().await;
656            match &mut *graph {
657                Some(Graph::Monolith(_)) => {
658                    *graph = None;
659                }
660                Some(Graph::Supergraph(supergraph)) => {
661                    if supergraph.remove(&params.text_document.uri).is_none() {
662                        panic!("Subgraph is unexpectedly None");
663                    }
664                }
665                None => panic!("Graph is unexpectedly None"),
666            }
667        }
668        self.state.maybe_auto_recompose().await;
669        self.state.maybe_send_can_compose_notification().await;
670    }
671
672    async fn completion(
673        &self,
674        params: lsp::CompletionParams,
675    ) -> jsonrpc::Result<Option<lsp::CompletionResponse>> {
676        let graph = self.state.graph.lock().await;
677        let max_spec_versions = &self.state.config.lock().await.max_spec_versions;
678
679        Ok(graph.as_ref().and_then(|graph| {
680            graph.completions(
681                &params.text_document_position.text_document.uri,
682                params.text_document_position.position,
683                max_spec_versions,
684            )
685        }))
686    }
687
688    async fn semantic_tokens_full(
689        &self,
690        params: lsp::SemanticTokensParams,
691    ) -> jsonrpc::Result<Option<lsp::SemanticTokensResult>> {
692        let graph = self.state.graph.lock().await;
693        Ok(graph.as_ref().map(|graph| {
694            let tokens = graph
695                .semantic_tokens_full(&params.text_document.uri)
696                .unwrap_or_default();
697            lsp::SemanticTokensResult::Tokens(lsp::SemanticTokens {
698                result_id: None,
699                data: incomplete_tokens_to_deltas(tokens),
700            })
701        }))
702    }
703
704    async fn hover(&self, params: lsp::HoverParams) -> jsonrpc::Result<Option<lsp::Hover>> {
705        let graph = self.state.graph.lock().await;
706        Ok(graph.as_ref().and_then(|graph| {
707            graph.on_hover(
708                &params.text_document_position_params.text_document.uri,
709                &params.text_document_position_params.position,
710            )
711        }))
712    }
713
714    async fn goto_definition(
715        &self,
716        params: lsp::GotoDefinitionParams,
717    ) -> jsonrpc::Result<Option<lsp::GotoDefinitionResponse>> {
718        let graph = self.state.graph.lock().await;
719        Ok(graph.as_ref().map(|graph| {
720            lsp::GotoDefinitionResponse::from(
721                graph
722                    .goto_definition(
723                        &params.text_document_position_params.text_document.uri,
724                        &params.text_document_position_params.position,
725                    )
726                    .unwrap_or_default(),
727            )
728        }))
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use apollo_federation_types::composition::{Severity, SubgraphLocation};
736    use lsp::{InitializeParams, PublishDiagnosticsParams};
737    use serde_json::{from_value, json, to_value};
738    use tokio::{sync::Mutex, task::yield_now};
739    use tower::{Service, ServiceExt};
740    use tower_lsp::{jsonrpc, LspService, Server};
741
742    fn initialize_request(id: i64) -> jsonrpc::Request {
743        jsonrpc::Request::build("initialize")
744            .params(json!({"capabilities":{}}))
745            .id(id)
746            .finish()
747    }
748
749    fn initialize_request_with_server_configurations(id: i64) -> jsonrpc::Request {
750        jsonrpc::Request::build("initialize")
751            .params(
752                to_value(InitializeParams {
753                    initialization_options: Some(json!({
754                        "enableAutoComposition": true,
755                        "disableTelemetry": true,
756                    })),
757                    process_id: None,
758                    root_uri: Some(lsp::Url::from_directory_path("/path/to/project").unwrap()),
759                    ..Default::default()
760                })
761                .unwrap(),
762            )
763            .id(id)
764            .finish()
765    }
766
767    fn initialized_notification() -> jsonrpc::Request {
768        jsonrpc::Request::build("initialized")
769            .params(json!({}))
770            .finish()
771    }
772
773    fn shutdown_request() -> jsonrpc::Request {
774        jsonrpc::Request::build("shutdown").finish()
775    }
776
777    fn server_capabilities() -> serde_json::Value {
778        serde_json::to_value(ApolloLanguageServer::capabilities()).unwrap()
779    }
780
781    async fn request(
782        server: &mut LspService<ApolloLanguageServer>,
783        request: jsonrpc::Request,
784    ) -> Option<jsonrpc::Response> {
785        server.ready().await.unwrap().call(request).await.unwrap()
786    }
787
788    #[allow(clippy::type_complexity)]
789    fn listen_to_channels(
790        mut socket: ClientSocket,
791        mut composition_listener: Receiver<Vec<SubgraphDefinition>>,
792    ) -> (
793        Arc<Mutex<Vec<jsonrpc::Request>>>,
794        Arc<Mutex<Vec<Vec<SubgraphDefinition>>>>,
795    ) {
796        let socket_messages = Arc::new(Mutex::new(Vec::default()));
797        let composition_listener_messages = Arc::new(Mutex::new(Vec::default()));
798
799        let socket_messages_inner = socket_messages.clone();
800        let composition_listener_messages_inner = composition_listener_messages.clone();
801        tokio::spawn(async move {
802            while let Some(data) = socket.next().await {
803                socket_messages_inner.lock().await.push(data)
804            }
805        });
806        tokio::spawn(async move {
807            while let Some(data) = composition_listener.next().await {
808                composition_listener_messages_inner.lock().await.push(data)
809            }
810        });
811
812        (socket_messages, composition_listener_messages)
813    }
814
815    #[tokio::test]
816    async fn initialization_and_shutdown() {
817        let (mut server, ..) =
818            ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
819
820        assert_eq!(
821            request(&mut server, initialize_request(1)).await,
822            Some(jsonrpc::Response::from_ok(1.into(), server_capabilities()))
823        );
824
825        assert_eq!(request(&mut server, initialized_notification()).await, None);
826
827        // TODO: something isn't working here, should be an error after 2nd shutdown request
828        // and our shutdown method isn't being called at all
829        assert_eq!(request(&mut server, shutdown_request()).await, None);
830        assert_eq!(request(&mut server, shutdown_request()).await, None);
831    }
832
833    #[tokio::test]
834    async fn initialization_with_client_config() {
835        let (mut server, ..) =
836            ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
837
838        assert_eq!(
839            request(
840                &mut server,
841                initialize_request_with_server_configurations(1)
842            )
843            .await,
844            Some(jsonrpc::Response::from_ok(1.into(), server_capabilities()))
845        );
846
847        assert!(server.inner().config().await.enable_auto_composition);
848        assert_eq!(
849            server.inner().config().await.root_uri,
850            "file:///path/to/project/".to_string()
851        );
852
853        assert_eq!(request(&mut server, initialized_notification()).await, None);
854    }
855
856    #[tokio::test]
857    async fn initialization_without_root_uri_specified() {
858        let (mut server, ..) =
859            ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
860
861        assert_eq!(
862            request(&mut server, initialize_request(1)).await,
863            Some(jsonrpc::Response::from_ok(1.into(), server_capabilities()))
864        );
865
866        assert_eq!(
867            server.inner().state.config.lock().await.root_uri,
868            "inmemory://".to_string()
869        );
870
871        assert_eq!(request(&mut server, initialized_notification()).await, None);
872    }
873
874    #[tokio::test]
875    async fn diagnostics() {
876        let (mut server, socket, composition_listener) =
877            ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
878
879        let (socket_messages, composition_listener_messages) =
880            listen_to_channels(socket, composition_listener);
881
882        request(
883            &mut server,
884            // enable composition for this test
885            initialize_request_with_server_configurations(1),
886        )
887        .await;
888        request(&mut server, initialized_notification()).await;
889
890        // On didOpen, we don't expect any diagnostics to be published when the
891        // document is valid. Further down, on didChange, we expect empty diagnostics
892        // to be published to clear any existing ones.
893        request(
894            &mut server,
895            jsonrpc::Request::build("textDocument/didOpen")
896                .params(json!({
897                    "textDocument": {
898                        "uri": "file:///path/to/file",
899                        "version": 1,
900                        "languageId": "apollo-graphql",
901                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String }"
902                    }
903                }))
904                .finish(),
905        )
906        .await;
907
908        request(
909            &mut server,
910            jsonrpc::Request::build("textDocument/didChange")
911                .params(json!({
912                    "textDocument": {
913                        "uri": "file:///path/to/file",
914                        "version": 2,
915                    },
916                    "contentChanges": [{
917                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String! }"
918                    }]
919                }))
920                .finish(),
921        )
922        .await;
923
924        // Wait for the client and composition channel to send their messages
925        yield_now().await;
926
927        let messages = &*socket_messages.lock().await;
928        assert_eq!(
929            messages,
930            &[
931                // no diagnostics published on didOpen, since the document is valid
932                jsonrpc::Request::build("apollo/canCompose")
933                    .params(json!({"canCompose": true, "subgraphsWithErrors": []}))
934                    .finish(),
935                // empty diagnostics published on didChange to clear any existing ones
936                jsonrpc::Request::build("textDocument/publishDiagnostics")
937                    .params(json!({"uri": "file:///path/to/file", "diagnostics": [], "version": 2}))
938                    .finish(),
939                jsonrpc::Request::build("apollo/canCompose")
940                    .params(json!({"canCompose": true, "subgraphsWithErrors": []}))
941                    .finish(),
942            ]
943        );
944
945        let composition_messages = &*composition_listener_messages.lock().await;
946        assert_eq!(composition_messages, &[
947            [
948                SubgraphDefinition {
949                    name: "file:///path/to/file".into(), 
950                    url: "file:///path/to/file".into(), 
951                    sdl: "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String }".into() 
952                }
953            ],[
954                SubgraphDefinition {
955                    name: "file:///path/to/file".into(), 
956                    url: "file:///path/to/file".into(), 
957                    sdl: "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String! }".into() 
958                }
959            ]
960        ]);
961    }
962
963    #[tokio::test]
964    async fn registers_non_zero_document_version_on_open() {
965        let (mut server, socket, composition_listener) =
966            ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
967
968        let (socket_messages, _) = listen_to_channels(socket, composition_listener);
969
970        request(
971            &mut server,
972            // enable composition for this test
973            initialize_request_with_server_configurations(1),
974        )
975        .await;
976        request(&mut server, initialized_notification()).await;
977
978        request(
979            &mut server,
980            jsonrpc::Request::build("textDocument/didOpen")
981                .params(json!({
982                    "textDocument": {
983                        "uri": "file:///path/to/file",
984                        "version": 42,
985                        "languageId": "apollo-graphql",
986                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String"
987                    }
988                }))
989                .finish(),
990        )
991        .await;
992
993        let messages = &*socket_messages.lock().await;
994        // The published diagnostics should include the document version that
995        // was provided in the didOpen request.
996        assert_eq!(messages[0].params().unwrap()["version"], 42);
997    }
998
999    #[tokio::test]
1000    async fn runs_in_tower_lsp_server() {
1001        fn mock_msg(msg: &str) -> String {
1002            format!("Content-Length: {}\r\n\r\n{}", msg.len(), msg)
1003        }
1004
1005        let mock_request = mock_msg(
1006            r#"{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":0}"#,
1007        );
1008
1009        let mock_response = mock_msg(
1010            &serde_json::to_string(&jsonrpc::Response::from_ok(0.into(), server_capabilities()))
1011                .unwrap(),
1012        );
1013
1014        let (sender, _) = channel::<Vec<SubgraphDefinition>>(1);
1015        let (service, socket) = LspService::new(|client| {
1016            ApolloLanguageServer::new(client, sender, Config::default(), Default::default(), None)
1017        });
1018
1019        let mut stdout = Vec::new();
1020
1021        Server::new(&mut mock_request.as_bytes(), &mut stdout, socket)
1022            .serve(service)
1023            .await;
1024
1025        let stdout = String::from_utf8(stdout).unwrap();
1026        assert_eq!(stdout, mock_response);
1027    }
1028
1029    #[tokio::test]
1030    async fn did_close() {
1031        let (mut server, _, mut composition_listener) =
1032            ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
1033
1034        request(
1035            &mut server,
1036            // enable composition for this test
1037            initialize_request_with_server_configurations(1),
1038        )
1039        .await;
1040        request(&mut server, initialized_notification()).await;
1041
1042        // On didOpen, we don't expect any diagnostics to be published when the
1043        // document is valid.
1044        request(
1045            &mut server,
1046            jsonrpc::Request::build("textDocument/didOpen")
1047                .params(json!({
1048                    "textDocument": {
1049                        "uri": "file:///path/to/file1",
1050                        "version": 1,
1051                        "languageId": "apollo-graphql",
1052                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String }"
1053                    }
1054                }))
1055                .finish(),
1056        )
1057        .await;
1058
1059        // The server will hang / wait for this message to be consumed. For this
1060        // test we don't really need to do anything with it.
1061        composition_listener.next().await.unwrap();
1062
1063        request(
1064            &mut server,
1065            jsonrpc::Request::build("textDocument/didOpen")
1066                .params(json!({
1067                    "textDocument": {
1068                        "uri": "file:///path/to/file2",
1069                        "version": 1,
1070                        "languageId": "apollo-graphql",
1071                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { goodbye: String! }"
1072                    },
1073                }))
1074                .finish(),
1075        )
1076        .await;
1077
1078        // The server will hang / wait for this message to be consumed. For this
1079        // test we don't really need to do anything with it.
1080        composition_listener.next().await.unwrap();
1081
1082        request(
1083            &mut server,
1084            jsonrpc::Request::build("textDocument/didClose")
1085                .params(json!({
1086                    "textDocument": {
1087                        "uri": "file:///path/to/file1",
1088                    }
1089                }))
1090                .finish(),
1091        )
1092        .await;
1093
1094        // On didClose, we expect composition to be requested again. If it
1095        // doesn't, this test will hang.
1096        composition_listener.next().await.unwrap();
1097    }
1098
1099    #[tokio::test]
1100    async fn custom_notification_toggle_auto_composition() {
1101        let (mut server, _, _) =
1102            ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
1103
1104        request(
1105            &mut server,
1106            initialize_request_with_server_configurations(1),
1107        )
1108        .await;
1109        request(&mut server, initialized_notification()).await;
1110
1111        assert!(server.inner().config().await.enable_auto_composition);
1112        request(
1113            &mut server,
1114            jsonrpc::Request::build(ApolloConfigureAutoCompositionNotification::METHOD)
1115                .params(json!({"enabled": false}))
1116                .finish(),
1117        )
1118        .await;
1119        assert!(!server.inner().config().await.enable_auto_composition);
1120        request(
1121            &mut server,
1122            jsonrpc::Request::build(ApolloConfigureAutoCompositionNotification::METHOD)
1123                .params(json!({"enabled": true}))
1124                .finish(),
1125        )
1126        .await;
1127        assert!(server.inner().config().await.enable_auto_composition);
1128    }
1129
1130    #[tokio::test]
1131    async fn custom_notification_trigger_recomposition() {
1132        let (mut server, socket, composition_listener) =
1133            ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
1134
1135        let (socket_messages, composition_listener_messages) =
1136            listen_to_channels(socket, composition_listener);
1137
1138        request(
1139            &mut server,
1140            initialize_request_with_server_configurations(1),
1141        )
1142        .await;
1143
1144        request(&mut server, initialized_notification()).await;
1145
1146        request(
1147            &mut server,
1148            jsonrpc::Request::build("textDocument/didOpen")
1149                .params(json!({
1150                    "textDocument": {
1151                        "uri": "file:///path/to/file1",
1152                        "version": 1,
1153                        "languageId": "apollo-graphql",
1154                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String }"
1155                    }
1156                }))
1157                .finish(),
1158        ).await;
1159
1160        request(
1161            &mut server,
1162            jsonrpc::Request::build(ApolloComposeServicesRequest::METHOD)
1163                .id(100)
1164                .params(json!({}))
1165                .finish(),
1166        )
1167        .await;
1168
1169        let messages = &*socket_messages.lock().await;
1170        assert_eq!(
1171            messages,
1172            &[jsonrpc::Request::build("apollo/canCompose")
1173                .params(json!({"canCompose": true, "subgraphsWithErrors": []}))
1174                .finish(),]
1175        );
1176
1177        let composition_messages = &*composition_listener_messages.lock().await;
1178        assert_eq!(composition_messages, &[
1179            [
1180                SubgraphDefinition {
1181                    name: "file:///path/to/file1".into(), 
1182                    url: "file:///path/to/file1".into(), 
1183                    sdl: "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String }".into() 
1184                }
1185            ],
1186            // the manual request
1187            [
1188                SubgraphDefinition {
1189                    name: "file:///path/to/file1".into(), 
1190                    url: "file:///path/to/file1".into(), 
1191                    sdl: "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String }".into() 
1192                }
1193            ]
1194        ]);
1195    }
1196
1197    #[tokio::test]
1198    async fn with_graphql_language_id() {
1199        let (mut server, _, mut composition_listener) =
1200            ApolloLanguageServer::build_service(Config::default(), HashMap::new(), None);
1201
1202        request(
1203            &mut server,
1204            // enable composition for this test
1205            initialize_request_with_server_configurations(1),
1206        )
1207        .await;
1208        request(&mut server, initialized_notification()).await;
1209
1210        // On didOpen, we don't expect any diagnostics to be published when the
1211        // document is valid.
1212        request(
1213            &mut server,
1214            jsonrpc::Request::build("textDocument/didOpen")
1215                .params(json!({
1216                    "textDocument": {
1217                        "uri": "file:///path/to/file1",
1218                        "version": 1,
1219                        "languageId": "graphql",
1220                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String }"
1221                    }
1222                }))
1223                .finish(),
1224        )
1225        .await;
1226
1227        // The server will hang / wait for this message to be consumed. For this
1228        // test we don't really need to do anything with it.
1229        composition_listener.next().await.unwrap();
1230
1231        request(
1232            &mut server,
1233            jsonrpc::Request::build("textDocument/didChange")
1234                .params(json!({
1235                    "textDocument": {
1236                        "uri": "file:///path/to/file1",
1237                        "version": 2,
1238                    },
1239                    "contentChanges": [{
1240                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String! }"
1241                    }]
1242                }))
1243                .finish(),
1244        )
1245        .await;
1246
1247        // The server will hang / wait for this message to be consumed. For this
1248        // test we don't really need to do anything with it.
1249        composition_listener.next().await.unwrap();
1250
1251        request(
1252            &mut server,
1253            jsonrpc::Request::build("textDocument/didClose")
1254                .params(json!({
1255                    "textDocument": {
1256                        "uri": "file:///path/to/file1",
1257                    }
1258                }))
1259                .finish(),
1260        )
1261        .await;
1262
1263        // On didClose, we expect composition to be requested again. If it
1264        // doesn't, this test will hang.
1265        composition_listener.next().await.unwrap();
1266    }
1267
1268    #[tokio::test]
1269    async fn publishing_diagnostics_to_known_subgraphs() {
1270        // Initialize the server with known subgraphs.
1271        // Note that `remote` has no URI - its composition diagnostics should be published to the root URI.
1272        // The other two subgraphs should have their diagnostics published to their respective URIs.
1273        let (mut service, socket, composition_listener) = ApolloLanguageServer::build_service(
1274            Config {
1275                root_uri: "/path/to/project".into(),
1276                ..Default::default()
1277            },
1278            [
1279                (
1280                    "local".into(),
1281                    SchemaSource::File {
1282                        file: "local.graphql".into(),
1283                    },
1284                ),
1285                (
1286                    "local_relative".into(),
1287                    SchemaSource::File {
1288                        file: "../../local_relative.graphql".into(),
1289                    },
1290                ),
1291                (
1292                    "remote".into(),
1293                    SchemaSource::Subgraph {
1294                        graphref: "testing@current".into(),
1295                        subgraph: "remote".into(),
1296                    },
1297                ),
1298            ]
1299            .into_iter()
1300            .collect(),
1301            None,
1302        );
1303
1304        let (socket_messages, _) = listen_to_channels(socket, composition_listener);
1305
1306        request(
1307            &mut service,
1308            initialize_request_with_server_configurations(1),
1309        )
1310        .await;
1311
1312        request(&mut service, initialized_notification()).await;
1313
1314        // didOpen request for local subgraph
1315        request(&mut service, jsonrpc::Request::build("textDocument/didOpen")
1316                .params(json!({
1317                    "textDocument": {
1318                        "uri": "file:///path/to/project/local.graphql",
1319                        "version": 1,
1320                        "languageId": "graphql",
1321                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello: String }"
1322                    }
1323                }))
1324                .finish()).await;
1325
1326        // didOpen request for local_relative subgraph
1327        request(&mut service, jsonrpc::Request::build("textDocument/didOpen")
1328                .params(json!({
1329                    "textDocument": {
1330                        "uri": "file:///path/local_relative.graphql",
1331                        "version": 1,
1332                        "languageId": "graphql",
1333                        "text": "extend schema @link(url: \"https://specs.apollo.dev/federation/v2.10\")\ntype Query { hello2: String }"
1334                    }
1335                }))
1336                .finish()).await;
1337
1338        service
1339            .inner()
1340            .composition_did_update(
1341                None,
1342                vec![
1343                    Issue {
1344                        code: "TEST_REMOTE".into(),
1345                        message: "Test issue".into(),
1346                        locations: vec![SubgraphLocation {
1347                            subgraph: Some("remote".into()),
1348                            range: None,
1349                        }],
1350                        severity: Severity::Error,
1351                    },
1352                    Issue {
1353                        code: "TEST_LOCAL".into(),
1354                        message: "Test issue".into(),
1355                        locations: vec![SubgraphLocation {
1356                            subgraph: Some("local".into()),
1357                            range: None,
1358                        }],
1359                        severity: Severity::Error,
1360                    },
1361                    Issue {
1362                        code: "TEST_LOCAL_RELATIVE".into(),
1363                        message: "Test issue".into(),
1364                        locations: vec![SubgraphLocation {
1365                            subgraph: Some("local_relative".into()),
1366                            range: None,
1367                        }],
1368                        severity: Severity::Error,
1369                    },
1370                ],
1371                None,
1372            )
1373            .await;
1374
1375        let lock = socket_messages.lock().await;
1376        let diagnostics = lock
1377            .iter()
1378            .filter(|msg| msg.method() == "textDocument/publishDiagnostics")
1379            .map(|msg| {
1380                from_value::<PublishDiagnosticsParams>(msg.params().unwrap().clone()).unwrap()
1381            })
1382            .collect::<Vec<_>>();
1383
1384        assert_eq!(diagnostics.len(), 3);
1385        assert!(diagnostics
1386            .iter()
1387            .any(|diag| diag.uri.as_str() == "file:///path/to/project/"));
1388        assert!(diagnostics
1389            .iter()
1390            .any(|diag| diag.uri.as_str() == "file:///path/local_relative.graphql"));
1391
1392        // local is a "known" subgraph which is then opened in this test, giving
1393        // it a `version`. Ensure this `version` isn't coming from the known
1394        // subgraphs map (with a default version of 0).
1395        let local = diagnostics
1396            .iter()
1397            .find(|diag| diag.uri.as_str() == "file:///path/to/project/local.graphql")
1398            .unwrap();
1399        assert_eq!(&local.version, &Some(1));
1400    }
1401
1402    #[tokio::test]
1403    async fn publishing_diagnostics_to_known_subgraphs_without_opening_files() {
1404        // Initialize the server with known subgraphs.
1405        // Note that `remote` has no URI - its composition diagnostics should be published to the root URI.
1406        // The other two subgraphs should have their diagnostics published to their respective URIs.
1407        let (mut service, socket, composition_listener) = ApolloLanguageServer::build_service(
1408            Config {
1409                root_uri: "/path/to/project".into(),
1410                ..Default::default()
1411            },
1412            [
1413                (
1414                    "local".into(),
1415                    SchemaSource::File {
1416                        file: "local.graphql".into(),
1417                    },
1418                ),
1419                (
1420                    "local_relative".into(),
1421                    SchemaSource::File {
1422                        file: "../../local_relative.graphql".into(),
1423                    },
1424                ),
1425                (
1426                    "remote".into(),
1427                    SchemaSource::Subgraph {
1428                        graphref: "testing@current".into(),
1429                        subgraph: "remote".into(),
1430                    },
1431                ),
1432            ]
1433            .into_iter()
1434            .collect(),
1435            None,
1436        );
1437
1438        let (socket_messages, _) = listen_to_channels(socket, composition_listener);
1439
1440        request(
1441            &mut service,
1442            initialize_request_with_server_configurations(1),
1443        )
1444        .await;
1445
1446        request(&mut service, initialized_notification()).await;
1447
1448        service
1449            .inner()
1450            .composition_did_update(
1451                None,
1452                vec![
1453                    Issue {
1454                        code: "TEST_REMOTE".into(),
1455                        message: "Test issue".into(),
1456                        locations: vec![SubgraphLocation {
1457                            subgraph: Some("remote".into()),
1458                            range: None,
1459                        }],
1460                        severity: Severity::Error,
1461                    },
1462                    Issue {
1463                        code: "TEST_LOCAL".into(),
1464                        message: "Test issue".into(),
1465                        locations: vec![SubgraphLocation {
1466                            subgraph: Some("local".into()),
1467                            range: None,
1468                        }],
1469                        severity: Severity::Error,
1470                    },
1471                    Issue {
1472                        code: "TEST_LOCAL_RELATIVE".into(),
1473                        message: "Test issue".into(),
1474                        locations: vec![SubgraphLocation {
1475                            subgraph: Some("local_relative".into()),
1476                            range: None,
1477                        }],
1478                        severity: Severity::Error,
1479                    },
1480                ],
1481                None,
1482            )
1483            .await;
1484
1485        let lock = socket_messages.lock().await;
1486        let diagnostics = lock
1487            .iter()
1488            .filter(|msg| msg.method() == "textDocument/publishDiagnostics")
1489            .map(|msg| {
1490                from_value::<PublishDiagnosticsParams>(msg.params().unwrap().clone()).unwrap()
1491            })
1492            .collect::<Vec<_>>();
1493
1494        assert_eq!(diagnostics.len(), 3);
1495        assert!(diagnostics
1496            .iter()
1497            .any(|diag| diag.uri.as_str() == "file:///path/to/project/"));
1498        assert!(diagnostics
1499            .iter()
1500            .any(|diag| diag.uri.as_str() == "file:///path/to/project/local.graphql"));
1501        assert!(diagnostics
1502            .iter()
1503            .any(|diag| diag.uri.as_str() == "file:///path/local_relative.graphql"));
1504    }
1505
1506    #[tokio::test]
1507    async fn host_can_publish_diagnostics() {
1508        let (mut service, socket, composition_listener) = ApolloLanguageServer::build_service(
1509            Config {
1510                root_uri: "/path/to/project".into(),
1511                ..Default::default()
1512            },
1513            HashMap::new(),
1514            None,
1515        );
1516
1517        let (socket_messages, _) = listen_to_channels(socket, composition_listener);
1518
1519        request(
1520            &mut service,
1521            initialize_request_with_server_configurations(1),
1522        )
1523        .await;
1524
1525        request(&mut service, initialized_notification()).await;
1526
1527        let diagnostics = vec![lsp::Diagnostic::new_simple(
1528            lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
1529            "supergraph.yaml missing fields".into(),
1530        )];
1531
1532        service
1533            .inner()
1534            .publish_diagnostics(
1535                lsp::Url::from_file_path("/path/to/project/supergraph.yaml").unwrap(),
1536                diagnostics,
1537            )
1538            .await;
1539
1540        yield_now().await;
1541
1542        let lock = socket_messages.lock().await;
1543        let diagnostics_published_to_client = lock
1544            .iter()
1545            .filter(|msg| msg.method() == "textDocument/publishDiagnostics")
1546            .map(|msg| {
1547                from_value::<PublishDiagnosticsParams>(msg.params().unwrap().clone()).unwrap()
1548            })
1549            .collect::<Vec<_>>();
1550
1551        assert_eq!(diagnostics_published_to_client.len(), 1);
1552        assert_eq!(
1553            diagnostics_published_to_client[0].uri.as_str(),
1554            "file:///path/to/project/supergraph.yaml"
1555        );
1556    }
1557
1558    #[tokio::test]
1559    async fn stale_diagnostics_removed_on_remote_subgraph_removal() {
1560        let (mut service, socket, composition_listener) = ApolloLanguageServer::build_service(
1561            Config {
1562                root_uri: "/path/to/project".into(),
1563                ..Default::default()
1564            },
1565            [(
1566                "remote".into(),
1567                SchemaSource::Subgraph {
1568                    graphref: "testing@current".into(),
1569                    subgraph: "remote".into(),
1570                },
1571            )]
1572            .into_iter()
1573            .collect(),
1574            None,
1575        );
1576
1577        let (socket_messages, _) = listen_to_channels(socket, composition_listener);
1578
1579        request(
1580            &mut service,
1581            initialize_request_with_server_configurations(1),
1582        )
1583        .await;
1584
1585        request(&mut service, initialized_notification()).await;
1586
1587        service
1588            .inner()
1589            .composition_did_update(
1590                None,
1591                vec![Issue {
1592                    code: "TEST_REMOTE".into(),
1593                    message: "Test issue".into(),
1594                    locations: vec![SubgraphLocation {
1595                        subgraph: Some("remote".into()),
1596                        range: None,
1597                    }],
1598                    severity: Severity::Error,
1599                }],
1600                None,
1601            )
1602            .await;
1603        {
1604            let messages = &mut *socket_messages.lock().await;
1605            let diagnostics = messages
1606                .iter()
1607                .filter(|msg| msg.method() == "textDocument/publishDiagnostics")
1608                .map(|msg| {
1609                    from_value::<PublishDiagnosticsParams>(msg.params().unwrap().clone()).unwrap()
1610                })
1611                .collect::<Vec<_>>();
1612
1613            assert_eq!(diagnostics.len(), 1);
1614            assert!(diagnostics
1615                .iter()
1616                .any(|diag| diag.uri.as_str() == "file:///path/to/project/"
1617                    && diag.diagnostics.len() == 1));
1618
1619            messages.clear();
1620        }
1621
1622        service.inner().remove_subgraph("remote").await;
1623
1624        {
1625            let messages = socket_messages.lock().await;
1626            let diagnostics = messages
1627                .iter()
1628                .filter(|msg| msg.method() == "textDocument/publishDiagnostics")
1629                .map(|msg| {
1630                    from_value::<PublishDiagnosticsParams>(msg.params().unwrap().clone()).unwrap()
1631                })
1632                .collect::<Vec<_>>();
1633
1634            // A subgraph was removed that we don't have a URI for, so its
1635            // diagnostics are at the root. We don't want to clear all root
1636            // diagnostics on every subgraph removal. This behavior depends on
1637            // composition_did_update being called to update any root level
1638            // diagnostics.
1639            assert_eq!(diagnostics.len(), 0);
1640        }
1641    }
1642
1643    #[tokio::test]
1644    async fn stale_diagnostics_removed_on_local_removal() {
1645        let (mut service, socket, composition_listener) = ApolloLanguageServer::build_service(
1646            Config {
1647                root_uri: "/path/to/project".into(),
1648                ..Default::default()
1649            },
1650            [(
1651                "local".into(),
1652                SchemaSource::File {
1653                    file: "local.graphql".into(),
1654                },
1655            )]
1656            .into_iter()
1657            .collect(),
1658            None,
1659        );
1660
1661        let (socket_messages, _) = listen_to_channels(socket, composition_listener);
1662
1663        request(
1664            &mut service,
1665            initialize_request_with_server_configurations(1),
1666        )
1667        .await;
1668
1669        request(&mut service, initialized_notification()).await;
1670
1671        service
1672            .inner()
1673            .composition_did_update(
1674                None,
1675                vec![Issue {
1676                    code: "TEST_LOCAL".into(),
1677                    message: "Test issue".into(),
1678                    locations: vec![SubgraphLocation {
1679                        subgraph: Some("local".into()),
1680                        range: None,
1681                    }],
1682                    severity: Severity::Error,
1683                }],
1684                None,
1685            )
1686            .await;
1687        {
1688            let messages = &mut *socket_messages.lock().await;
1689            let diagnostics = messages
1690                .iter()
1691                .filter(|msg| msg.method() == "textDocument/publishDiagnostics")
1692                .map(|msg| {
1693                    from_value::<PublishDiagnosticsParams>(msg.params().unwrap().clone()).unwrap()
1694                })
1695                .collect::<Vec<_>>();
1696
1697            assert_eq!(diagnostics.len(), 2);
1698            assert!(diagnostics.iter().any(|diag| diag.uri.as_str()
1699                == "file:///path/to/project/local.graphql"
1700                && diag.diagnostics.len() == 1));
1701
1702            messages.clear();
1703        }
1704
1705        service.inner().remove_subgraph("local").await;
1706
1707        {
1708            let messages = socket_messages.lock().await;
1709            let diagnostics = messages
1710                .iter()
1711                .filter(|msg| msg.method() == "textDocument/publishDiagnostics")
1712                .map(|msg| {
1713                    from_value::<PublishDiagnosticsParams>(msg.params().unwrap().clone()).unwrap()
1714                })
1715                .collect::<Vec<_>>();
1716
1717            // Here we're expecting a message to clear the diagnostics for the actual file URI.
1718            assert_eq!(diagnostics.len(), 1);
1719            assert!(diagnostics.iter().any(|diag| diag.uri.as_str()
1720                == "file:///path/to/project/local.graphql"
1721                && diag.diagnostics.is_empty()));
1722        }
1723    }
1724}