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    body: &str,
47    cfg: &CodeLang,
48    splitter: &dyn SentenceSplitter,
49    format_code: bool,
50) -> String {
51    let after_comment_reflow = reflow_comments(body, cfg, splitter);
52    if format_code {
53        if let Some(ref argv) = cfg.formatter {
54            match run_formatter(&after_comment_reflow, argv) {
55                Ok(out) => return out,
56                Err(diag) => {
57                    eprintln!("snapper: {diag}");
58                    return after_comment_reflow;
59                }
60            }
61        }
62    }
63    after_comment_reflow
64}
65
66/// Run the comment-reflow pass. Pure function; no I/O.
67fn reflow_comments(body: &str, cfg: &CodeLang, splitter: &dyn SentenceSplitter) -> String {
68    let mut out = String::with_capacity(body.len());
69    let mut iter = body.lines().peekable();
70    let mut pragma_off = false;
71    // Track whether the original body ended with a trailing newline so we
72    // can reproduce it byte-identically.
73    let trailing_newline = body.ends_with('\n');
74
75    while let Some(line) = iter.next() {
76        // Pragma check first; lines between off/on are verbatim.
77        if let Some(on) = check_pragma_for(line, cfg) {
78            pragma_off = !on;
79            out.push_str(line);
80            out.push('\n');
81            continue;
82        }
83        if pragma_off {
84            out.push_str(line);
85            out.push('\n');
86            continue;
87        }
88
89        // Try block-comment open. If matched, accumulate to the close marker
90        // and reflow the interior as plaintext.
91        if let Some(ref pair) = cfg.block_comment {
92            let [open, close] = [pair[0].as_str(), pair[1].as_str()];
93            if !open.is_empty() {
94                if let Some((indent, after_open)) = split_at_marker(line, open) {
95                    // Same-line open + close (e.g. `/* one sentence. */`)?
96                    let trimmed_after = after_open.trim_start();
97                    if !close.is_empty() {
98                        if let Some(idx) = trimmed_after.find(close) {
99                            let interior = &trimmed_after[..idx];
100                            // Emit: indent + open\n + reflowed interior\n + indent + close\n
101                            emit_block_comment(
102                                &mut out,
103                                indent,
104                                open,
105                                close,
106                                interior,
107                                splitter,
108                            );
109                            continue;
110                        }
111                    }
112                    // Multi-line block comment: gather body until close marker.
113                    let mut interior = after_open.to_string();
114                    let mut close_indent: Option<String> = None;
115                    let mut closed = false;
116                    for next in iter.by_ref() {
117                        if let Some(idx) = next.find(close) {
118                            // Found close. Anything before it (on this line)
119                            // joins the interior; the close marker stays on
120                            // its own emitted line at its original indent.
121                            let pre = &next[..idx];
122                            let pre_trim = pre.trim();
123                            if !pre_trim.is_empty() {
124                                if !interior.is_empty() && !interior.ends_with(' ') {
125                                    interior.push(' ');
126                                }
127                                interior.push_str(pre_trim);
128                            }
129                            close_indent = Some(
130                                next[..next.len() - next.trim_start().len()].to_string(),
131                            );
132                            closed = true;
133                            break;
134                        }
135                        let stripped = next.trim_start();
136                        // Strip a leading `*` decoration commonly used in
137                        // C/Java/JS doc comments, plus one optional space.
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 closed {
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                        continue;
159                    }
160                    // Unterminated block comment: emit interior we accumulated
161                    // and bail out (best-effort; keep input shape).
162                    out.push_str(line);
163                    out.push('\n');
164                    if !interior.is_empty() {
165                        out.push_str(interior.trim_end());
166                        out.push('\n');
167                    }
168                    continue;
169                }
170            }
171        }
172
173        // Line-comment reflow.
174        if let Some(ref marker) = cfg.line_comment {
175            if let Some((indent, rest)) = strip_line_comment(line, marker) {
176                let prose = rest.trim();
177                if prose.is_empty() {
178                    out.push_str(line);
179                    out.push('\n');
180                    continue;
181                }
182                // If this comment line is the pragma itself we already handled
183                // it above. Reflow as plaintext.
184                let sentences = splitter.split(prose);
185                if sentences.is_empty() {
186                    out.push_str(line);
187                    out.push('\n');
188                    continue;
189                }
190                for s in &sentences {
191                    out.push_str(indent);
192                    out.push_str(marker);
193                    out.push(' ');
194                    out.push_str(s);
195                    out.push('\n');
196                }
197                continue;
198            }
199        }
200
201        // Non-comment line: passthrough.
202        out.push_str(line);
203        out.push('\n');
204    }
205
206    // Reproduce trailing-newline shape of the input.
207    if !trailing_newline && out.ends_with('\n') {
208        out.pop();
209    }
210    out
211}
212
213/// Recognise the snapper pragma carried inside a code-block comment.
214/// Accepts the language's `line_comment` marker in addition to the
215/// format-specific prefixes already recognised by `parser::check_pragma`.
216fn check_pragma_for(line: &str, cfg: &CodeLang) -> Option<bool> {
217    if let Some(b) = check_pragma(line) {
218        return Some(b);
219    }
220    let trimmed = line.trim();
221    if let Some(ref marker) = cfg.line_comment {
222        if let Some(rest) = trimmed.strip_prefix(marker.as_str()) {
223            let rest = rest.trim();
224            if rest == "snapper:off" {
225                return Some(false);
226            }
227            if rest == "snapper:on" {
228                return Some(true);
229            }
230        }
231    }
232    None
233}
234
235/// Split a line at the first occurrence of `marker`. Returns
236/// `(indent, after_marker)` where `indent` is the leading whitespace
237/// preserved verbatim. Returns `None` if `marker` is not the first
238/// non-whitespace token.
239fn split_at_marker<'a>(line: &'a str, marker: &str) -> Option<(&'a str, &'a str)> {
240    let leading = line.len() - line.trim_start().len();
241    let (indent, rest) = line.split_at(leading);
242    rest.strip_prefix(marker).map(|after| (indent, after))
243}
244
245/// Strip a line-comment prefix from `line` if present. Returns
246/// `(indent, body_after_marker_and_one_optional_space)`.
247fn strip_line_comment<'a>(line: &'a str, marker: &str) -> Option<(&'a str, &'a str)> {
248    let leading = line.len() - line.trim_start().len();
249    let (indent, rest) = line.split_at(leading);
250    let after = rest.strip_prefix(marker)?;
251    // Accept (but don't require) a single separating space; further leading
252    // whitespace is preserved as part of the prose so quoted code blocks like
253    // `//   code` round-trip.
254    let after = after.strip_prefix(' ').unwrap_or(after);
255    Some((indent, after))
256}
257
258/// Emit a same-line `/* ... */`-style comment as three lines:
259/// `indent + open\n + indent + " " + sentence\n... + indent + close\n`.
260/// Interior reflows as plaintext via the sentence splitter.
261fn emit_block_comment(
262    out: &mut String,
263    indent: &str,
264    open: &str,
265    close: &str,
266    interior: &str,
267    splitter: &dyn SentenceSplitter,
268) {
269    out.push_str(indent);
270    out.push_str(open);
271    out.push('\n');
272    let sentences = splitter.split(interior.trim());
273    for s in &sentences {
274        out.push_str(indent);
275        out.push(' ');
276        out.push_str(s);
277        out.push('\n');
278    }
279    out.push_str(indent);
280    out.push_str(close);
281    out.push('\n');
282}
283
284/// Emit a multi-line block comment: open marker stays on its original line,
285/// interior reflows, close marker on its own line at `close_indent`.
286fn emit_block_comment_multi(
287    out: &mut String,
288    indent: &str,
289    open: &str,
290    close: &str,
291    close_indent: &str,
292    interior: &str,
293    splitter: &dyn SentenceSplitter,
294) {
295    out.push_str(indent);
296    out.push_str(open);
297    out.push('\n');
298    let sentences = splitter.split(interior);
299    for s in &sentences {
300        out.push_str(indent);
301        out.push(' ');
302        out.push_str(s);
303        out.push('\n');
304    }
305    out.push_str(close_indent);
306    out.push_str(close);
307    out.push('\n');
308}
309
310/// Pipe `body` through the formatter `argv` via stdin/stdout.
311/// Returns the formatter's stdout on success. Returns `Err(message)` on
312/// any failure mode (binary missing, non-zero exit, timeout, I/O); the
313/// caller is expected to log the message and fall back to the input.
314///
315/// The wait is implemented with a watchdog thread that calls `Child::kill`
316/// after `FORMATTER_TIMEOUT_SECS`. On the happy path the watchdog is
317/// signalled to exit via an mpsc channel and joins immediately.
318pub fn run_formatter(body: &str, argv: &[String]) -> Result<String, String> {
319    if argv.is_empty() {
320        return Err("formatter argv is empty".to_string());
321    }
322    let mut cmd = Command::new(&argv[0]);
323    cmd.args(&argv[1..])
324        .stdin(Stdio::piped())
325        .stdout(Stdio::piped())
326        .stderr(Stdio::piped());
327
328    let mut child = match cmd.spawn() {
329        Ok(c) => c,
330        Err(e) => {
331            if e.kind() == std::io::ErrorKind::NotFound {
332                return Err(format!("formatter not found: {}", argv[0]));
333            }
334            return Err(format!("formatter spawn failed: {}: {e}", argv[0]));
335        }
336    };
337
338    // Write stdin in a worker thread so the parent can poll for timeout.
339    if let Some(mut stdin) = child.stdin.take() {
340        let body_owned = body.to_string();
341        let _ = thread::spawn(move || {
342            let _ = stdin.write_all(body_owned.as_bytes());
343            // stdin drops at end of scope, signalling EOF to the child.
344        });
345    }
346
347    // Watchdog: kill the child after FORMATTER_TIMEOUT_SECS unless told to stop.
348    let (done_tx, done_rx) = mpsc::channel::<()>();
349    let child_id = child.id();
350    let watchdog = thread::spawn(move || {
351        match done_rx.recv_timeout(Duration::from_secs(FORMATTER_TIMEOUT_SECS)) {
352            Ok(_) | Err(mpsc::RecvTimeoutError::Disconnected) => {
353                // Normal completion path; nothing to do.
354            }
355            Err(mpsc::RecvTimeoutError::Timeout) => {
356                // Best-effort SIGKILL by PID. On unix this is a kill(2);
357                // we avoid pulling in nix and rely on the platform tool.
358                #[cfg(unix)]
359                unsafe {
360                    libc_kill(child_id as i32);
361                }
362                #[cfg(not(unix))]
363                {
364                    let _ = std::process::Command::new("taskkill")
365                        .args(["/F", "/PID", &child_id.to_string()])
366                        .output();
367                }
368            }
369        }
370    });
371
372    // Wait for the child. On timeout the watchdog SIGKILLs and `wait`
373    // returns with a non-zero status.
374    let output = child.wait_with_output();
375    // Signal the watchdog regardless of outcome so it joins promptly.
376    let _ = done_tx.send(());
377    let _ = watchdog.join();
378
379    let output = match output {
380        Ok(o) => o,
381        Err(e) => return Err(format!("formatter wait failed: {}: {e}", argv[0])),
382    };
383
384    if !output.status.success() {
385        let stderr = String::from_utf8_lossy(&output.stderr);
386        return Err(format!(
387            "formatter {} exited non-zero (status {:?}): {}",
388            argv[0],
389            output.status.code(),
390            stderr.trim()
391        ));
392    }
393
394    String::from_utf8(output.stdout)
395        .map_err(|e| format!("formatter {} produced non-UTF-8 output: {e}", argv[0]))
396}
397
398/// SIGKILL via libc. The cross-platform stdlib has no `kill_by_pid`, but
399/// `libc::kill` is stable. We declare the extern manually to avoid a new
400/// always-on dependency.
401#[cfg(unix)]
402unsafe fn libc_kill(pid: i32) {
403    // `extern "C"` declarations are unsafe-by-association; we wrap the call.
404    unsafe extern "C" {
405        fn kill(pid: i32, sig: i32) -> i32;
406    }
407    const SIGKILL: i32 = 9;
408    unsafe {
409        let _ = kill(pid, SIGKILL);
410    }
411}
412
413/// Read helper used in tests to capture formatter stdout. Exposed here so
414/// the integration tests can share the pattern without re-deriving it.
415#[doc(hidden)]
416pub fn read_to_string(mut r: impl Read) -> std::io::Result<String> {
417    let mut s = String::new();
418    r.read_to_string(&mut s)?;
419    Ok(s)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::sentence::unicode::UnicodeSentenceSplitter;
426
427    fn rust_cfg() -> CodeLang {
428        CodeLang {
429            line_comment: Some("//".to_string()),
430            block_comment: Some(["/*".to_string(), "*/".to_string()]),
431            formatter: None,
432        }
433    }
434
435    #[test]
436    fn line_comment_two_sentences_split() {
437        let body = "// First sentence. Second sentence.\nfn main() {}\n";
438        let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
439        assert_eq!(
440            out,
441            "// First sentence.\n// Second sentence.\nfn main() {}\n"
442        );
443    }
444
445    #[test]
446    fn indented_comment_preserved() {
447        let body = "    // First. Second.\n    fn x() {}\n";
448        let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
449        assert_eq!(out, "    // First.\n    // Second.\n    fn x() {}\n");
450    }
451
452    #[test]
453    fn non_comment_passes_through() {
454        let body = "fn main() { println!(\"hi\"); }\n";
455        let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
456        assert_eq!(out, body);
457    }
458
459    #[test]
460    fn block_comment_one_liner_splits() {
461        let body = "/* First. Second. */\n";
462        let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
463        assert_eq!(out, "/*\n First.\n Second.\n*/\n");
464    }
465
466    #[test]
467    fn pragma_freezes_run() {
468        let body = "// snapper:off\n// Long.\n// Off.\n// snapper:on\n// Reflow this. Now.\n";
469        let out = reflow_comments(body, &rust_cfg(), &UnicodeSentenceSplitter::new());
470        let expected = concat!(
471            "// snapper:off\n",
472            "// Long.\n",
473            "// Off.\n",
474            "// snapper:on\n",
475            "// Reflow this.\n",
476            "// Now.\n",
477        );
478        assert_eq!(out, expected);
479    }
480}