Skip to main content

package_json_lsp/
server.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde_json::json;
5use tokio::sync::RwLock;
6use tower_lsp::jsonrpc::Result;
7use tower_lsp::lsp_types::*;
8use tower_lsp::{Client, LanguageServer, LspService, Server};
9
10use crate::color::get_catalog_color;
11use crate::document::{Document, position_in_range};
12use crate::parser::parse_package_dependencies;
13use crate::workspace::WorkspaceManager;
14
15pub async fn run_stdio_server() {
16    let stdin = tokio::io::stdin();
17    let stdout = tokio::io::stdout();
18
19    let documents = Arc::new(RwLock::new(HashMap::new()));
20    let documents_for_service = Arc::clone(&documents);
21    let (service, socket) = LspService::new(move |client| Backend {
22        client,
23        documents: documents_for_service,
24        workspace: WorkspaceManager::new(Arc::clone(&documents)),
25    });
26
27    Server::new(stdin, stdout, socket).serve(service).await;
28}
29
30pub struct Backend {
31    client: Client,
32    documents: Arc<RwLock<HashMap<Url, Document>>>,
33    workspace: WorkspaceManager,
34}
35
36#[tower_lsp::async_trait]
37impl LanguageServer for Backend {
38    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
39        if let Some(folders) = params.workspace_folders {
40            self.workspace
41                .set_workspace_folders(folders.into_iter().map(|folder| folder.uri).collect())
42                .await;
43        }
44
45        Ok(InitializeResult {
46            capabilities: ServerCapabilities {
47                text_document_sync: Some(TextDocumentSyncCapability::Kind(
48                    TextDocumentSyncKind::INCREMENTAL,
49                )),
50                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
51                inlay_hint_provider: Some(OneOf::Left(true)),
52                code_lens_provider: Some(CodeLensOptions {
53                    resolve_provider: Some(false),
54                }),
55                hover_provider: Some(HoverProviderCapability::Simple(true)),
56                definition_provider: Some(OneOf::Left(true)),
57                workspace: Some(WorkspaceServerCapabilities {
58                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
59                        supported: Some(true),
60                        change_notifications: Some(OneOf::Left(true)),
61                    }),
62                    file_operations: None,
63                }),
64                ..ServerCapabilities::default()
65            },
66            server_info: Some(ServerInfo {
67                name: "package-json-lsp".to_string(),
68                version: Some(env!("CARGO_PKG_VERSION").to_string()),
69            }),
70        })
71    }
72
73    async fn initialized(&self, _: InitializedParams) {
74        self.client
75            .log_message(MessageType::INFO, "package-json-lsp initialized")
76            .await;
77    }
78
79    async fn shutdown(&self) -> Result<()> {
80        Ok(())
81    }
82
83    async fn did_open(&self, params: DidOpenTextDocumentParams) {
84        let doc = Document::new(
85            params.text_document.uri.clone(),
86            params.text_document.version,
87            params.text_document.text,
88        );
89        self.documents
90            .write()
91            .await
92            .insert(params.text_document.uri.clone(), doc);
93        self.send_diagnostics(params.text_document.uri).await;
94    }
95
96    async fn did_change(&self, params: DidChangeTextDocumentParams) {
97        if let Some(doc) = self
98            .documents
99            .write()
100            .await
101            .get_mut(&params.text_document.uri)
102        {
103            doc.version = params.text_document.version;
104            doc.apply_changes(params.content_changes);
105        }
106        self.workspace
107            .clear_document_caches(&params.text_document.uri)
108            .await;
109    }
110
111    async fn did_save(&self, params: DidSaveTextDocumentParams) {
112        self.workspace
113            .clear_document_caches(&params.text_document.uri)
114            .await;
115        self.send_diagnostics(params.text_document.uri).await;
116    }
117
118    async fn did_close(&self, params: DidCloseTextDocumentParams) {
119        self.documents
120            .write()
121            .await
122            .remove(&params.text_document.uri);
123        self.client
124            .publish_diagnostics(params.text_document.uri, Vec::new(), None)
125            .await;
126    }
127
128    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
129        let Some(document) = self.package_document(&params.text_document.uri).await else {
130            return Ok(None);
131        };
132
133        let deps = parse_package_dependencies(&document);
134        let mut hints = Vec::new();
135        for dep in deps {
136            let Some(catalog) = dep.catalog else {
137                continue;
138            };
139            let Some(result) = self
140                .workspace
141                .resolve_catalog(&document.uri, &dep.package_name, &catalog)
142                .await
143            else {
144                continue;
145            };
146            let color_key = if catalog == "default" {
147                "default".to_string()
148            } else {
149                format!("{catalog}-lens")
150            };
151            hints.push(InlayHint {
152                position: dep.value_range.end,
153                label: InlayHintLabel::String(result.version),
154                kind: Some(InlayHintKind::TYPE),
155                text_edits: None,
156                tooltip: None,
157                padding_left: None,
158                padding_right: None,
159                data: Some(json!({
160                    "catalog": catalog,
161                    "color": get_catalog_color(&color_key),
162                })),
163            });
164        }
165
166        Ok(Some(hints))
167    }
168
169    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
170        let Some(document) = self
171            .package_document(&params.text_document_position_params.text_document.uri)
172            .await
173        else {
174            return Ok(None);
175        };
176        let position = params.text_document_position_params.position;
177        let deps = parse_package_dependencies(&document);
178
179        for dep in &deps {
180            let Some(catalog) = &dep.catalog else {
181                continue;
182            };
183            if position_in_range(position, dep.value_range)
184                && let Some(result) = self
185                    .workspace
186                    .resolve_catalog(&document.uri, &dep.package_name, catalog)
187                    .await
188            {
189                return Ok(Some(Hover {
190                    contents: HoverContents::Markup(MarkupContent {
191                        kind: MarkupKind::Markdown,
192                        value: format!(
193                            "- {} Catalog: `{}`\n- Version: `{}`",
194                            result.manager.as_str(),
195                            catalog,
196                            result.version
197                        ),
198                    }),
199                    range: Some(dep.value_range),
200                }));
201            }
202        }
203
204        for dep in deps {
205            if position_in_range(position, dep.property_range) {
206                let Some(outdated) = self
207                    .workspace
208                    .resolve_version(&document.uri, &dep.package_name)
209                    .await
210                else {
211                    return Ok(Some(Hover {
212                        contents: HoverContents::Markup(MarkupContent {
213                            kind: MarkupKind::Markdown,
214                            value: "latest version".to_string(),
215                        }),
216                        range: Some(dep.property_range),
217                    }));
218                };
219
220                return Ok(Some(Hover {
221                    contents: HoverContents::Markup(MarkupContent {
222                        kind: MarkupKind::Markdown,
223                        value: format!(
224                            "- Wanted: `{}`\n- Latest: `{}`",
225                            outdated.wanted, outdated.latest
226                        ),
227                    }),
228                    range: Some(dep.property_range),
229                }));
230            }
231        }
232
233        Ok(None)
234    }
235
236    async fn goto_definition(
237        &self,
238        params: GotoDefinitionParams,
239    ) -> Result<Option<GotoDefinitionResponse>> {
240        let Some(document) = self
241            .package_document(&params.text_document_position_params.text_document.uri)
242            .await
243        else {
244            return Ok(None);
245        };
246        let position = params.text_document_position_params.position;
247
248        for dep in parse_package_dependencies(&document) {
249            if !position_in_range(position, dep.value_range) {
250                continue;
251            }
252            if let Some(catalog) = dep.catalog
253                && let Some(result) = self
254                    .workspace
255                    .resolve_catalog(&document.uri, &dep.package_name, &catalog)
256                    .await
257                && let Some(definition) = result.definition
258            {
259                return Ok(Some(GotoDefinitionResponse::Scalar(definition)));
260            }
261            if dep.is_workspace_ref
262                && let Some(result) = self
263                    .workspace
264                    .resolve_workspace_package(&document.uri, &dep.package_name)
265                    .await
266            {
267                return Ok(Some(GotoDefinitionResponse::Scalar(result.definition)));
268            }
269        }
270
271        Ok(None)
272    }
273
274    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
275        let Some(document) = self.package_document(&params.text_document.uri).await else {
276            return Ok(None);
277        };
278
279        let mut lenses = Vec::new();
280        for dep in parse_package_dependencies(&document) {
281            let Some(outdated) = self
282                .workspace
283                .resolve_version(&document.uri, &dep.package_name)
284                .await
285            else {
286                continue;
287            };
288
289            let version = if let Some(catalog) = &dep.catalog {
290                self.workspace
291                    .resolve_catalog(&document.uri, &dep.package_name, catalog)
292                    .await
293                    .map(|result| result.version)
294                    .unwrap_or_else(|| dep.version_string.clone())
295            } else {
296                dep.version_string.clone()
297            };
298            let prefix = version_prefix(&version);
299            let title = if outdated.current == outdated.wanted {
300                format!("Latest: {prefix}{}", outdated.latest)
301            } else {
302                format!(
303                    "Wanted: {prefix}{} | Latest: {prefix}{}",
304                    outdated.wanted, outdated.latest
305                )
306            };
307
308            lenses.push(CodeLens {
309                range: dep.value_range,
310                command: Some(Command {
311                    title,
312                    command: "package-json-lsp:update".to_string(),
313                    arguments: None,
314                }),
315                data: None,
316            });
317        }
318
319        Ok(Some(lenses))
320    }
321
322    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
323        let Some(document) = self.package_document(&params.text_document.uri).await else {
324            return Ok(None);
325        };
326
327        for dep in parse_package_dependencies(&document) {
328            if !position_in_range(params.range.start, dep.value_range)
329                && !position_in_range(params.range.end, dep.value_range)
330            {
331                continue;
332            }
333            let Some(outdated) = self
334                .workspace
335                .resolve_version(&document.uri, &dep.package_name)
336                .await
337            else {
338                return Ok(None);
339            };
340
341            let (uri, range, version) = if let Some(catalog) = &dep.catalog {
342                let Some(catalog_info) = self
343                    .workspace
344                    .resolve_catalog(&document.uri, &dep.package_name, catalog)
345                    .await
346                else {
347                    return Ok(None);
348                };
349                let Some(definition) = catalog_info.definition else {
350                    return Ok(None);
351                };
352                (definition.uri, definition.range, catalog_info.version)
353            } else {
354                (
355                    document.uri.clone(),
356                    dep.value_range,
357                    dep.version_string.clone(),
358                )
359            };
360
361            let prefix = version_prefix(&version);
362            let new_text = format!("{prefix}{}", outdated.latest);
363            let edit = WorkspaceEdit {
364                changes: Some(HashMap::from([(
365                    uri,
366                    vec![TextEdit {
367                        range,
368                        new_text: new_text.clone(),
369                    }],
370                )])),
371                document_changes: None,
372                change_annotations: None,
373            };
374
375            return Ok(Some(vec![CodeActionOrCommand::CodeAction(CodeAction {
376                title: format!(
377                    "Update {} to latest version ({})",
378                    dep.package_name, new_text
379                ),
380                kind: None,
381                diagnostics: Some(
382                    params
383                        .context
384                        .diagnostics
385                        .into_iter()
386                        .filter(|diagnostic| {
387                            diagnostic.source.as_deref() == Some("pnpm")
388                                && diagnostic.code.as_ref().is_some_and(|code| match code {
389                                    NumberOrString::String(value) => value == "outdated",
390                                    NumberOrString::Number(_) => false,
391                                })
392                        })
393                        .collect(),
394                ),
395                edit: Some(edit),
396                command: None,
397                is_preferred: None,
398                disabled: None,
399                data: None,
400            })]));
401        }
402
403        Ok(None)
404    }
405}
406
407impl Backend {
408    async fn package_document(&self, uri: &Url) -> Option<Document> {
409        if !uri.path().ends_with("package.json") {
410            return None;
411        }
412        self.documents.read().await.get(uri).cloned()
413    }
414
415    async fn send_diagnostics(&self, uri: Url) {
416        let Some(document) = self.package_document(&uri).await else {
417            return;
418        };
419        let mut diagnostics = Vec::new();
420
421        for dep in parse_package_dependencies(&document) {
422            let Some(outdated) = self
423                .workspace
424                .resolve_version(&document.uri, &dep.package_name)
425                .await
426            else {
427                continue;
428            };
429
430            diagnostics.push(Diagnostic {
431                range: dep.value_range,
432                severity: Some(DiagnosticSeverity::INFORMATION),
433                code: Some(NumberOrString::String("outdated".to_string())),
434                code_description: None,
435                source: Some("pnpm".to_string()),
436                message: format!(
437                    "- Wanted: `{}`\n- Latest: `{}`",
438                    outdated.wanted, outdated.latest
439                ),
440                related_information: None,
441                tags: outdated
442                    .is_deprecated
443                    .then_some(vec![DiagnosticTag::DEPRECATED]),
444                data: None,
445            });
446        }
447
448        self.client
449            .publish_diagnostics(uri, diagnostics, Some(document.version))
450            .await;
451    }
452}
453
454fn version_prefix(version: &str) -> &str {
455    if version.starts_with(">=") {
456        ">="
457    } else if version.starts_with(['~', '^', '>']) {
458        &version[..1]
459    } else {
460        ""
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn preserves_version_prefixes() {
470        assert_eq!(version_prefix("^1.0.0"), "^");
471        assert_eq!(version_prefix("~1.0.0"), "~");
472        assert_eq!(version_prefix(">1.0.0"), ">");
473        assert_eq!(version_prefix(">=1.0.0"), ">=");
474        assert_eq!(version_prefix("1.0.0"), "");
475    }
476}