Skip to main content

snapper_fmt/
lsp.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use tower_lsp::jsonrpc::Result;
5use tower_lsp::lsp_types::*;
6use tower_lsp::{Client, LanguageServer, LspService, Server};
7
8use crate::format::Format;
9use crate::{FormatConfig, format_text};
10
11pub struct SnapperLsp {
12    client: Client,
13    documents: Mutex<HashMap<Url, (String, Format)>>,
14}
15
16impl SnapperLsp {
17    fn new(client: Client) -> Self {
18        Self {
19            client,
20            documents: Mutex::new(HashMap::new()),
21        }
22    }
23
24    fn make_config(&self, format: Format) -> FormatConfig {
25        FormatConfig {
26            format,
27            max_width: 0,
28            use_neural: false,
29            neural_lang: "en".to_string(),
30            neural_model_path: None,
31            extra_abbreviations: vec![],
32            use_pandoc: false,
33            pandoc_format: None,
34        }
35    }
36
37    fn format_document(&self, uri: &Url) -> Option<Vec<TextEdit>> {
38        let docs = self.documents.lock().ok()?;
39        let (text, format) = docs.get(uri)?;
40        let config = self.make_config(*format);
41        let formatted = format_text(text, &config).ok()?;
42        if formatted == *text {
43            return None;
44        }
45        let lines = text.lines().count();
46        let last_line_len = text.lines().last().map_or(0, |l| l.len());
47        Some(vec![TextEdit {
48            range: Range {
49                start: Position::new(0, 0),
50                end: Position::new(lines as u32, last_line_len as u32),
51            },
52            new_text: formatted,
53        }])
54    }
55
56    fn compute_diagnostics(&self, uri: &Url) -> Vec<Diagnostic> {
57        let docs = self.documents.lock().ok().unwrap();
58        let Some((text, _)) = docs.get(uri) else {
59            return vec![];
60        };
61
62        let mut diagnostics = Vec::new();
63        for (i, line) in text.lines().enumerate() {
64            // Flag lines that contain multiple sentences (period + space + capital)
65            let trimmed = line.trim();
66            if trimmed.is_empty() {
67                continue;
68            }
69            let mut sentence_boundaries = 0;
70            let chars: Vec<char> = trimmed.chars().collect();
71            for j in 1..chars.len().saturating_sub(1) {
72                if (chars[j - 1] == '.' || chars[j - 1] == '!' || chars[j - 1] == '?')
73                    && chars[j] == ' '
74                    && chars.get(j + 1).is_some_and(|c| c.is_uppercase())
75                {
76                    sentence_boundaries += 1;
77                }
78            }
79            if sentence_boundaries >= 1 {
80                diagnostics.push(Diagnostic {
81                    range: Range {
82                        start: Position::new(i as u32, 0),
83                        end: Position::new(i as u32, line.len() as u32),
84                    },
85                    severity: Some(DiagnosticSeverity::HINT),
86                    source: Some("snapper".to_string()),
87                    message: format!(
88                        "Line contains {} sentence boundary(ies). Consider running snapper.",
89                        sentence_boundaries
90                    ),
91                    ..Default::default()
92                });
93            }
94        }
95        diagnostics
96    }
97}
98
99#[tower_lsp::async_trait]
100impl LanguageServer for SnapperLsp {
101    async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
102        Ok(InitializeResult {
103            capabilities: ServerCapabilities {
104                text_document_sync: Some(TextDocumentSyncCapability::Kind(
105                    TextDocumentSyncKind::FULL,
106                )),
107                document_formatting_provider: Some(OneOf::Left(true)),
108                document_range_formatting_provider: Some(OneOf::Left(true)),
109                ..Default::default()
110            },
111            ..Default::default()
112        })
113    }
114
115    async fn initialized(&self, _: InitializedParams) {
116        self.client
117            .log_message(MessageType::INFO, "snapper LSP initialized")
118            .await;
119    }
120
121    async fn shutdown(&self) -> Result<()> {
122        Ok(())
123    }
124
125    async fn did_open(&self, params: DidOpenTextDocumentParams) {
126        let uri = params.text_document.uri.clone();
127        let text = params.text_document.text.clone();
128        let format = detect_format_from_uri(&uri, &params.text_document.language_id);
129        self.documents
130            .lock()
131            .unwrap()
132            .insert(uri.clone(), (text, format));
133
134        let diagnostics = self.compute_diagnostics(&uri);
135        self.client
136            .publish_diagnostics(uri, diagnostics, None)
137            .await;
138    }
139
140    async fn did_change(&self, params: DidChangeTextDocumentParams) {
141        let uri = params.text_document.uri.clone();
142        if let Some(change) = params.content_changes.into_iter().last() {
143            let format = {
144                let docs = self.documents.lock().unwrap();
145                docs.get(&uri).map_or(Format::Plaintext, |(_, f)| *f)
146            };
147            self.documents
148                .lock()
149                .unwrap()
150                .insert(uri.clone(), (change.text, format));
151
152            let diagnostics = self.compute_diagnostics(&uri);
153            self.client
154                .publish_diagnostics(uri, diagnostics, None)
155                .await;
156        }
157    }
158
159    async fn did_close(&self, params: DidCloseTextDocumentParams) {
160        self.documents
161            .lock()
162            .unwrap()
163            .remove(&params.text_document.uri);
164    }
165
166    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
167        Ok(self.format_document(&params.text_document.uri))
168    }
169
170    async fn range_formatting(
171        &self,
172        params: DocumentRangeFormattingParams,
173    ) -> Result<Option<Vec<TextEdit>>> {
174        let uri = &params.text_document.uri;
175        let range = params.range;
176        let docs = self.documents.lock().unwrap();
177        let Some((text, format)) = docs.get(uri) else {
178            return Ok(None);
179        };
180
181        let lines: Vec<&str> = text.lines().collect();
182        let start = range.start.line as usize;
183        let end = (range.end.line as usize).min(lines.len().saturating_sub(1));
184        let range_text = lines[start..=end].join("\n");
185
186        let config = self.make_config(*format);
187        let formatted = match format_text(&range_text, &config) {
188            Ok(f) => f,
189            Err(_) => return Ok(None),
190        };
191
192        if formatted == range_text {
193            return Ok(None);
194        }
195
196        let last_col = lines.get(end).map_or(0, |l| l.len());
197
198        Ok(Some(vec![TextEdit {
199            range: Range {
200                start: Position::new(start as u32, 0),
201                end: Position::new(end as u32, last_col as u32),
202            },
203            new_text: formatted,
204        }]))
205    }
206}
207
208fn detect_format_from_uri(uri: &Url, language_id: &str) -> Format {
209    // Try language ID first
210    match language_id {
211        "org" => return Format::Org,
212        "latex" | "tex" => return Format::Latex,
213        "markdown" => return Format::Markdown,
214        "plaintext" => return Format::Plaintext,
215        _ => {}
216    }
217    // Fall back to file extension
218    if let Ok(path) = uri.to_file_path() {
219        Format::from_path(&path)
220    } else {
221        Format::Plaintext
222    }
223}
224
225/// Run the LSP server on stdin/stdout.
226pub async fn run_lsp() {
227    let stdin = tokio::io::stdin();
228    let stdout = tokio::io::stdout();
229
230    let (service, socket) = LspService::new(SnapperLsp::new);
231    Server::new(stdin, stdout, socket).serve(service).await;
232}