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