1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use anyhow::{Context, Result, anyhow};
6use cmake_tidy_ast::TextRange;
7use cmake_tidy_check::{CheckOptions, Diagnostic as CheckDiagnostic, RuleCode, check_source};
8use cmake_tidy_config::{Configuration, load_configuration};
9use cmake_tidy_format::format_source_with_options;
10use tokio::sync::RwLock;
11use tower_lsp::jsonrpc::{Error as JsonRpcError, ErrorCode, Result as JsonResult};
12use tower_lsp::lsp_types::{
13 Diagnostic as LspDiagnostic, DiagnosticSeverity, DidChangeTextDocumentParams,
14 DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentFormattingParams,
15 InitializeParams, InitializeResult, InitializedParams, MessageType, OneOf, Position, Range,
16 ServerCapabilities, ServerInfo, TextDocumentSyncCapability, TextDocumentSyncKind,
17 TextDocumentSyncOptions, TextEdit, Url, WorkspaceFolder,
18};
19use tower_lsp::{Client, LanguageServer, LspService, Server, async_trait};
20
21#[derive(Debug, Default)]
22struct ServerState {
23 workspace_root: Option<PathBuf>,
24 documents: HashMap<Url, String>,
25}
26
27#[derive(Debug)]
28struct Backend {
29 client: Client,
30 state: Arc<RwLock<ServerState>>,
31}
32
33impl Backend {
34 fn new(client: Client) -> Self {
35 Self {
36 client,
37 state: Arc::new(RwLock::new(ServerState::default())),
38 }
39 }
40
41 async fn set_workspace_root(&self, workspace_root: Option<PathBuf>) {
42 self.state.write().await.workspace_root = workspace_root;
43 }
44
45 async fn store_document(&self, uri: Url, text: String) {
46 self.state.write().await.documents.insert(uri, text);
47 }
48
49 async fn remove_document(&self, uri: &Url) {
50 self.state.write().await.documents.remove(uri);
51 }
52
53 async fn document_text(&self, uri: &Url) -> Result<String> {
54 let text = self.state.read().await.documents.get(uri).cloned();
55 if let Some(text) = text {
56 return Ok(text);
57 }
58
59 let path = file_path_from_uri(uri)?;
60 std::fs::read_to_string(&path)
61 .with_context(|| format!("failed to read document {}", path.display()))
62 }
63
64 async fn workspace_root(&self) -> Result<PathBuf> {
65 let workspace_root = self.state.read().await.workspace_root.clone();
66 if let Some(path) = workspace_root {
67 return Ok(path);
68 }
69
70 std::env::current_dir().context("failed to read current directory")
71 }
72
73 async fn publish_document_diagnostics(&self, uri: Url, text: &str) {
74 match self.analyze_document(&uri, text).await {
75 Ok(diagnostics) => {
76 self.client
77 .publish_diagnostics(uri, diagnostics, None)
78 .await;
79 }
80 Err(error) => {
81 self.client
82 .publish_diagnostics(uri.clone(), Vec::new(), None)
83 .await;
84 self.client
85 .log_message(
86 MessageType::ERROR,
87 format!("failed to analyze {uri}: {error:#}"),
88 )
89 .await;
90 }
91 }
92 }
93
94 async fn analyze_document(&self, uri: &Url, text: &str) -> Result<Vec<LspDiagnostic>> {
95 let workspace_root = self.workspace_root().await?;
96 let file_path = file_path_from_uri(uri)?;
97 let configuration = load_configuration(&workspace_root).with_context(|| {
98 format!(
99 "failed to load configuration from {}",
100 workspace_root.display()
101 )
102 })?;
103
104 if is_excluded(&file_path, &workspace_root, &configuration) {
105 return Ok(Vec::new());
106 }
107
108 let relative_path = relative_match_path(&file_path, &workspace_root);
109 let options = CheckOptions {
110 project_root: is_workspace_root_cmakelists(&file_path, &workspace_root),
111 function_name_case: configuration.lint.function_name_case,
112 };
113
114 let index = PositionIndex::new(text);
115 Ok(check_source(text, &options)
116 .diagnostics
117 .into_iter()
118 .filter(|diagnostic| {
119 configuration
120 .lint
121 .is_rule_enabled_for_path(&relative_path, &diagnostic.code.to_string())
122 })
123 .map(|diagnostic| to_lsp_diagnostic(&index, diagnostic))
124 .collect())
125 }
126
127 async fn format_document(&self, uri: &Url) -> Result<Option<Vec<TextEdit>>> {
128 let workspace_root = self.workspace_root().await?;
129 let file_path = file_path_from_uri(uri)?;
130 let configuration = load_configuration(&workspace_root).with_context(|| {
131 format!(
132 "failed to load configuration from {}",
133 workspace_root.display()
134 )
135 })?;
136
137 if is_excluded(&file_path, &workspace_root, &configuration) {
138 return Ok(None);
139 }
140
141 let source = self.document_text(uri).await?;
142 let result = format_source_with_options(&source, &configuration.format);
143 if !result.changed {
144 return Ok(None);
145 }
146
147 let index = PositionIndex::new(&source);
148 Ok(Some(vec![TextEdit {
149 range: Range::new(Position::new(0, 0), index.position(source.len())),
150 new_text: result.output,
151 }]))
152 }
153}
154
155#[async_trait]
156impl LanguageServer for Backend {
157 async fn initialize(&self, params: InitializeParams) -> JsonResult<InitializeResult> {
158 self.set_workspace_root(extract_workspace_root(¶ms))
159 .await;
160
161 Ok(InitializeResult {
162 capabilities: ServerCapabilities {
163 text_document_sync: Some(TextDocumentSyncCapability::Options(
164 TextDocumentSyncOptions {
165 open_close: Some(true),
166 change: Some(TextDocumentSyncKind::FULL),
167 ..TextDocumentSyncOptions::default()
168 },
169 )),
170 document_formatting_provider: Some(OneOf::Left(true)),
171 ..ServerCapabilities::default()
172 },
173 server_info: Some(ServerInfo {
174 name: "cmake-tidy".to_owned(),
175 version: Some(env!("CARGO_PKG_VERSION").to_owned()),
176 }),
177 })
178 }
179
180 async fn initialized(&self, _: InitializedParams) {
181 self.client
182 .log_message(MessageType::INFO, "cmake-tidy LSP server initialized")
183 .await;
184 }
185
186 async fn shutdown(&self) -> JsonResult<()> {
187 Ok(())
188 }
189
190 async fn did_open(&self, params: DidOpenTextDocumentParams) {
191 let uri = params.text_document.uri;
192 let text = params.text_document.text;
193 self.store_document(uri.clone(), text.clone()).await;
194 self.publish_document_diagnostics(uri, &text).await;
195 }
196
197 async fn did_change(&self, params: DidChangeTextDocumentParams) {
198 let Some(change) = params.content_changes.into_iter().last() else {
199 return;
200 };
201
202 let uri = params.text_document.uri;
203 let text = change.text;
204 self.store_document(uri.clone(), text.clone()).await;
205 self.publish_document_diagnostics(uri, &text).await;
206 }
207
208 async fn did_close(&self, params: DidCloseTextDocumentParams) {
209 let uri = params.text_document.uri;
210 self.remove_document(&uri).await;
211 self.client.publish_diagnostics(uri, Vec::new(), None).await;
212 }
213
214 async fn formatting(
215 &self,
216 params: DocumentFormattingParams,
217 ) -> JsonResult<Option<Vec<TextEdit>>> {
218 self.format_document(¶ms.text_document.uri)
219 .await
220 .map_err(|error| jsonrpc_error(&error))
221 }
222}
223
224pub fn run() -> Result<()> {
225 let runtime = tokio::runtime::Runtime::new().context("failed to start Tokio runtime")?;
226 runtime.block_on(async {
227 let stdin = tokio::io::stdin();
228 let stdout = tokio::io::stdout();
229 let (service, socket) = LspService::new(Backend::new);
230 Server::new(stdin, stdout, socket).serve(service).await;
231 });
232 Ok(())
233}
234
235fn extract_workspace_root(params: &InitializeParams) -> Option<PathBuf> {
236 params
237 .workspace_folders
238 .as_ref()
239 .and_then(|folders| {
240 folders
241 .iter()
242 .find_map(|folder| workspace_folder_path(folder).ok())
243 })
244 .or_else(|| {
245 params
246 .root_uri
247 .as_ref()
248 .and_then(|uri| uri.to_file_path().ok())
249 })
250}
251
252fn workspace_folder_path(folder: &WorkspaceFolder) -> Result<PathBuf> {
253 folder
254 .uri
255 .to_file_path()
256 .map_err(|()| anyhow!("workspace folder URI must use the file scheme"))
257}
258
259fn file_path_from_uri(uri: &Url) -> Result<PathBuf> {
260 uri.to_file_path()
261 .map_err(|()| anyhow!("document URI must use the file scheme: {uri}"))
262}
263
264fn is_workspace_root_cmakelists(path: &Path, workspace_root: &Path) -> bool {
265 path.file_name()
266 .is_some_and(|file_name| file_name == "CMakeLists.txt")
267 && path
268 .strip_prefix(workspace_root)
269 .is_ok_and(|relative| relative == Path::new("CMakeLists.txt"))
270}
271
272fn is_excluded(path: &Path, workspace_root: &Path, configuration: &Configuration) -> bool {
273 configuration.main.is_path_excluded(path)
274 || path
275 .strip_prefix(workspace_root)
276 .is_ok_and(|relative| configuration.main.is_path_excluded(relative))
277}
278
279fn relative_match_path(path: &Path, workspace_root: &Path) -> PathBuf {
280 path.strip_prefix(workspace_root).map_or_else(
281 |_| {
282 path.file_name()
283 .map_or_else(|| path.to_path_buf(), PathBuf::from)
284 },
285 PathBuf::from,
286 )
287}
288
289fn to_lsp_diagnostic(index: &PositionIndex, diagnostic: CheckDiagnostic) -> LspDiagnostic {
290 let severity = if diagnostic.code == RuleCode::E001 {
291 Some(DiagnosticSeverity::ERROR)
292 } else {
293 Some(DiagnosticSeverity::WARNING)
294 };
295
296 LspDiagnostic {
297 range: index.range(diagnostic.range),
298 severity,
299 code: Some(tower_lsp::lsp_types::NumberOrString::String(
300 diagnostic.code.to_string(),
301 )),
302 source: Some("cmake-tidy".to_owned()),
303 message: diagnostic.message,
304 ..LspDiagnostic::default()
305 }
306}
307
308fn jsonrpc_error(error: &anyhow::Error) -> JsonRpcError {
309 JsonRpcError {
310 code: ErrorCode::InternalError,
311 message: error.to_string().into(),
312 data: None,
313 }
314}
315
316#[derive(Debug, Clone)]
317struct PositionIndex {
318 source: String,
319 line_starts: Vec<usize>,
320}
321
322impl PositionIndex {
323 fn new(source: &str) -> Self {
324 let mut line_starts = vec![0];
325 for (index, character) in source.char_indices() {
326 if character == '\n' {
327 line_starts.push(index + 1);
328 }
329 }
330
331 Self {
332 source: source.to_owned(),
333 line_starts,
334 }
335 }
336
337 fn range(&self, range: TextRange) -> Range {
338 Range::new(self.position(range.start), self.position(range.end))
339 }
340
341 fn position(&self, offset: usize) -> Position {
342 let offset = clamp_char_boundary(&self.source, offset.min(self.source.len()));
343 let line_index = match self.line_starts.binary_search(&offset) {
344 Ok(index) => index,
345 Err(index) => index.saturating_sub(1),
346 };
347 let line_start = self.line_starts[line_index];
348 let character = utf16_code_units(&self.source[line_start..offset]);
349 Position::new(lsp_u32(line_index), lsp_u32(character))
350 }
351}
352
353const fn clamp_char_boundary(source: &str, mut offset: usize) -> usize {
354 while !source.is_char_boundary(offset) {
355 offset -= 1;
356 }
357 offset
358}
359
360fn lsp_u32(value: usize) -> u32 {
361 u32::try_from(value).unwrap_or(u32::MAX)
362}
363
364fn utf16_code_units(text: &str) -> usize {
365 text.encode_utf16().count()
366}
367
368#[cfg(test)]
369mod tests {
370 use cmake_tidy_ast::TextRange;
371 use tower_lsp::lsp_types::{DiagnosticSeverity, Position};
372
373 use super::{
374 PositionIndex, is_workspace_root_cmakelists, relative_match_path, to_lsp_diagnostic,
375 };
376
377 #[test]
378 fn detects_only_workspace_root_cmakelists() {
379 let workspace_root = std::path::Path::new("/workspace");
380 assert!(is_workspace_root_cmakelists(
381 &workspace_root.join("CMakeLists.txt"),
382 workspace_root,
383 ));
384 assert!(!is_workspace_root_cmakelists(
385 &workspace_root.join("src").join("CMakeLists.txt"),
386 workspace_root,
387 ));
388 }
389
390 #[test]
391 fn relative_match_path_uses_workspace_relative_path() {
392 let workspace_root = std::path::Path::new("/workspace");
393 assert_eq!(
394 relative_match_path(
395 &workspace_root.join("cmake").join("tooling.cmake"),
396 workspace_root
397 ),
398 std::path::PathBuf::from("cmake").join("tooling.cmake")
399 );
400 }
401
402 #[test]
403 fn converts_offsets_to_utf16_positions() {
404 let source = "é\n😀x\n";
405 let index = PositionIndex::new(source);
406 assert_eq!(index.position(0), Position::new(0, 0));
407 assert_eq!(index.position("é\n".len()), Position::new(1, 0));
408 assert_eq!(index.position("é\n😀".len()), Position::new(1, 2));
409 }
410
411 #[test]
412 fn maps_check_diagnostics_to_lsp_diagnostics() {
413 let index = PositionIndex::new("project(\n");
414 let diagnostic = to_lsp_diagnostic(
415 &index,
416 cmake_tidy_check::Diagnostic::new(
417 cmake_tidy_check::RuleCode::E001,
418 "parse error",
419 TextRange::new(0, 7),
420 ),
421 );
422 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
423 assert_eq!(
424 diagnostic.code,
425 Some(tower_lsp::lsp_types::NumberOrString::String(
426 "E001".to_owned(),
427 ))
428 );
429 }
430}