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