Skip to main content

snapper_fmt/
lsp.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Mutex;
4
5use tower_lsp::jsonrpc::Result;
6use tower_lsp::lsp_types::*;
7use tower_lsp::{Client, LanguageServer, LspService, Server};
8
9use crate::config::ProjectConfig;
10use crate::format::Format;
11use crate::{FormatConfig, format_text};
12
13pub struct SnapperLsp {
14    client: Client,
15    documents: Mutex<HashMap<Url, (String, Format)>>,
16    project_config: Mutex<ProjectConfig>,
17    root_path: Mutex<Option<PathBuf>>,
18}
19
20impl SnapperLsp {
21    fn new(client: Client) -> Self {
22        Self {
23            client,
24            documents: Mutex::new(HashMap::new()),
25            project_config: Mutex::new(ProjectConfig::default()),
26            root_path: Mutex::new(None),
27        }
28    }
29
30    fn reload_config(&self) {
31        let root = self.root_path.lock().expect("root_path lock poisoned");
32        let config = if let Some(ref root) = *root {
33            ProjectConfig::find_and_load(root).unwrap_or_default()
34        } else {
35            ProjectConfig::default()
36        };
37        *self.project_config.lock().expect("config lock poisoned") = config;
38    }
39
40    fn make_config(&self, format: Format) -> FormatConfig {
41        let project = self.project_config.lock().expect("config lock poisoned");
42        let format_str = match format {
43            Format::Org => "org",
44            Format::Latex => "latex",
45            Format::Markdown => "markdown",
46            Format::Rst => "rst",
47            Format::Plaintext => "plaintext",
48        };
49        FormatConfig {
50            format,
51            max_width: project.max_width_for_format(format_str).unwrap_or(0),
52            extra_abbreviations: project.abbreviations_for_format(format_str),
53            ..Default::default()
54        }
55    }
56
57    fn format_document(&self, uri: &Url) -> Option<Vec<TextEdit>> {
58        // Returns None if lock is poisoned (graceful degradation)
59        let docs = self.documents.lock().ok()?;
60        let (text, format) = docs.get(uri)?;
61        let config = self.make_config(*format);
62        let formatted = format_text(text, &config).ok()?;
63
64        if formatted == *text {
65            return None;
66        }
67
68        let lines_count = text.lines().count();
69        let end_line = lines_count.saturating_sub(1);
70        let last_line_len = text.lines().last().map_or(0, |l| l.len());
71
72        Some(vec![TextEdit {
73            range: Range {
74                start: Position::new(0, 0),
75                end: Position::new(end_line as u32, last_line_len as u32),
76            },
77            new_text: formatted,
78        }])
79    }
80
81    fn compute_diagnostics(&self, uri: &Url) -> Vec<Diagnostic> {
82        let docs = self.documents.lock().expect("document store poisoned");
83        let Some((text, _)) = docs.get(uri) else {
84            return vec![];
85        };
86
87        let mut diagnostics = Vec::new();
88        // Flag lines that contain multiple sentences (period + space + capital)
89        for (i, line) in text.lines().enumerate() {
90            let chars: Vec<char> = line.chars().collect();
91            if chars.is_empty() {
92                continue;
93            }
94
95            // Highlight the exact space character where a split should occur
96            for j in 1..chars.len().saturating_sub(1) {
97                if (chars[j - 1] == '.' || chars[j - 1] == '!' || chars[j - 1] == '?')
98                    && chars[j] == ' '
99                    && chars.get(j + 1).is_some_and(|c| c.is_uppercase())
100                {
101                    diagnostics.push(Diagnostic {
102                        range: Range {
103                            start: Position::new(i as u32, j as u32),
104                            end: Position::new(i as u32, (j + 1) as u32),
105                        },
106                        severity: Some(DiagnosticSeverity::HINT),
107                        source: Some("snapper".to_string()),
108                        message: "Semantic line break recommended here. Consider running snapper."
109                            .to_string(),
110                        ..Default::default()
111                    });
112                }
113            }
114        }
115        diagnostics
116    }
117}
118
119#[tower_lsp::async_trait]
120impl LanguageServer for SnapperLsp {
121    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
122        let root = params
123            .workspace_folders
124            .as_ref()
125            .and_then(|folders| folders.first())
126            .and_then(|f| f.uri.to_file_path().ok())
127            .or_else(|| {
128                #[allow(deprecated)]
129                params.root_uri.as_ref().and_then(|u| u.to_file_path().ok())
130            });
131
132        if let Some(ref root) = root {
133            *self.root_path.lock().expect("root_path lock poisoned") = Some(root.clone());
134        }
135        self.reload_config();
136
137        Ok(InitializeResult {
138            capabilities: ServerCapabilities {
139                text_document_sync: Some(TextDocumentSyncCapability::Kind(
140                    TextDocumentSyncKind::FULL,
141                )),
142                document_formatting_provider: Some(OneOf::Left(true)),
143                document_range_formatting_provider: Some(OneOf::Left(true)),
144                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
145                code_lens_provider: Some(CodeLensOptions {
146                    resolve_provider: Some(false),
147                }),
148                document_on_type_formatting_provider: Some(DocumentOnTypeFormattingOptions {
149                    first_trigger_character: ".".to_string(),
150                    more_trigger_character: Some(vec![
151                        " ".to_string(),
152                        "?".to_string(),
153                        "!".to_string(),
154                        "\n".to_string(),
155                    ]),
156                }),
157                hover_provider: Some(HoverProviderCapability::Simple(true)),
158                execute_command_provider: Some(ExecuteCommandOptions {
159                    commands: vec!["snapper.reloadConfig".to_string()],
160                    ..Default::default()
161                }),
162                ..Default::default()
163            },
164            ..Default::default()
165        })
166    }
167
168    async fn initialized(&self, _: InitializedParams) {
169        let msg = {
170            let config = self.project_config.lock().expect("config lock poisoned");
171            format!(
172                "snapper LSP initialized ({} extra abbreviations, max_width={})",
173                config.extra_abbreviations.len(),
174                config.max_width.unwrap_or(0),
175            )
176        };
177        self.client.log_message(MessageType::INFO, msg).await;
178    }
179
180    async fn shutdown(&self) -> Result<()> {
181        Ok(())
182    }
183
184    async fn did_open(&self, params: DidOpenTextDocumentParams) {
185        let uri = params.text_document.uri.clone();
186        let text = params.text_document.text.clone();
187        let format = detect_format_from_uri(&uri, &params.text_document.language_id);
188
189        self.documents
190            .lock()
191            .expect("document store poisoned")
192            .insert(uri.clone(), (text, format));
193
194        let diagnostics = self.compute_diagnostics(&uri);
195        self.client
196            .publish_diagnostics(uri, diagnostics, None)
197            .await;
198    }
199
200    async fn did_change(&self, params: DidChangeTextDocumentParams) {
201        let uri = params.text_document.uri.clone();
202        if let Some(change) = params.content_changes.into_iter().last() {
203            let format = {
204                let docs = self.documents.lock().expect("document store poisoned");
205                docs.get(&uri).map_or(Format::Plaintext, |(_, f)| *f)
206            };
207            self.documents
208                .lock()
209                .expect("document store poisoned")
210                .insert(uri.clone(), (change.text, format));
211
212            let diagnostics = self.compute_diagnostics(&uri);
213            self.client
214                .publish_diagnostics(uri, diagnostics, None)
215                .await;
216        }
217    }
218
219    async fn did_close(&self, params: DidCloseTextDocumentParams) {
220        self.documents
221            .lock()
222            .expect("document store poisoned")
223            .remove(&params.text_document.uri);
224    }
225
226    async fn did_change_watched_files(&self, _params: DidChangeWatchedFilesParams) {
227        self.reload_config();
228        self.client
229            .log_message(MessageType::INFO, "Reloaded .snapperrc.toml")
230            .await;
231
232        let uris: Vec<Url> = {
233            let docs = self.documents.lock().expect("document store poisoned");
234            docs.keys().cloned().collect()
235        };
236        for uri in uris {
237            let diagnostics = self.compute_diagnostics(&uri);
238            self.client
239                .publish_diagnostics(uri, diagnostics, None)
240                .await;
241        }
242    }
243
244    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
245        Ok(self.format_document(&params.text_document.uri))
246    }
247
248    async fn range_formatting(
249        &self,
250        params: DocumentRangeFormattingParams,
251    ) -> Result<Option<Vec<TextEdit>>> {
252        let uri = &params.text_document.uri;
253        let range = params.range;
254
255        let docs = self.documents.lock().expect("document store poisoned");
256        let Some((text, format)) = docs.get(uri) else {
257            return Ok(None);
258        };
259
260        let lines: Vec<&str> = text.lines().collect();
261        let start = range.start.line as usize;
262        let end = (range.end.line as usize).min(lines.len().saturating_sub(1));
263        let range_text = lines[start..=end].join("\n");
264
265        let config = self.make_config(*format);
266        let formatted = match format_text(&range_text, &config) {
267            Ok(f) => f,
268            Err(_) => return Ok(None),
269        };
270
271        if formatted == range_text {
272            return Ok(None);
273        }
274
275        let last_col = lines.get(end).map_or(0, |l| l.len());
276
277        Ok(Some(vec![TextEdit {
278            range: Range {
279                start: Position::new(start as u32, 0),
280                end: Position::new(end as u32, last_col as u32),
281            },
282            new_text: formatted,
283        }]))
284    }
285
286    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
287        let uri = &params.text_document.uri;
288        let mut actions = Vec::new();
289
290        // Global Source Action: Format Document
291        let wants_source_action = params.context.only.as_ref().is_none_or(|only| {
292            only.contains(&CodeActionKind::SOURCE_FIX_ALL) || only.contains(&CodeActionKind::SOURCE)
293        });
294
295        if wants_source_action {
296            if let Some(edits) = self.format_document(uri) {
297                let mut changes = HashMap::new();
298                changes.insert(uri.clone(), edits);
299                actions.push(CodeActionOrCommand::CodeAction(CodeAction {
300                    title: "Format document with snapper".to_string(),
301                    kind: Some(CodeActionKind::SOURCE_FIX_ALL),
302                    edit: Some(WorkspaceEdit {
303                        changes: Some(changes),
304                        ..Default::default()
305                    }),
306                    ..Default::default()
307                }));
308            }
309        }
310
311        let snapper_diags: Vec<&Diagnostic> = params
312            .context
313            .diagnostics
314            .iter()
315            .filter(|d| d.source.as_deref() == Some("snapper"))
316            .collect();
317
318        let docs = self.documents.lock().expect("document store poisoned");
319        let Some((text, format)) = docs.get(uri) else {
320            return Ok(if actions.is_empty() {
321                None
322            } else {
323                Some(actions)
324            });
325        };
326
327        let config = self.make_config(*format);
328
329        for diag in &snapper_diags {
330            let lines: Vec<&str> = text.lines().collect();
331            let start = diag.range.start.line as usize;
332            let end = diag.range.end.line as usize;
333
334            if start >= lines.len() {
335                continue;
336            }
337
338            let end = end.min(lines.len().saturating_sub(1));
339            let range_text = lines[start..=end].join("\n");
340
341            let formatted = match format_text(&range_text, &config) {
342                Ok(f) => f,
343                Err(_) => continue,
344            };
345
346            if formatted == range_text {
347                continue;
348            }
349
350            let last_col = lines.get(end).map_or(0, |l| l.len());
351            let edit = TextEdit {
352                range: Range {
353                    start: Position::new(start as u32, 0),
354                    end: Position::new(end as u32, last_col as u32),
355                },
356                new_text: formatted,
357            };
358
359            let mut changes = HashMap::new();
360            changes.insert(uri.clone(), vec![edit]);
361
362            actions.push(CodeActionOrCommand::CodeAction(CodeAction {
363                title: "Apply semantic line break".to_string(),
364                kind: Some(CodeActionKind::QUICKFIX),
365                diagnostics: Some(vec![(*diag).clone()]),
366                edit: Some(WorkspaceEdit {
367                    changes: Some(changes),
368                    ..Default::default()
369                }),
370                is_preferred: Some(true),
371                ..Default::default()
372            }));
373        }
374
375        if actions.is_empty() {
376            Ok(None)
377        } else {
378            Ok(Some(actions))
379        }
380    }
381
382    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
383        let uri = &params.text_document.uri;
384        let docs = self.documents.lock().expect("document store poisoned");
385        let Some((_, format)) = docs.get(uri) else {
386            return Ok(None);
387        };
388
389        let config = self.make_config(*format);
390        let width_display = if config.max_width == 0 {
391            "unlimited".to_string()
392        } else {
393            config.max_width.to_string()
394        };
395
396        let title = format!("snapper: {:?} | width: {}", config.format, width_display);
397
398        Ok(Some(vec![CodeLens {
399            range: Range {
400                start: Position::new(0, 0),
401                end: Position::new(0, 0),
402            },
403            command: Some(Command {
404                title,
405                command: "snapper.showOutputChannel".to_string(),
406                arguments: None,
407            }),
408            data: None,
409        }]))
410    }
411
412    async fn on_type_formatting(
413        &self,
414        params: DocumentOnTypeFormattingParams,
415    ) -> Result<Option<Vec<TextEdit>>> {
416        let position = params.text_document_position.position;
417        self.range_formatting(DocumentRangeFormattingParams {
418            text_document: params.text_document_position.text_document,
419            range: Range {
420                start: Position::new(position.line, 0),
421                end: Position::new(position.line, position.character),
422            },
423            options: params.options,
424            work_done_progress_params: Default::default(),
425        })
426        .await
427    }
428
429    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
430        let uri = &params.text_document_position_params.text_document.uri;
431        let pos = params.text_document_position_params.position;
432
433        let docs = self.documents.lock().expect("document store poisoned");
434        let Some((text, format)) = docs.get(uri) else {
435            return Ok(None);
436        };
437
438        let line = text.lines().nth(pos.line as usize).unwrap_or("");
439        let config = self.make_config(*format);
440        let formatted = format_text(line, &config).unwrap_or_default();
441
442        // Show hover preview only if the line actually needs formatting
443        if formatted.trim() != line.trim() && formatted.lines().count() > 1 {
444            return Ok(Some(Hover {
445                contents: HoverContents::Markup(MarkupContent {
446                    kind: MarkupKind::Markdown,
447                    value: format!("**snapper preview:**\n```text\n{}\n```", formatted.trim()),
448                }),
449                range: None,
450            }));
451        }
452
453        Ok(None)
454    }
455
456    async fn execute_command(
457        &self,
458        params: ExecuteCommandParams,
459    ) -> Result<Option<serde_json::Value>> {
460        if params.command == "snapper.reloadConfig" {
461            self.reload_config();
462            self.client
463                .log_message(MessageType::INFO, "Manually reloaded .snapperrc.toml")
464                .await;
465        }
466        Ok(None)
467    }
468}
469
470fn detect_format_from_uri(uri: &Url, language_id: &str) -> Format {
471    match language_id {
472        "org" => return Format::Org,
473        "latex" | "tex" => return Format::Latex,
474        "markdown" => return Format::Markdown,
475        "plaintext" => return Format::Plaintext,
476        "restructuredtext" => return Format::Rst,
477        _ => {}
478    }
479    if let Ok(path) = uri.to_file_path() {
480        Format::from_path(&path)
481    } else {
482        Format::Plaintext
483    }
484}
485
486/// Run the LSP server on stdin/stdout.
487pub async fn run_lsp() {
488    let stdin = tokio::io::stdin();
489    let stdout = tokio::io::stdout();
490
491    let (service, socket) = LspService::new(SnapperLsp::new);
492    Server::new(stdin, stdout, socket).serve(service).await;
493}