1use std::io::{Read, Write};
26use std::process::{Command, Stdio};
27use std::sync::mpsc;
28use std::thread;
29use std::time::Duration;
30
31use crate::config::CodeLang;
32use crate::parser::check_pragma;
33use crate::sentence::SentenceSplitter;
34
35pub const FORMATTER_TIMEOUT_SECS: u64 = 30;
37
38pub fn reflow_code_body(
46 lang: &str,
47 body: &str,
48 cfg: &CodeLang,
49 splitter: &dyn SentenceSplitter,
50 format_code: bool,
51) -> String {
52 #[cfg(feature = "treesitter")]
57 let body: &str = &{
58 let frozen = frozen_lines(body, cfg);
59 crate::ts_comments::reflow_grammar_comments(lang, body, cfg, splitter, &frozen)
60 .unwrap_or_else(|| body.to_string())
61 };
62 #[cfg(not(feature = "treesitter"))]
63 let _ = lang;
64
65 let after_comment_reflow = reflow_comments(body, cfg, splitter);
66 if format_code {
67 if let Some(ref argv) = cfg.formatter {
68 match run_formatter(&after_comment_reflow, argv) {
69 Ok(out) => return out,
70 Err(diag) => {
71 eprintln!("snapper: {diag}");
72 return after_comment_reflow;
73 }
74 }
75 }
76 }
77 after_comment_reflow
78}
79
80fn reflow_comments(body: &str, cfg: &CodeLang, splitter: &dyn SentenceSplitter) -> String {
83 let lines = crate::parser::iter_lines(body);
84 let mut out = String::with_capacity(body.len());
85 let mut i = 0;
86 let mut pragma_off = false;
87
88 while i < lines.len() {
89 let line = lines[i];
90 let slice = &body[line.start..line.end];
91
92 if let Some(on) = check_pragma_for(line.text, cfg) {
93 pragma_off = !on;
94 out.push_str(slice);
95 i += 1;
96 continue;
97 }
98 if pragma_off {
99 out.push_str(slice);
100 i += 1;
101 continue;
102 }
103
104 if let Some(ref pair) = cfg.block_comment {
105 let [open, close] = [pair[0].as_str(), pair[1].as_str()];
106 if !open.is_empty() {
107 if let Some((indent, after_open)) = split_at_marker(line.text, open) {
108 let trimmed_after = after_open.trim_start();
109 if !close.is_empty() {
110 if let Some(idx) = find_close(trimmed_after, close, cfg) {
111 let interior = &trimmed_after[..idx];
112 emit_block_comment(&mut out, indent, open, close, interior, splitter);
113 i += 1;
114 continue;
115 }
116 }
117 let mut interior = after_open.to_string();
118 let mut close_indent: Option<String> = None;
119 let mut closed_at = None;
120 for (j, next) in lines.iter().enumerate().skip(i + 1) {
121 if let Some(idx) = find_close(next.text, close, cfg) {
122 let pre = &next.text[..idx];
123 let pre_trim = pre.trim();
124 if !pre_trim.is_empty() {
125 if !interior.is_empty() && !interior.ends_with(' ') {
126 interior.push(' ');
127 }
128 interior.push_str(pre_trim);
129 }
130 close_indent = Some(
131 next.text[..next.text.len() - next.text.trim_start().len()]
132 .to_string(),
133 );
134 closed_at = Some(j);
135 break;
136 }
137 let stripped = next.text.trim_start();
138 let stripped = stripped
139 .strip_prefix("* ")
140 .or_else(|| stripped.strip_prefix('*'))
141 .unwrap_or(stripped);
142 if !interior.is_empty() && !interior.ends_with(' ') {
143 interior.push(' ');
144 }
145 interior.push_str(stripped.trim());
146 }
147 if let Some(j) = closed_at {
148 let ci = close_indent.unwrap_or_else(|| indent.to_string());
149 emit_block_comment_multi(
150 &mut out,
151 indent,
152 open,
153 close,
154 &ci,
155 interior.trim(),
156 splitter,
157 );
158 i = j + 1;
159 continue;
160 }
161 for keep in &lines[i..] {
163 out.push_str(&body[keep.start..keep.end]);
164 }
165 break;
166 }
167 }
168 }
169
170 if let Some(ref marker) = cfg.line_comment {
171 if let Some((indent, marker, rest)) = strip_line_comment(line.text, marker) {
172 let prose = rest.trim();
173 if prose.is_empty() {
174 out.push_str(slice);
175 i += 1;
176 continue;
177 }
178 let sentences = splitter.split(prose);
179 if sentences.len() <= 1 {
180 out.push_str(slice);
181 i += 1;
182 continue;
183 }
184 for (k, s) in sentences.iter().enumerate() {
185 out.push_str(indent);
186 out.push_str(marker);
187 out.push(' ');
188 out.push_str(s);
189 if k + 1 < sentences.len() {
190 out.push('\n');
191 } else {
192 out.push_str(
193 &body[line.terminator_span().start..line.terminator_span().end],
194 );
195 }
196 }
197 i += 1;
198 continue;
199 }
200 }
201
202 if let Some(ref marker) = cfg.line_comment {
203 if let Some(at) = trailing_comment_at(line.text, marker, cfg) {
204 if let Some(rewritten) = rewrite_trailing(line.text, at, marker, splitter) {
205 out.push_str(&rewritten);
206 out.push_str(&body[line.terminator_span().start..line.terminator_span().end]);
207 i += 1;
208 continue;
209 }
210 }
211 }
212
213 out.push_str(slice);
214 i += 1;
215 }
216 out
217}
218
219#[cfg(feature = "treesitter")]
222fn frozen_lines(body: &str, cfg: &CodeLang) -> std::collections::HashSet<usize> {
223 let mut frozen = std::collections::HashSet::new();
224 let mut off = false;
225 for (i, line) in body.lines().enumerate() {
226 if let Some(on) = check_pragma_for(line, cfg) {
227 frozen.insert(i);
228 off = !on;
229 continue;
230 }
231 if off {
232 frozen.insert(i);
233 }
234 }
235 frozen
236}
237
238fn check_pragma_for(line: &str, cfg: &CodeLang) -> Option<bool> {
242 if let Some(b) = check_pragma(line) {
243 return Some(b);
244 }
245 let trimmed = line.trim();
246 if let Some(ref marker) = cfg.line_comment {
247 if let Some(rest) = trimmed.strip_prefix(marker.as_str()) {
248 let rest = rest.trim();
249 if rest == "snapper:off" {
250 return Some(false);
251 }
252 if rest == "snapper:on" {
253 return Some(true);
254 }
255 }
256 }
257 None
258}
259
260fn trailing_comment_at(line: &str, marker: &str, cfg: &CodeLang) -> Option<usize> {
269 if marker.is_empty() {
270 return None;
271 }
272 let quotes = cfg.quote_chars();
273 let escape = cfg.escape_char();
274 let block_open = cfg
275 .block_comment
276 .as_ref()
277 .map(|pair| pair[0].as_str())
278 .unwrap_or("");
279
280 let bytes = line.as_bytes();
281 let mut in_string: Option<char> = None;
282 let mut i = 0;
283 let mut seen_code = false;
284
285 while i < bytes.len() {
286 let rest = &line[i..];
287 let ch = rest.chars().next()?;
288
289 match in_string {
290 Some(delim) => {
291 if ch == escape {
292 i += ch.len_utf8();
293 if let Some(next) = line[i..].chars().next() {
294 i += next.len_utf8();
295 }
296 continue;
297 }
298 if ch == delim {
299 in_string = None;
300 }
301 }
302 None => {
303 if quotes.contains(&ch) {
304 in_string = Some(ch);
305 seen_code = true;
306 } else if !block_open.is_empty() && rest.starts_with(block_open) {
307 return None;
310 } else if rest.starts_with(marker) {
311 return if seen_code { Some(i) } else { None };
312 } else if !ch.is_whitespace() {
313 seen_code = true;
314 }
315 }
316 }
317 i += ch.len_utf8();
318 }
319 None
320}
321
322fn rewrite_trailing(
326 line: &str,
327 at: usize,
328 marker: &str,
329 splitter: &dyn SentenceSplitter,
330) -> Option<String> {
331 let (code, comment) = line.split_at(at);
332 let (_, found, rest) = strip_line_comment(comment, marker)?;
333 let prose = rest.trim();
334 if prose.is_empty() {
335 return None;
336 }
337 let sentences = splitter.split(prose);
338 if sentences.len() < 2 {
339 return None;
340 }
341
342 let pad: String = code
343 .chars()
344 .map(|c| if c == '\t' { '\t' } else { ' ' })
345 .collect();
346 let mut out = String::with_capacity(line.len() + sentences.len() * (pad.len() + 4));
347 for (i, sentence) in sentences.iter().enumerate() {
348 if i == 0 {
349 out.push_str(code);
350 } else {
351 out.push('\n');
352 out.push_str(&pad);
353 }
354 out.push_str(found);
355 out.push(' ');
356 out.push_str(sentence);
357 }
358 Some(out)
359}
360
361fn find_close(line: &str, close: &str, cfg: &CodeLang) -> Option<usize> {
365 if close.is_empty() {
366 return None;
367 }
368 let quotes = cfg.quote_chars();
369 if close.chars().all(|c| quotes.contains(&c)) {
370 return line.find(close);
371 }
372 let escape = cfg.escape_char();
373 let bytes = line.as_bytes();
374 let mut in_string: Option<char> = None;
375 let mut i = 0;
376 while i < bytes.len() {
377 let rest = &line[i..];
378 let ch = rest.chars().next()?;
379 match in_string {
380 Some(delim) => {
381 if ch == escape {
382 i += ch.len_utf8();
383 if let Some(next) = line[i..].chars().next() {
384 i += next.len_utf8();
385 }
386 continue;
387 }
388 if ch == delim {
389 in_string = None;
390 }
391 }
392 None => {
393 if rest.starts_with(close) {
394 return Some(i);
395 }
396 if quotes.contains(&ch) {
397 in_string = Some(ch);
398 }
399 }
400 }
401 i += ch.len_utf8();
402 }
403 None
404}
405
406fn split_at_marker<'a>(line: &'a str, marker: &str) -> Option<(&'a str, &'a str)> {
411 let leading = line.len() - line.trim_start().len();
412 let (indent, rest) = line.split_at(leading);
413 rest.strip_prefix(marker).map(|after| (indent, after))
414}
415
416fn strip_line_comment<'a>(line: &'a str, marker: &str) -> Option<(&'a str, &'a str, &'a str)> {
424 let leading = line.len() - line.trim_start().len();
425 let (indent, rest) = line.split_at(leading);
426 rest.strip_prefix(marker)?;
427
428 let mut end = marker.len();
429 if let Some(last) = marker.chars().last() {
430 let bytes = rest.as_bytes();
431 while end < bytes.len() && bytes[end] == last as u8 {
432 end += 1;
433 }
434 if marker.len() > 1 && end < bytes.len() && bytes[end] == b'!' {
437 end += 1;
438 }
439 }
440 let (found, after) = rest.split_at(end);
441
442 let after = after.strip_prefix(' ').unwrap_or(after);
446 Some((indent, found, after))
447}
448
449fn emit_block_comment(
453 out: &mut String,
454 indent: &str,
455 open: &str,
456 close: &str,
457 interior: &str,
458 splitter: &dyn SentenceSplitter,
459) {
460 out.push_str(indent);
461 out.push_str(open);
462 out.push('\n');
463 let sentences = splitter.split(interior.trim());
464 for s in &sentences {
465 out.push_str(indent);
466 out.push(' ');
467 out.push_str(s);
468 out.push('\n');
469 }
470 out.push_str(indent);
471 out.push_str(close);
472 out.push('\n');
473}
474
475fn emit_block_comment_multi(
478 out: &mut String,
479 indent: &str,
480 open: &str,
481 close: &str,
482 close_indent: &str,
483 interior: &str,
484 splitter: &dyn SentenceSplitter,
485) {
486 out.push_str(indent);
487 out.push_str(open);
488 out.push('\n');
489 let sentences = splitter.split(interior);
490 for s in &sentences {
491 out.push_str(indent);
492 out.push(' ');
493 out.push_str(s);
494 out.push('\n');
495 }
496 out.push_str(close_indent);
497 out.push_str(close);
498 out.push('\n');
499}
500
501pub fn run_formatter(body: &str, argv: &[String]) -> Result<String, String> {
510 if argv.is_empty() {
511 return Err("formatter argv is empty".to_string());
512 }
513 let mut cmd = Command::new(&argv[0]);
514 cmd.args(&argv[1..])
515 .stdin(Stdio::piped())
516 .stdout(Stdio::piped())
517 .stderr(Stdio::piped());
518
519 let mut child = match cmd.spawn() {
520 Ok(c) => c,
521 Err(e) => {
522 if e.kind() == std::io::ErrorKind::NotFound {
523 return Err(format!("formatter not found: {}", argv[0]));
524 }
525 return Err(format!("formatter spawn failed: {}: {e}", argv[0]));
526 }
527 };
528
529 if let Some(mut stdin) = child.stdin.take() {
531 let body_owned = body.to_string();
532 let _ = thread::spawn(move || {
533 let _ = stdin.write_all(body_owned.as_bytes());
534 });
536 }
537
538 let (done_tx, done_rx) = mpsc::channel::<()>();
540 let child_id = child.id();
541 let watchdog = thread::spawn(move || {
542 match done_rx.recv_timeout(Duration::from_secs(FORMATTER_TIMEOUT_SECS)) {
543 Ok(_) | Err(mpsc::RecvTimeoutError::Disconnected) => {
544 }
546 Err(mpsc::RecvTimeoutError::Timeout) => {
547 #[cfg(unix)]
550 unsafe {
551 libc_kill(child_id as i32);
552 }
553 #[cfg(not(unix))]
554 {
555 let _ = std::process::Command::new("taskkill")
556 .args(["/F", "/PID", &child_id.to_string()])
557 .output();
558 }
559 }
560 }
561 });
562
563 let output = child.wait_with_output();
566 let _ = done_tx.send(());
568 let _ = watchdog.join();
569
570 let output = match output {
571 Ok(o) => o,
572 Err(e) => return Err(format!("formatter wait failed: {}: {e}", argv[0])),
573 };
574
575 if !output.status.success() {
576 let stderr = String::from_utf8_lossy(&output.stderr);
577 return Err(format!(
578 "formatter {} exited non-zero (status {:?}): {}",
579 argv[0],
580 output.status.code(),
581 stderr.trim()
582 ));
583 }
584
585 String::from_utf8(output.stdout)
586 .map_err(|e| format!("formatter {} produced non-UTF-8 output: {e}", argv[0]))
587}
588
589#[cfg(unix)]
593unsafe fn libc_kill(pid: i32) {
594 unsafe extern "C" {
596 fn kill(pid: i32, sig: i32) -> i32;
597 }
598 const SIGKILL: i32 = 9;
599 unsafe {
600 let _ = kill(pid, SIGKILL);
601 }
602}
603
604#[doc(hidden)]
607pub fn read_to_string(mut r: impl Read) -> std::io::Result<String> {
608 let mut s = String::new();
609 r.read_to_string(&mut s)?;
610 Ok(s)
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use crate::sentence::unicode::UnicodeSentenceSplitter;
617
618 fn rust_cfg() -> CodeLang {
619 CodeLang {
620 line_comment: Some("//".to_string()),
621 block_comment: Some(["/*".to_string(), "*/".to_string()]),
622 ..Default::default()
623 }
624 }
625
626 #[test]
627 fn line_comment_two_sentences_split() {
628 let body = "// First sentence. Second sentence.\nfn main() {}\n";
629 let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
630 assert_eq!(
631 out,
632 "// First sentence.\n// Second sentence.\nfn main() {}\n"
633 );
634 }
635
636 #[test]
637 fn indented_comment_preserved() {
638 let body = " // First. Second.\n fn x() {}\n";
639 let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
640 assert_eq!(out, " // First.\n // Second.\n fn x() {}\n");
641 }
642
643 #[test]
644 fn non_comment_passes_through() {
645 let body = "fn main() { println!(\"hi\"); }\n";
646 let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
647 assert_eq!(out, body);
648 }
649
650 #[test]
651 fn block_comment_one_liner_splits() {
652 let body = "/* First. Second. */\n";
653 let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
654 assert_eq!(out, "/*\n First.\n Second.\n*/\n");
655 }
656
657 #[test]
658 fn pragma_freezes_run() {
659 let body = "// snapper:off\n// Long.\n// Off.\n// snapper:on\n// Reflow this. Now.\n";
660 let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
661 let expected = concat!(
662 "// snapper:off\n",
663 "// Long.\n",
664 "// Off.\n",
665 "// snapper:on\n",
666 "// Reflow this.\n",
667 "// Now.\n",
668 );
669 assert_eq!(out, expected);
670 }
671}