Skip to main content

snapper_fmt/
code_block.rs

1//! Comment-aware reflow inside a `Region::Code`.
2//!
3//! The contract: take the raw `body` of a code block (between the fence
4//! lines), reflow any prose carried inside comments per the language's
5//! configured comment markers, and optionally pipe the result through an
6//! external formatter. Lines that are not comments pass through unchanged
7//! unless the formatter rewrites them.
8//!
9//! Indentation is preserved exactly. For a line that matches the
10//! `line_comment` marker, the leading whitespace + marker + (optional one
11//! space) are stripped, the remainder runs through the sentence splitter
12//! with `Format::Plaintext` semantics, and each output sentence is re-emitted
13//! with the original prefix.
14//!
15//! Block comments (`block_comment = ["open", "close"]`) treat the open and
16//! close marker lines verbatim and reflow the prose between as a single
17//! plaintext blob.
18//!
19//! The `// snapper:off` / `// snapper:on` pragma applies inside code blocks:
20//! lines between the markers (inclusive of the pragma lines) emit verbatim.
21//! The pragma matcher accepts the language's `line_comment` marker in
22//! addition to the format-specific prefixes already recognised by
23//! `parser::check_pragma`.
24
25use 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
35/// Wall-clock budget for the external formatter, in seconds.
36pub const FORMATTER_TIMEOUT_SECS: u64 = 30;
37
38/// Reflow the `body` of a `Region::Code`.
39///
40/// `cfg` carries the per-language marker configuration. `splitter` is the
41/// active sentence splitter (used for comment prose). When `format_code`
42/// is `true` and `cfg.formatter` is set, the post-comment-reflow body is
43/// piped through that formatter; failures degrade gracefully by returning
44/// the pre-formatter body and emitting a diagnostic on stderr.
45pub fn reflow_code_body(
46    lang: &str,
47    body: &str,
48    cfg: &CodeLang,
49    splitter: &dyn SentenceSplitter,
50    format_code: bool,
51) -> String {
52    // A grammar, where one exists, catches the comments the line-start rule
53    // below cannot see. Its output feeds the scanner unchanged: re-splitting
54    // a single sentence yields that sentence, so the second pass is a no-op
55    // on anything the first already shaped.
56    #[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
80/// Run the comment-reflow pass. Non-comment lines stay original slices;
81/// only comment spans are rewritten.
82fn 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                    // Unterminated: copy original slices through EOF.
162                    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/// Zero-based line numbers no comment pass may rewrite: the `snapper:off`
220/// and `snapper:on` pragma lines themselves, and everything between them.
221#[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
238/// Recognise the snapper pragma carried inside a code-block comment.
239/// Accepts the language's `line_comment` marker in addition to the
240/// format-specific prefixes already recognised by `parser::check_pragma`.
241fn 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
260/// Byte offset of a comment marker that follows code on the same line, or
261/// `None` when the line has no such marker outside a string.
262///
263/// Quote tracking is what separates `x = 1; // note` from
264/// `let s = "// not a comment";`. It is deliberately conservative: an
265/// unbalanced quote (a Rust lifetime, an apostrophe in a shell word) leaves
266/// the rest of the line looking quoted, so a marker after it is missed
267/// rather than mistaken.
268fn 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                    // A block comment on a code line is the scanner's blind
308                    // spot either way; leave the whole line alone.
309                    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
322/// Rewrite a line whose comment starts at `at`, keeping the first sentence
323/// beside the code and aligning the rest under the marker. Returns `None`
324/// when the comment holds a single sentence and the line stands as written.
325fn 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
361/// Byte offset of `close` on `line`, skipping matches that sit inside a
362/// string. When `close` is itself a quote sequence (`"""`, `'''`), the first
363/// match is the closer and quoting does not apply.
364fn 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
406/// Split a line at the first occurrence of `marker`. Returns
407/// `(indent, after_marker)` where `indent` is the leading whitespace
408/// preserved verbatim. Returns `None` if `marker` is not the first
409/// non-whitespace token.
410fn 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
416/// Strip a line-comment prefix from `line` if present. Returns
417/// `(indent, marker_as_written, body_after_marker_and_one_optional_space)`.
418///
419/// The marker returned is the one on the page, not the one in the config: a
420/// doc comment repeats or decorates the configured marker (`///`, `//!`,
421/// `;;;`), and re-emitting such a line under the short form would push the
422/// extra characters into the prose.
423fn 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        // `//!` is a marker; `#!` opening the first line of a shell block is
435        // a shebang, so only multi-character markers absorb the bang.
436        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    // Accept (but don't require) a single separating space; further leading
443    // whitespace is preserved as part of the prose so quoted code blocks like
444    // `//   code` round-trip.
445    let after = after.strip_prefix(' ').unwrap_or(after);
446    Some((indent, found, after))
447}
448
449/// Emit a same-line `/* ... */`-style comment as three lines:
450/// `indent + open\n + indent + " " + sentence\n... + indent + close\n`.
451/// Interior reflows as plaintext via the sentence splitter.
452fn 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
475/// Emit a multi-line block comment: open marker stays on its original line,
476/// interior reflows, close marker on its own line at `close_indent`.
477fn 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
501/// Pipe `body` through the formatter `argv` via stdin/stdout.
502/// Returns the formatter's stdout on success. Returns `Err(message)` on
503/// any failure mode (binary missing, non-zero exit, timeout, I/O); the
504/// caller is expected to log the message and fall back to the input.
505///
506/// The wait is implemented with a watchdog thread that calls `Child::kill`
507/// after `FORMATTER_TIMEOUT_SECS`. On the happy path the watchdog is
508/// signalled to exit via an mpsc channel and joins immediately.
509pub 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    // Write stdin in a worker thread so the parent can poll for timeout.
530    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            // stdin drops at end of scope, signalling EOF to the child.
535        });
536    }
537
538    // Watchdog: kill the child after FORMATTER_TIMEOUT_SECS unless told to stop.
539    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                // Normal completion path; nothing to do.
545            }
546            Err(mpsc::RecvTimeoutError::Timeout) => {
547                // Best-effort SIGKILL by PID. On unix this is a kill(2);
548                // we avoid pulling in nix and rely on the platform tool.
549                #[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    // Wait for the child. On timeout the watchdog SIGKILLs and `wait`
564    // returns with a non-zero status.
565    let output = child.wait_with_output();
566    // Signal the watchdog regardless of outcome so it joins promptly.
567    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/// SIGKILL via libc. The cross-platform stdlib has no `kill_by_pid`, but
590/// `libc::kill` is stable. We declare the extern manually to avoid a new
591/// always-on dependency.
592#[cfg(unix)]
593unsafe fn libc_kill(pid: i32) {
594    // `extern "C"` declarations are unsafe-by-association; we wrap the call.
595    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/// Read helper used in tests to capture formatter stdout. Exposed here so
605/// the integration tests can share the pattern without re-deriving it.
606#[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}