Skip to main content

rumdl_lib/lsp/
mod.rs

1//! Language Server Protocol implementation for rumdl
2//!
3//! This module provides a Language Server Protocol (LSP) implementation for rumdl,
4//! enabling real-time markdown linting in editors and IDEs.
5//!
6//! Following Ruff's approach, this is built directly into the main rumdl binary
7//! and can be started with `rumdl server`.
8
9mod completion;
10mod configuration;
11pub mod index_worker;
12mod linting;
13mod navigation;
14mod position;
15pub mod server;
16mod symbols;
17pub mod types;
18
19pub use server::RumdlLanguageServer;
20pub use types::{RumdlLspConfig, warning_to_code_actions, warning_to_diagnostic};
21
22use anyhow::Result;
23use std::path::{Path, PathBuf};
24use tokio::net::TcpListener;
25use tower_lsp::{LspService, Server};
26
27/// Resolve a workspace root to the path space the server identifies files in.
28///
29/// Every index key descends from a resolved root, so a document must be
30/// resolved the same way for a lookup to find its entry. Sharing one space also
31/// makes a root-relative comparison (`starts_with`, exclude relativization)
32/// answer for the paths documents actually arrive as.
33pub(crate) fn resolve_workspace_root(path: &Path) -> PathBuf {
34    crate::discovery::canonicalize_for_matching(path).unwrap_or_else(|| path.to_path_buf())
35}
36
37/// Resolve a document path to the same space as [`resolve_workspace_root`].
38///
39/// A URI arrives in whatever form the editor sent. It can reach the file
40/// through a symlinked ancestor, and on Windows it never carries the `\\?\`
41/// prefix that canonicalization produces, so the raw path routinely names the
42/// same file as a key without being equal to it.
43///
44/// Only the directory is resolved. The workspace scan does not follow symlinks,
45/// so it records a symlinked file under the name it was reached by rather than
46/// under its target, and resolving the file itself would look up a path the
47/// scan never produced. Resolving the directory also keeps working for a file
48/// that is not on disk, such as one deleted since it was indexed.
49pub(crate) fn resolve_document_path(path: &Path) -> PathBuf {
50    let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
51        return resolve_workspace_root(path);
52    };
53    match crate::discovery::canonicalize_for_matching(parent) {
54        Some(dir) => dir.join(name),
55        None => path.to_path_buf(),
56    }
57}
58
59/// Resolve a document URI to the path the server identifies it by.
60///
61/// `None` for a URI that names no file, such as an editor's untitled buffer.
62pub(crate) fn resolve_uri(uri: &tower_lsp::lsp_types::Url) -> Option<PathBuf> {
63    uri.to_file_path().ok().map(|path| resolve_document_path(&path))
64}
65
66/// Spell a URI the way the server identifies the document it names.
67///
68/// Navigation resolves a link target to a path and turns it back into a URI to
69/// ask for that document's content, so a document whose own URI spells its path
70/// differently is reachable under two URIs. This is the one the server treats as
71/// the document's identity; a URI naming no file is its own.
72pub(crate) fn resolve_uri_spelling(uri: &tower_lsp::lsp_types::Url) -> tower_lsp::lsp_types::Url {
73    resolve_uri(uri)
74        .and_then(|path| tower_lsp::lsp_types::Url::from_file_path(path).ok())
75        .unwrap_or_else(|| uri.clone())
76}
77
78/// Start the Language Server Protocol server
79/// This is the main entry point for `rumdl server`
80pub async fn start_server(config_path: Option<&str>) -> Result<()> {
81    let stdin = tokio::io::stdin();
82    let stdout = tokio::io::stdout();
83
84    let (service, socket) = LspService::new(|client| RumdlLanguageServer::new(client, config_path));
85
86    log::info!("Starting rumdl Language Server Protocol server");
87
88    Server::new(stdin, stdout, socket).serve(service).await;
89
90    Ok(())
91}
92
93/// Start the LSP server over TCP (useful for debugging)
94pub async fn start_tcp_server(port: u16, config_path: Option<&str>) -> Result<()> {
95    let listener = TcpListener::bind(format!("127.0.0.1:{port}")).await?;
96    log::info!("rumdl LSP server listening on 127.0.0.1:{port}");
97
98    // Clone config_path to owned String so we can move it into the spawned task
99    let config_path_owned = config_path.map(std::string::ToString::to_string);
100
101    loop {
102        let (stream, _) = listener.accept().await?;
103        let config_path_clone = config_path_owned.clone();
104        let (service, socket) =
105            LspService::new(move |client| RumdlLanguageServer::new(client, config_path_clone.as_deref()));
106
107        tokio::spawn(async move {
108            let (read, write) = tokio::io::split(stream);
109            Server::new(read, write, socket).serve(service).await;
110        });
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn test_module_exports() {
120        // Verify that the module exports are accessible
121        // This ensures the public API is stable
122        fn _check_exports() {
123            // These should compile without errors
124            let _server_type: RumdlLanguageServer;
125            let _config_type: RumdlLspConfig;
126            let _func1: fn(&crate::rule::LintWarning, &str) -> tower_lsp::lsp_types::Diagnostic = warning_to_diagnostic;
127            let _func2: fn(
128                &crate::rule::LintWarning,
129                &tower_lsp::lsp_types::Url,
130                &str,
131            ) -> Vec<tower_lsp::lsp_types::CodeAction> = warning_to_code_actions;
132        }
133    }
134
135    #[tokio::test]
136    async fn test_tcp_server_bind() {
137        use std::net::TcpListener as StdTcpListener;
138
139        // Find an available port
140        let listener = StdTcpListener::bind("127.0.0.1:0").unwrap();
141        let port = listener.local_addr().unwrap().port();
142        drop(listener);
143
144        // Start the server in a background task
145        let server_handle = tokio::spawn(async move {
146            // Server should start without panicking
147            match tokio::time::timeout(std::time::Duration::from_millis(100), start_tcp_server(port, None)).await {
148                Ok(Ok(())) => {} // Server started and stopped normally
149                Ok(Err(_)) => {} // Server had an error (expected in test)
150                Err(_) => {}     // Timeout (expected - server runs forever)
151            }
152        });
153
154        // Give the server time to start
155        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
156
157        // Try to connect to verify it's listening
158        match tokio::time::timeout(
159            std::time::Duration::from_millis(50),
160            tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")),
161        )
162        .await
163        {
164            Ok(Ok(_)) => {
165                // Successfully connected
166            }
167            _ => {
168                // Connection failed or timed out - that's okay for this test
169            }
170        }
171
172        // Cancel the server task
173        server_handle.abort();
174    }
175
176    #[tokio::test]
177    async fn test_tcp_server_invalid_port() {
178        // Port 0 is technically valid (OS assigns), but let's test a privileged port
179        // that we likely can't bind to without root
180        let result = tokio::time::timeout(std::time::Duration::from_millis(100), start_tcp_server(80, None)).await;
181
182        match result {
183            Ok(Err(_)) => {
184                // Expected - should fail to bind to privileged port
185            }
186            Ok(Ok(())) => {
187                panic!("Should not be able to bind to port 80 without privileges");
188            }
189            Err(_) => {
190                // Timeout - server tried to run, which means bind succeeded
191                // This might happen if tests are run as root
192            }
193        }
194    }
195
196    #[tokio::test]
197    async fn test_service_creation() {
198        // Test that we can create the LSP service
199        let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
200
201        // Service should be created successfully
202        // We can't easily test more without a full LSP client
203        drop(service);
204    }
205
206    #[tokio::test]
207    async fn test_multiple_tcp_connections() {
208        use std::net::TcpListener as StdTcpListener;
209
210        // Find an available port
211        let listener = StdTcpListener::bind("127.0.0.1:0").unwrap();
212        let port = listener.local_addr().unwrap().port();
213        drop(listener);
214
215        // Start the server
216        let server_handle = tokio::spawn(async move {
217            let _ = tokio::time::timeout(std::time::Duration::from_millis(500), start_tcp_server(port, None)).await;
218        });
219
220        // Give server time to start
221        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
222
223        // Try multiple connections
224        let mut handles = vec![];
225        for _ in 0..3 {
226            let handle = tokio::spawn(async move {
227                match tokio::time::timeout(
228                    std::time::Duration::from_millis(100),
229                    tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")),
230                )
231                .await
232                {
233                    Ok(Ok(_stream)) => {
234                        // Connection successful
235                        true
236                    }
237                    _ => false,
238                }
239            });
240            handles.push(handle);
241        }
242
243        // Wait for all connections
244        for handle in handles {
245            let _ = handle.await;
246        }
247
248        // Clean up
249        server_handle.abort();
250    }
251
252    #[test]
253    fn test_logging_initialization() {
254        // Verify that starting the server includes proper logging
255        // This is more of a smoke test to ensure logging statements compile
256
257        // The actual log::info! calls are in the async functions,
258        // but we can at least verify the module imports and uses logging
259        let _info_level = log::Level::Info;
260    }
261}