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