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 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 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 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, ¶ms.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(¶ms.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(¶ms.text_document.uri))
247 }
248
249 async fn range_formatting(
250 &self,
251 params: DocumentRangeFormattingParams,
252 ) -> Result<Option<Vec<TextEdit>>> {
253 let uri = ¶ms.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 = ¶ms.text_document.uri;
289 let mut actions = Vec::new();
290
291 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 = ¶ms.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 = ¶ms.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 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
487pub 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}