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