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