Skip to main content

matrixcode_core/lsp/
client.rs

1//! LSP Client
2//!
3//! High-level LSP client that manages a single LSP server connection
4//! and provides APIs for hover, definition, references, and diagnostics.
5
6use anyhow::{Result, anyhow};
7use lsp_types::{
8    ClientCapabilities, Diagnostic, DiagnosticSeverity, GotoDefinitionParams,
9    Hover, HoverContents, HoverParams, InitializeParams,
10    InitializedParams, Location, MarkupKind, Position, ReferenceContext,
11    ReferenceParams, TextDocumentClientCapabilities, TextDocumentIdentifier,
12    TextDocumentItem, TextDocumentPositionParams, Url, WorkspaceClientCapabilities,
13    WorkspaceFolder,
14};
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18use tokio::sync::Mutex;
19use tokio::time::{timeout, Duration};
20
21use super::constants::{PROCESS_STARTUP_TIMEOUT, SERVER_INIT_TIMEOUT};
22use super::progress::LspProgressCallback;
23use super::transport::LspTransport;
24use super::types::LspServerConfig;
25
26/// Result from hover request
27#[derive(Debug, Clone)]
28pub struct HoverResult {
29    /// Type signature or primary hover content
30    pub signature: String,
31    /// Documentation or additional info
32    pub documentation: Option<String>,
33}
34
35impl HoverResult {
36    /// Create a new hover result
37    pub fn new(signature: impl Into<String>) -> Self {
38        Self {
39            signature: signature.into(),
40            documentation: None,
41        }
42    }
43
44    /// Add documentation
45    pub fn with_documentation(mut self, doc: impl Into<String>) -> Self {
46        self.documentation = Some(doc.into());
47        self
48    }
49}
50
51/// LSP Client
52///
53/// Manages a single LSP server connection and provides high-level API
54/// for common LSP operations like hover, definition, references, and diagnostics.
55pub struct LspClient {
56    /// Transport layer for communication
57    transport: Arc<Mutex<Option<LspTransport>>>,
58    /// Language identifier (e.g., "rust", "typescript")
59    language: String,
60    /// Server name (e.g., "rust-analyzer")
61    server_name: String,
62    /// Project root path
63    project_root: PathBuf,
64    /// Cache of open files (URI -> content)
65    open_files: Arc<Mutex<HashMap<Url, String>>>,
66    /// Cache of diagnostics (URI -> diagnostics)
67    diagnostics_cache: Arc<Mutex<HashMap<Url, Vec<Diagnostic>>>>,
68    /// Server capabilities (set after initialization)
69    capabilities: Arc<Mutex<Option<lsp_types::ServerCapabilities>>>,
70}
71
72impl LspClient {
73    /// Create a new LSP client
74    pub fn new(language: impl Into<String>, server_name: impl Into<String>, project_root: PathBuf) -> Self {
75        Self {
76            transport: Arc::new(Mutex::new(None)),
77            language: language.into(),
78            server_name: server_name.into(),
79            project_root,
80            open_files: Arc::new(Mutex::new(HashMap::new())),
81            diagnostics_cache: Arc::new(Mutex::new(HashMap::new())),
82            capabilities: Arc::new(Mutex::new(None)),
83        }
84    }
85
86    /// Create LSP client from config
87    pub fn from_config(config: &LspServerConfig, project_root: PathBuf) -> Self {
88        Self::new(config.language.clone(), config.command.clone(), project_root)
89    }
90
91    /// Spawn and initialize the LSP server
92    pub async fn spawn(&self, config: &LspServerConfig) -> Result<()> {
93        log::info!("LSP spawn: starting '{}'...", self.server_name);
94        crate::debug::debug_log().log("lsp", &format!("spawn: starting '{}'...", self.server_name));
95
96        // Start the server process
97        let transport = LspTransport::spawn(&config.command, &config.command, &config.args).await?;
98        log::info!("LSP spawn: '{}' process started", self.server_name);
99        crate::debug::debug_log().log("lsp", &format!("spawn: '{}' process started", self.server_name));
100
101        {
102            let mut transport_guard = self.transport.lock().await;
103            *transport_guard = Some(transport);
104        }
105
106        // Initialize the server
107        log::info!("LSP spawn: initializing '{}'...", self.server_name);
108        crate::debug::debug_log().log("lsp", &format!("spawn: initializing '{}'...", self.server_name));
109        self.initialize().await?;
110        log::info!("LSP spawn: '{}' initialized successfully", self.server_name);
111        crate::debug::debug_log().log("lsp", &format!("spawn: '{}' initialized OK", self.server_name));
112
113        log::info!("LSP client '{}' spawned and initialized successfully", self.server_name);
114        Ok(())
115    }
116
117    /// Spawn LSP server with async initialization and progress callbacks
118    ///
119    /// This is the async version of `spawn()` with:
120    /// - Separate timeouts for process startup (5s) and server initialization (60s)
121    /// - Progress callbacks for UI updates
122    /// - Better error messages for each phase
123    ///
124    /// # Arguments
125    /// - `config`: LSP server configuration
126    /// - `progress_callback`: Callback for progress updates (can be `NoOpProgressCallback` if not needed)
127    ///
128    /// # Progress Flow
129    /// - 0.1: Starting process...
130    /// - 0.3: Process started, initializing server...
131    /// - 0.5-0.9: Loading workspace/indexes...
132    /// - 1.0: Ready
133    ///
134    /// # Errors
135    /// - Process startup timeout (5s): binary missing, permission denied
136    /// - Server initialization timeout (60s): large workspace, slow indexing
137    pub async fn spawn_async(
138        &self,
139        config: &LspServerConfig,
140        progress_callback: Arc<dyn LspProgressCallback>,
141    ) -> Result<()> {
142        log::info!("LSP spawn_async: starting '{}'...", self.server_name);
143
144        // Phase 1: Start process (5s timeout, should be fast)
145        progress_callback.on_progress(0.1, "Starting process...");
146        
147        let transport_result = timeout(PROCESS_STARTUP_TIMEOUT, async {
148            LspTransport::spawn(&config.command, &config.command, &config.args).await
149        })
150        .await;
151
152        let transport = transport_result.map_err(|_| {
153            let error_msg = format!(
154                "LSP process startup timeout ({}s).\n\
155                Possible causes:\n\
156                - Binary '{}' not found in PATH\n\
157                - Permission denied\n\
158                - Process hangs immediately",
159                PROCESS_STARTUP_TIMEOUT.as_secs(),
160                config.command
161            );
162            progress_callback.on_error(&error_msg);
163            anyhow!(error_msg)
164        })?;
165
166        let transport = transport.map_err(|e| {
167            let error_msg = format!("Failed to start LSP process '{}': {}", config.command, e);
168            progress_callback.on_error(&error_msg);
169            anyhow!(error_msg)
170        })?;
171
172        log::info!("LSP spawn_async: '{}' process started", self.server_name);
173        progress_callback.on_progress(0.3, "Process started, initializing server...");
174
175        // Store transport
176        {
177            let mut transport_guard = self.transport.lock().await;
178            *transport_guard = Some(transport);
179        }
180
181        // Phase 2: Initialize server (timeout, but continue in background if needed)
182        progress_callback.on_progress(0.5, "Loading workspace...");
183
184        let init_result = timeout(SERVER_INIT_TIMEOUT, async {
185            self.initialize().await
186        })
187        .await;
188
189        match init_result {
190            Ok(result) => {
191                result?; // initialize() 成功
192                log::info!("LSP spawn_async: '{}' initialized successfully", self.server_name);
193                progress_callback.on_progress(1.0, "Ready");
194                progress_callback.on_complete();
195                Ok(())
196            }
197            Err(_) => {
198                // Timeout occurred, but process is still running
199                // Start background task to continue waiting for initialization
200                log::info!(
201                    "LSP '{}' initialization timeout after {}s, continuing in background...",
202                    self.server_name,
203                    SERVER_INIT_TIMEOUT.as_secs()
204                );
205
206                // Don't mark as error - process is still running
207                // Return Ok to indicate process started, but initialization pending
208                progress_callback.on_progress(0.7, "Background init...");
209
210                // Spawn background task to continue waiting
211                let server_name = self.server_name.clone();
212                let transport = self.transport.clone();
213                let callback = progress_callback.clone();
214
215                tokio::spawn(async move {
216                    // Wait for background initialization (30 seconds grace period)
217                    tokio::time::sleep(Duration::from_secs(30)).await;
218
219                    // Check if transport is still available (process running)
220                    let transport_guard = transport.lock().await;
221                    if transport_guard.is_some() {
222                        log::info!("LSP '{}' background initialization completed", server_name);
223                        callback.on_progress(1.0, "Ready");
224                        callback.on_complete();
225                    } else {
226                        log::warn!("LSP '{}' process died during background init", server_name);
227                        callback.on_error("Process died");
228                    }
229                });
230
231                // Return Ok to indicate process started successfully
232                // Background task will update status when ready
233                Ok(())
234            }
235        }
236    }
237
238    /// Send LSP initialize request
239    #[allow(deprecated)] // root_path and root_uri are deprecated but needed for LSP server compatibility
240    pub async fn initialize(&self) -> Result<()> {
241        let transport = self.transport.lock().await;
242        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
243
244        let root_uri = Url::from_file_path(&self.project_root)
245            .map_err(|_| anyhow!("Invalid project root path: {:?}", self.project_root))?;
246
247        let params = InitializeParams {
248            process_id: Some(std::process::id()),
249            root_path: None,
250            root_uri: Some(root_uri.clone()),
251            initialization_options: None,
252            capabilities: ClientCapabilities {
253                text_document: Some(TextDocumentClientCapabilities {
254                    hover: Some(lsp_types::HoverClientCapabilities {
255                        dynamic_registration: Some(false),
256                        content_format: Some(vec![MarkupKind::Markdown, MarkupKind::PlainText]),
257                    }),
258                    definition: Some(lsp_types::GotoCapability {
259                        dynamic_registration: Some(false),
260                        link_support: Some(true),
261                    }),
262                    references: Some(lsp_types::ReferenceClientCapabilities {
263                        dynamic_registration: Some(false),
264                    }),
265                    publish_diagnostics: Some(lsp_types::PublishDiagnosticsClientCapabilities {
266                        related_information: Some(true),
267                        tag_support: Some(lsp_types::TagSupport {
268                            value_set: vec![lsp_types::DiagnosticTag::UNNECESSARY, lsp_types::DiagnosticTag::DEPRECATED],
269                        }),
270                        version_support: Some(false),
271                        code_description_support: Some(true),
272                        data_support: Some(false),
273                    }),
274                    ..Default::default()
275                }),
276                workspace: Some(WorkspaceClientCapabilities {
277                    workspace_folders: Some(true),
278                    ..Default::default()
279                }),
280                ..Default::default()
281            },
282            trace: Some(lsp_types::TraceValue::Off),
283            workspace_folders: Some(vec![WorkspaceFolder {
284                uri: root_uri,
285                name: self.project_root
286                    .file_name()
287                    .map(|n| n.to_string_lossy().to_string())
288                    .unwrap_or_else(|| "workspace".to_string()),
289            }]),
290            client_info: Some(lsp_types::ClientInfo {
291                name: "matrixcode".to_string(),
292                version: Some(env!("CARGO_PKG_VERSION").to_string()),
293            }),
294            locale: None,
295            work_done_progress_params: Default::default(),
296        };
297
298        let result = transport
299            .send_request("initialize", serde_json::to_value(params)?)
300            .await?;
301
302        // Store capabilities
303        if let Some(capabilities) = result.get("capabilities") {
304            let caps: lsp_types::ServerCapabilities = serde_json::from_value(capabilities.clone())?;
305            let mut caps_guard = self.capabilities.lock().await;
306            *caps_guard = Some(caps);
307        }
308
309        // Send initialized notification
310        self.initialized().await?;
311
312        Ok(())
313    }
314
315    /// Send initialized notification
316    pub async fn initialized(&self) -> Result<()> {
317        let transport = self.transport.lock().await;
318        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
319
320        let params = InitializedParams {};
321        transport
322            .send_notification("initialized", serde_json::to_value(params)?)
323            .await?;
324
325        Ok(())
326    }
327
328    /// Open a file in the LSP server
329    pub async fn open_file(&self, uri: &Url, content: &str) -> Result<()> {
330        let transport = self.transport.lock().await;
331        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
332
333        let language_id = self.language.clone();
334        let version = 1;
335
336        let text_document = TextDocumentItem {
337            uri: uri.clone(),
338            language_id,
339            version,
340            text: content.to_string(),
341        };
342
343        let params = lsp_types::DidOpenTextDocumentParams { text_document };
344        transport
345            .send_notification("textDocument/didOpen", serde_json::to_value(params)?)
346            .await?;
347
348        // Cache the open file
349        let mut open_files = self.open_files.lock().await;
350        open_files.insert(uri.clone(), content.to_string());
351
352        log::debug!("Opened file in LSP: {}", uri);
353        Ok(())
354    }
355
356    /// Get hover information at a position
357    pub async fn hover(&self, uri: &Url, position: Position) -> Result<Option<HoverResult>> {
358        let transport = self.transport.lock().await;
359        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
360
361        let text_document = TextDocumentIdentifier { uri: uri.clone() };
362        let params = HoverParams {
363            text_document_position_params: TextDocumentPositionParams {
364                text_document,
365                position,
366            },
367            work_done_progress_params: Default::default(),
368        };
369
370        let result = transport
371            .send_request("textDocument/hover", serde_json::to_value(params)?)
372            .await?;
373
374        if result.is_null() {
375            return Ok(None);
376        }
377
378        let hover: Hover = serde_json::from_value(result)?;
379        Ok(Some(Self::parse_hover(hover)))
380    }
381
382    /// Parse hover response into HoverResult
383    fn parse_hover(hover: Hover) -> HoverResult {
384        let (signature, documentation) = match hover.contents {
385            HoverContents::Scalar(scalar) => {
386                let content = match scalar {
387                    lsp_types::MarkedString::String(s) => s,
388                    lsp_types::MarkedString::LanguageString(ls) => {
389                        format!("```{}\n{}\n```", ls.language, ls.value)
390                    }
391                };
392                (content, None)
393            }
394            HoverContents::Array(arr) => {
395                let parts: Vec<String> = arr
396                    .into_iter()
397                    .map(|ms| match ms {
398                        lsp_types::MarkedString::String(s) => s,
399                        lsp_types::MarkedString::LanguageString(ls) => {
400                            format!("```{}\n{}\n```", ls.language, ls.value)
401                        }
402                    })
403                    .collect();
404                let signature = parts.first().cloned().unwrap_or_default();
405                let documentation = if parts.len() > 1 {
406                    Some(parts[1..].join("\n\n"))
407                } else {
408                    None
409                };
410                (signature, documentation)
411            }
412            HoverContents::Markup(markup) => {
413                let content = markup.value;
414                // Try to split signature and documentation
415                if content.contains("\n\n") {
416                    let parts: Vec<&str> = content.splitn(2, "\n\n").collect();
417                    (parts[0].to_string(), Some(parts[1].to_string()))
418                } else {
419                    (content, None)
420                }
421            }
422        };
423
424        HoverResult { signature, documentation }
425    }
426
427    /// Get definition locations at a position
428    pub async fn definition(&self, uri: &Url, position: Position) -> Result<Vec<Location>> {
429        let transport = self.transport.lock().await;
430        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
431
432        let text_document = TextDocumentIdentifier { uri: uri.clone() };
433        let params = GotoDefinitionParams {
434            text_document_position_params: TextDocumentPositionParams {
435                text_document,
436                position,
437            },
438            work_done_progress_params: Default::default(),
439            partial_result_params: Default::default(),
440        };
441
442        let result = transport
443            .send_request("textDocument/definition", serde_json::to_value(params)?)
444            .await?;
445
446        if result.is_null() {
447            return Ok(Vec::new());
448        }
449
450        let locations = Self::parse_definition_response(result)?;
451        Ok(locations)
452    }
453
454    /// Parse definition response
455    fn parse_definition_response(result: serde_json::Value) -> Result<Vec<Location>> {
456        // Try to parse as LocationLink array first
457        if let Ok(links) = serde_json::from_value::<Vec<lsp_types::LocationLink>>(result.clone()) {
458            return Ok(links
459                .into_iter()
460                .map(|link| Location {
461                    uri: link.target_uri,
462                    range: link.target_selection_range,
463                })
464                .collect());
465        }
466
467        // Try to parse as Location array
468        if let Ok(locations) = serde_json::from_value::<Vec<Location>>(result.clone()) {
469            return Ok(locations);
470        }
471
472        // Try to parse as single Location
473        if let Ok(location) = serde_json::from_value::<Location>(result.clone()) {
474            return Ok(vec![location]);
475        }
476
477        // Try to parse as single LocationLink
478        if let Ok(link) = serde_json::from_value::<lsp_types::LocationLink>(result) {
479            return Ok(vec![Location {
480                uri: link.target_uri,
481                range: link.target_selection_range,
482            }]);
483        }
484
485        Ok(Vec::new())
486    }
487
488    /// Get reference locations at a position
489    pub async fn references(&self, uri: &Url, position: Position, include_declaration: bool) -> Result<Vec<Location>> {
490        let transport = self.transport.lock().await;
491        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
492
493        let text_document = TextDocumentIdentifier { uri: uri.clone() };
494        let params = ReferenceParams {
495            text_document_position: TextDocumentPositionParams {
496                text_document,
497                position,
498            },
499            work_done_progress_params: Default::default(),
500            partial_result_params: Default::default(),
501            context: ReferenceContext {
502                include_declaration,
503            },
504        };
505
506        let result = transport
507            .send_request("textDocument/references", serde_json::to_value(params)?)
508            .await?;
509
510        if result.is_null() {
511            return Ok(Vec::new());
512        }
513
514        let locations: Vec<Location> = serde_json::from_value(result)?;
515        Ok(locations)
516    }
517
518    /// Get diagnostics for a file from cache
519    pub async fn diagnostics(&self, uri: &Url) -> Result<Vec<Diagnostic>> {
520        let cache = self.diagnostics_cache.lock().await;
521        Ok(cache.get(uri).cloned().unwrap_or_default())
522    }
523
524    /// Update diagnostics cache (called when receiving publishDiagnostics notification)
525    pub async fn update_diagnostics(&self, uri: Url, diagnostics: Vec<Diagnostic>) {
526        let mut cache = self.diagnostics_cache.lock().await;
527        cache.insert(uri, diagnostics);
528    }
529
530    /// Shutdown the LSP server gracefully
531    pub async fn shutdown(&self) -> Result<()> {
532        let mut transport_guard = self.transport.lock().await;
533
534        if let Some(transport) = transport_guard.take() {
535            // Send shutdown request
536            transport
537                .send_request("shutdown", serde_json::Value::Null)
538                .await?;
539
540            // Send exit notification
541            transport
542                .send_notification("exit", serde_json::Value::Null)
543                .await?;
544
545            // Close the transport
546            transport.close().await?;
547
548            log::info!("LSP client '{}' shutdown successfully", self.server_name);
549        }
550
551        // Clear caches
552        let mut open_files = self.open_files.lock().await;
553        open_files.clear();
554        let mut diagnostics_cache = self.diagnostics_cache.lock().await;
555        diagnostics_cache.clear();
556
557        Ok(())
558    }
559
560    /// Check if the client is connected
561    pub async fn is_connected(&self) -> bool {
562        let transport = self.transport.lock().await;
563        transport.is_some()
564    }
565
566    /// Get the language identifier
567    pub fn language(&self) -> &str {
568        &self.language
569    }
570
571    /// Get the server name
572    pub fn server_name(&self) -> &str {
573        &self.server_name
574    }
575
576    /// Get the project root
577    pub fn project_root(&self) -> &PathBuf {
578        &self.project_root
579    }
580
581    /// Get server capabilities
582    pub async fn capabilities(&self) -> Option<lsp_types::ServerCapabilities> {
583        let caps = self.capabilities.lock().await;
584        caps.clone()
585    }
586}
587
588// ============================================================================
589// Helper Functions
590// ============================================================================
591
592/// Format a location for human-readable output
593pub fn format_location(location: &Location) -> String {
594    let path = location.uri.to_file_path();
595    let path_str = path
596        .map(|p| p.to_string_lossy().to_string())
597        .unwrap_or_else(|_| location.uri.to_string());
598
599    let range = &location.range;
600    let start = &range.start;
601    format!(
602        "{}:{}:{}",
603        path_str,
604        start.line + 1,  // LSP uses 0-based line numbers
605        start.character + 1
606    )
607}
608
609/// Format a diagnostic for human-readable output
610pub fn format_diagnostic(diagnostic: &Diagnostic) -> String {
611    let severity = diagnostic.severity
612        .map(|s| format_severity(s))
613        .unwrap_or_else(|| "error".to_string());
614
615    let message = &diagnostic.message;
616
617    let location = diagnostic.related_information
618        .as_ref()
619        .and_then(|info| info.first())
620        .map(|info| format!(" at {}:{}", info.location.uri, info.location.range.start.line + 1))
621        .unwrap_or_default();
622
623    let code = diagnostic.code
624        .as_ref()
625        .map(|c| format!("[{}] ", match c {
626            lsp_types::NumberOrString::Number(n) => n.to_string(),
627            lsp_types::NumberOrString::String(s) => s.clone(),
628        }))
629        .unwrap_or_default();
630
631    format!("{}{}: {}{}", severity, code, message, location)
632}
633
634/// Format diagnostic severity
635fn format_severity(severity: DiagnosticSeverity) -> String {
636    match severity {
637        DiagnosticSeverity::ERROR => "error".to_string(),
638        DiagnosticSeverity::WARNING => "warning".to_string(),
639        DiagnosticSeverity::INFORMATION => "info".to_string(),
640        DiagnosticSeverity::HINT => "hint".to_string(),
641        _ => "unknown".to_string(),
642    }
643}
644
645/// Format hover result for display
646pub fn format_hover_result(hover: &HoverResult) -> String {
647    if let Some(doc) = &hover.documentation {
648        format!("{}\n\n{}", hover.signature, doc)
649    } else {
650        hover.signature.clone()
651    }
652}
653
654/// Create a file URL from a path
655pub fn path_to_uri(path: &PathBuf) -> Result<Url> {
656    // On Windows, convert Unix-style paths (e.g., /c/Users/...) to Windows format
657    #[cfg(target_os = "windows")]
658    {
659        let path_str = path.to_string_lossy();
660
661        // Check if it's a Unix-style Windows path (e.g., /c/Users/...)
662        if path_str.starts_with('/') && path_str.chars().nth(1).map(|c| c.is_ascii_lowercase()).unwrap_or(false) {
663            // Convert /c/... to C:/...
664            let drive_letter = path_str.chars().nth(1).unwrap();
665            let rest = &path_str[2..]; // Skip /c
666            let windows_path = format!("{}:{}", drive_letter.to_ascii_uppercase(), rest);
667            let windows_pathbuf = PathBuf::from(windows_path);
668
669            Url::from_file_path(&windows_pathbuf)
670                .map_err(|_| anyhow!("Invalid file path: {:?}", path))
671        } else {
672            Url::from_file_path(path)
673                .map_err(|_| anyhow!("Invalid file path: {:?}", path))
674        }
675    }
676
677    #[cfg(not(target_os = "windows"))]
678    {
679        Url::from_file_path(path)
680            .map_err(|_| anyhow!("Invalid file path: {:?}", path))
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn test_hover_result_new() {
690        let result = HoverResult::new("fn foo() -> i32");
691        assert_eq!(result.signature, "fn foo() -> i32");
692        assert!(result.documentation.is_none());
693    }
694
695    #[test]
696    fn test_hover_result_with_documentation() {
697        let result = HoverResult::new("fn foo() -> i32")
698            .with_documentation("This is a test function");
699        assert_eq!(result.signature, "fn foo() -> i32");
700        assert_eq!(result.documentation, Some("This is a test function".to_string()));
701    }
702
703    #[test]
704    fn test_format_hover_result() {
705        let hover = HoverResult::new("fn foo() -> i32")
706            .with_documentation("Docs");
707        let formatted = format_hover_result(&hover);
708        assert!(formatted.contains("fn foo() -> i32"));
709        assert!(formatted.contains("Docs"));
710    }
711
712    #[test]
713    fn test_format_severity() {
714        assert_eq!(format_severity(DiagnosticSeverity::ERROR), "error");
715        assert_eq!(format_severity(DiagnosticSeverity::WARNING), "warning");
716        assert_eq!(format_severity(DiagnosticSeverity::INFORMATION), "info");
717        assert_eq!(format_severity(DiagnosticSeverity::HINT), "hint");
718    }
719
720    #[test]
721    fn test_path_to_uri() {
722        let path = if cfg!(target_os = "windows") {
723            PathBuf::from("C:\\temp\\test.rs")
724        } else {
725            PathBuf::from("/tmp/test.rs")
726        };
727        let uri = path_to_uri(&path).unwrap();
728        assert!(uri.to_string().ends_with("test.rs"));
729    }
730}