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 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, ¶ms.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(¶ms.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(¶ms.text_document.uri))
252 }
253
254 async fn range_formatting(
255 &self,
256 params: DocumentRangeFormattingParams,
257 ) -> Result<Option<Vec<TextEdit>>> {
258 let uri = ¶ms.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 = ¶ms.text_document.uri;
294 let mut actions = Vec::new();
295
296 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 = ¶ms.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 = ¶ms.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 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
492pub 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}