Skip to main content

cmake_tidy/
server.rs

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(&params))
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(&params.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}