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