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