Skip to main content

coreutils_rs/pr/
core.rs

1use std::io::{self, BufRead, IoSlice, Write};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4/// Default page length in lines.
5pub const DEFAULT_PAGE_LENGTH: usize = 66;
6/// Default page width in columns.
7pub const DEFAULT_PAGE_WIDTH: usize = 72;
8/// Number of header lines (2 blank + 1 header + 2 blank).
9pub const HEADER_LINES: usize = 5;
10/// Number of footer lines (5 blank).
11pub const FOOTER_LINES: usize = 5;
12
13/// Configuration for the pr command.
14#[derive(Clone)]
15pub struct PrConfig {
16    /// First page to print (1-indexed).
17    pub first_page: usize,
18    /// Last page to print (0 = no limit).
19    pub last_page: usize,
20    /// Number of columns.
21    pub columns: usize,
22    /// Print columns across rather than down.
23    pub across: bool,
24    /// Show control characters in hat notation (^X).
25    pub show_control_chars: bool,
26    /// Double-space output.
27    pub double_space: bool,
28    /// Date format string for header.
29    pub date_format: String,
30    /// Expand input tabs to spaces (char, width).
31    pub expand_tabs: Option<(char, usize)>,
32    /// Use form feeds instead of newlines for page breaks.
33    pub form_feed: bool,
34    /// Custom header string (replaces filename).
35    pub header: Option<String>,
36    /// Replace spaces with tabs in output (char, width).
37    pub output_tabs: Option<(char, usize)>,
38    /// Join lines (do not truncate lines when using columns).
39    pub join_lines: bool,
40    /// Page length in lines (including header/footer).
41    pub page_length: usize,
42    /// Merge multiple files side by side.
43    pub merge: bool,
44    /// Number lines: (separator_char, digits).
45    pub number_lines: Option<(char, usize)>,
46    /// First line number.
47    pub first_line_number: usize,
48    /// Indent (offset) each line by this many spaces.
49    pub indent: usize,
50    /// Suppress file-not-found warnings.
51    pub no_file_warnings: bool,
52    /// Column separator character.
53    pub separator: Option<char>,
54    /// Column separator string.
55    pub sep_string: Option<String>,
56    /// Omit header and trailer.
57    pub omit_header: bool,
58    /// Omit header, trailer, and form feeds.
59    pub omit_pagination: bool,
60    /// Show nonprinting characters.
61    pub show_nonprinting: bool,
62    /// Page width.
63    pub page_width: usize,
64    /// Truncate lines to page width (-W).
65    pub truncate_lines: bool,
66}
67
68impl Default for PrConfig {
69    fn default() -> Self {
70        Self {
71            first_page: 1,
72            last_page: 0,
73            columns: 1,
74            across: false,
75            show_control_chars: false,
76            double_space: false,
77            date_format: "%Y-%m-%d %H:%M".to_string(),
78            expand_tabs: None,
79            form_feed: false,
80            header: None,
81            output_tabs: None,
82            join_lines: false,
83            page_length: DEFAULT_PAGE_LENGTH,
84            merge: false,
85            number_lines: None,
86            first_line_number: 1,
87            indent: 0,
88            no_file_warnings: false,
89            separator: None,
90            sep_string: None,
91            omit_header: false,
92            omit_pagination: false,
93            show_nonprinting: false,
94            page_width: DEFAULT_PAGE_WIDTH,
95            truncate_lines: false,
96        }
97    }
98}
99
100/// Format a SystemTime as a date string using libc strftime.
101fn format_header_date(time: &SystemTime, format: &str) -> String {
102    let secs = time
103        .duration_since(UNIX_EPOCH)
104        .unwrap_or_default()
105        .as_secs() as i64;
106    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
107    unsafe {
108        libc::localtime_r(&secs, &mut tm);
109    }
110
111    // Use strftime via libc
112    let c_format = std::ffi::CString::new(format).unwrap_or_default();
113    let mut buf = vec![0u8; 256];
114    let len = unsafe {
115        libc::strftime(
116            buf.as_mut_ptr() as *mut libc::c_char,
117            buf.len(),
118            c_format.as_ptr(),
119            &tm,
120        )
121    };
122    if len == 0 {
123        return String::new();
124    }
125    buf.truncate(len);
126    String::from_utf8_lossy(&buf).into_owned()
127}
128
129/// Expand tabs in a line to spaces.
130fn expand_tabs_in_line(line: &str, tab_char: char, tab_width: usize) -> String {
131    if tab_width == 0 {
132        return line.replace(tab_char, "");
133    }
134    // Pre-allocate with extra capacity for tab expansion
135    let mut result = String::with_capacity(line.len() + line.len() / 4);
136    let tab_byte = tab_char as u8;
137    let bytes = line.as_bytes();
138    let mut col = 0;
139    let mut seg_start = 0;
140
141    for (i, &b) in bytes.iter().enumerate() {
142        if b == tab_byte {
143            // Copy segment before tab
144            if i > seg_start {
145                result.push_str(&line[seg_start..i]);
146                col += i - seg_start;
147            }
148            let spaces = tab_width - (col % tab_width);
149            // Batch push spaces
150            let space_buf = "                                ";
151            let mut remaining = spaces;
152            while remaining > 0 {
153                let chunk = remaining.min(space_buf.len());
154                result.push_str(&space_buf[..chunk]);
155                remaining -= chunk;
156            }
157            col += spaces;
158            seg_start = i + 1;
159        }
160    }
161    // Copy remaining segment after last tab
162    if seg_start < bytes.len() {
163        result.push_str(&line[seg_start..]);
164    }
165    result
166}
167
168/// Push hat notation (^X) for a control character into a String, avoiding allocation.
169#[inline]
170fn push_hat_notation(result: &mut String, ch: char) {
171    let b = ch as u32;
172    if b < 32 {
173        result.push('^');
174        result.push((b as u8 + b'@') as char);
175    } else if b == 127 {
176        result.push_str("^?");
177    } else {
178        result.push(ch);
179    }
180}
181
182/// Push nonprinting notation (like cat -v) for a character into a String.
183#[inline]
184fn push_nonprinting(result: &mut String, ch: char) {
185    let b = ch as u32;
186    if b < 32 && b != 9 && b != 10 {
187        result.push('^');
188        result.push((b as u8 + b'@') as char);
189    } else if b == 127 {
190        result.push_str("^?");
191    } else if b >= 128 && b < 160 {
192        result.push_str("M-^");
193        result.push((b as u8 - 128 + b'@') as char);
194    } else if b >= 160 && b < 255 {
195        result.push_str("M-");
196        result.push((b as u8 - 128) as char);
197    } else if b == 255 {
198        result.push_str("M-^?");
199    } else {
200        result.push(ch);
201    }
202}
203
204/// Process a line for control char display.
205fn process_control_chars(line: &str, show_control: bool, show_nonprinting: bool) -> String {
206    if !show_control && !show_nonprinting {
207        return line.to_string();
208    }
209    let mut result = String::with_capacity(line.len() + line.len() / 4);
210    for ch in line.chars() {
211        if show_nonprinting {
212            push_nonprinting(&mut result, ch);
213        } else if show_control {
214            push_hat_notation(&mut result, ch);
215        } else {
216            result.push(ch);
217        }
218    }
219    result
220}
221
222/// Get the column separator to use.
223fn get_column_separator(config: &PrConfig) -> String {
224    if let Some(ref s) = config.sep_string {
225        s.clone()
226    } else if let Some(c) = config.separator {
227        c.to_string()
228    } else {
229        " ".to_string()
230    }
231}
232
233/// Check if the user has explicitly set a column separator.
234fn has_explicit_separator(config: &PrConfig) -> bool {
235    config.sep_string.is_some() || config.separator.is_some()
236}
237
238/// Write tab-based padding from an absolute position on the line to a target absolute position.
239/// GNU pr pads columns using tab characters (8-space tab stops) to reach the column boundary.
240/// `abs_pos` is the current absolute position on the line.
241/// `target_abs_pos` is the target absolute position.
242/// Static spaces buffer for padding without allocation.
243const SPACES: [u8; 256] = [b' '; 256];
244
245/// Write `n` spaces to output using the static SPACES buffer.
246#[inline]
247fn write_spaces<W: Write>(output: &mut W, n: usize) -> io::Result<()> {
248    let mut remaining = n;
249    while remaining > 0 {
250        let chunk = remaining.min(SPACES.len());
251        output.write_all(&SPACES[..chunk])?;
252        remaining -= chunk;
253    }
254    Ok(())
255}
256
257fn write_column_padding<W: Write>(
258    output: &mut W,
259    abs_pos: usize,
260    target_abs_pos: usize,
261) -> io::Result<()> {
262    // GNU pr uses tabs (8-stop) + trailing spaces to reach column boundary.
263    // When the gap can be filled entirely with spaces fewer than one tab
264    // stop away, just emit spaces (matching GNU's encoder).
265    let n = target_abs_pos.saturating_sub(abs_pos);
266    if n == 0 {
267        return Ok(());
268    }
269    let next_tab = (abs_pos / 8 + 1) * 8;
270    if next_tab > target_abs_pos {
271        // Gap doesn't reach next tab stop → spaces only
272        return write_spaces(output, n);
273    }
274    // Tab to the first tab stop, then continue
275    output.write_all(b"\t")?;
276    let mut col = next_tab;
277    while col < target_abs_pos {
278        let nt = (col / 8 + 1) * 8;
279        if nt <= target_abs_pos {
280            output.write_all(b"\t")?;
281            col = nt;
282        } else {
283            write_spaces(output, target_abs_pos - col)?;
284            col = target_abs_pos;
285        }
286    }
287    Ok(())
288}
289
290/// Paginate raw byte data — fast path that avoids per-line String allocation.
291/// When no tab expansion or control char processing is needed, lines are
292/// extracted as byte slices directly from the input buffer (zero-copy).
293pub fn pr_data<W: Write>(
294    data: &[u8],
295    output: &mut W,
296    config: &PrConfig,
297    filename: &str,
298    file_date: Option<SystemTime>,
299) -> io::Result<()> {
300    let needs_transform =
301        config.expand_tabs.is_some() || config.show_control_chars || config.show_nonprinting;
302
303    if needs_transform {
304        // Fall back to the String-based path for transforms
305        let reader = io::Cursor::new(data);
306        return pr_file(reader, output, config, filename, file_date);
307    }
308
309    // Ultra-fast path: single column, no per-line transforms → contiguous chunk writes
310    // Instead of splitting into individual lines and writing each one, we index newline
311    // positions and write entire page bodies as contiguous slices of the original data.
312    let is_simple = config.columns <= 1
313        && config.number_lines.is_none()
314        && config.indent == 0
315        && !config.truncate_lines
316        && !config.double_space
317        && !config.across
318        && memchr::memchr(b'\r', data).is_none()
319        && memchr::memchr(b'\x0c', data).is_none();
320
321    if is_simple {
322        // Passthrough: -T (omit_pagination) with no transforms and no page range → output == input.
323        // -t (omit_header) alone does NOT qualify because page bodies are still separated
324        // by blank lines; only -T eliminates all inter-page structure.
325        if config.omit_pagination && config.first_page == 1 && config.last_page == 0 {
326            return output.write_all(data);
327        }
328        return pr_data_contiguous(data, output, config, filename, file_date);
329    }
330
331    // Fast path: single column with numbering only (no indent, no truncate, no double-space)
332    if config.columns <= 1
333        && config.number_lines.is_some()
334        && config.indent == 0
335        && !config.truncate_lines
336        && !config.double_space
337        && memchr::memchr(b'\r', data).is_none()
338        && memchr::memchr(b'\x0c', data).is_none()
339    {
340        return pr_data_numbered(data, output, config, filename, file_date);
341    }
342
343    // Fast path: multi-column down mode without numbering, no transforms.
344    // Guards: ≤32 columns (fixed-size array), ≤4GB data (u32 offsets).
345    if config.columns > 1
346        && config.columns <= 32
347        && !config.across
348        && config.number_lines.is_none()
349        && config.indent == 0
350        && !config.double_space
351        && !config.join_lines
352        && data.len() <= u32::MAX as usize
353        && memchr::memchr(b'\r', data).is_none()
354        && memchr::memchr(b'\x0c', data).is_none()
355    {
356        return pr_data_multicolumn_fast(data, output, config, filename, file_date);
357    }
358
359    // Normal path: split into line byte slices using SIMD memchr
360    let mut lines: Vec<&[u8]> = Vec::with_capacity(data.len() / 40 + 64);
361    let mut start = 0;
362    for pos in memchr::memchr_iter(b'\n', data) {
363        let end = if pos > start && data[pos - 1] == b'\r' {
364            pos - 1
365        } else {
366            pos
367        };
368        lines.push(&data[start..end]);
369        start = pos + 1;
370    }
371    // Handle last line without trailing newline
372    if start < data.len() {
373        let end = if data.last() == Some(&b'\r') {
374            data.len() - 1
375        } else {
376            data.len()
377        };
378        lines.push(&data[start..end]);
379    }
380
381    pr_lines_generic(&lines, output, config, filename, file_date)
382}
383
384/// Fast multi-column "down" paginator using unsafe pointer arithmetic.
385/// Pre-splits lines via SIMD memchr, pre-computes column padding, and builds
386/// output directly into a Vec<u8> buffer with bulk copies. Avoids the many
387/// small extend_from_slice calls of the generic multicolumn path.
388fn pr_data_multicolumn_fast<W: Write>(
389    data: &[u8],
390    output: &mut W,
391    config: &PrConfig,
392    filename: &str,
393    file_date: Option<SystemTime>,
394) -> io::Result<()> {
395    let date = file_date.unwrap_or_else(SystemTime::now);
396    let header_str = config.header.as_deref().unwrap_or(filename);
397    let date_str = format_header_date(&date, &config.date_format);
398
399    let columns = config.columns.max(1);
400    let explicit_sep = has_explicit_separator(config);
401    let col_sep = get_column_separator(config);
402    let col_sep_bytes = col_sep.as_bytes();
403    let col_width = if explicit_sep {
404        if columns > 1 {
405            (config
406                .page_width
407                .saturating_sub(col_sep.len() * (columns - 1)))
408                / columns
409        } else {
410            config.page_width
411        }
412    } else {
413        config.page_width / columns
414    };
415    let content_width = if explicit_sep {
416        col_width
417    } else {
418        col_width.saturating_sub(1)
419    };
420
421    let suppress_header = !config.omit_header
422        && !config.omit_pagination
423        && config.page_length <= HEADER_LINES + FOOTER_LINES;
424    let body_lines_per_page = if config.omit_header || config.omit_pagination {
425        if config.page_length > 0 {
426            config.page_length
427        } else {
428            DEFAULT_PAGE_LENGTH
429        }
430    } else if suppress_header {
431        config.page_length
432    } else {
433        config.page_length - HEADER_LINES - FOOTER_LINES
434    };
435    let show_header = !config.omit_header && !config.omit_pagination && !suppress_header;
436    let lines_consumed_per_page = body_lines_per_page * columns;
437
438    // Pre-split lines using SIMD memchr — store (start, len) as u32 pairs for compactness
439    let mut line_offsets: Vec<(u32, u32)> = Vec::with_capacity(data.len() / 40 + 64);
440    let mut start = 0usize;
441    for pos in memchr::memchr_iter(b'\n', data) {
442        line_offsets.push((start as u32, (pos - start) as u32));
443        start = pos + 1;
444    }
445    if start < data.len() {
446        line_offsets.push((start as u32, (data.len() - start) as u32));
447    }
448    let total_lines = line_offsets.len();
449
450    // Single output buffer
451    let out_cap = (data.len() + data.len() / 3 + 4096).min(64 * 1024 * 1024);
452    let mut out_buf: Vec<u8> = Vec::with_capacity(out_cap);
453
454    let mut line_idx = 0usize;
455    let mut page_num = 1usize;
456    let src = data.as_ptr();
457
458    while line_idx < total_lines || (line_idx == 0 && total_lines == 0) {
459        if total_lines == 0 {
460            if show_header
461                && page_num >= config.first_page
462                && (config.last_page == 0 || page_num <= config.last_page)
463            {
464                write_header(&mut out_buf, &date_str, header_str, page_num, config)?;
465                write_footer(&mut out_buf, config)?;
466            }
467            break;
468        }
469
470        let page_end = (line_idx + lines_consumed_per_page).min(total_lines);
471        let page_lines = &line_offsets[line_idx..page_end];
472        let n = page_lines.len();
473
474        if page_num >= config.first_page && (config.last_page == 0 || page_num <= config.last_page)
475        {
476            if show_header {
477                write_header(&mut out_buf, &date_str, header_str, page_num, config)?;
478            }
479
480            // Compute column layout: "down" mode distributes lines across columns
481            let base = n / columns;
482            let extra = n % columns;
483            // Guard ensures columns <= 32, so col_starts[columns] is always in bounds.
484            let mut col_starts = [0u32; 33];
485            let max_cols = columns;
486            for col in 0..max_cols {
487                let col_lines = base + if col < extra { 1 } else { 0 };
488                col_starts[col + 1] = col_starts[col] + col_lines as u32;
489            }
490            let num_rows = if extra > 0 { base + 1 } else { base };
491
492            // Build rows directly
493            for row in 0..num_rows {
494                // Find last column with data for this row
495                let mut last_data_col = 0;
496                for col in 0..max_cols {
497                    let col_lines = (col_starts[col + 1] - col_starts[col]) as usize;
498                    if row < col_lines {
499                        last_data_col = col;
500                    }
501                }
502
503                let mut abs_pos = 0usize;
504                for col in 0..max_cols {
505                    let col_lines = (col_starts[col + 1] - col_starts[col]) as usize;
506                    if row < col_lines {
507                        let li = col_starts[col] as usize + row;
508                        let (off, len) = page_lines[li];
509                        let content_len = (len as usize).min(content_width);
510
511                        if explicit_sep && col > 0 {
512                            out_buf.extend_from_slice(col_sep_bytes);
513                            abs_pos += col_sep_bytes.len();
514                        }
515
516                        // Copy line content directly from source.
517                        // SAFETY: `src` is `data.as_ptr()` and `out_buf` is a separate
518                        // allocation, so reallocating `out_buf` cannot invalidate `src`.
519                        // `off` and `len` come from `line_offsets` built by iterating
520                        // `memchr_iter` over `data`, guaranteeing `off + len <= data.len()`.
521                        // `content_len = len.min(content_width) <= len`, so
522                        // `off as usize + content_len <= data.len()`. The u32→usize cast
523                        // is lossless because the guard ensures `data.len() <= u32::MAX`.
524                        if content_len > 0 {
525                            let wp = out_buf.len();
526                            out_buf.reserve(content_len);
527                            unsafe {
528                                std::ptr::copy_nonoverlapping(
529                                    src.add(off as usize),
530                                    out_buf.as_mut_ptr().add(wp),
531                                    content_len,
532                                );
533                                out_buf.set_len(wp + content_len);
534                            }
535                        }
536
537                        // Strip trailing spaces from last data column (GNU compat)
538                        if col == last_data_col && !explicit_sep {
539                            while out_buf.last() == Some(&b' ') {
540                                out_buf.pop();
541                            }
542                        }
543
544                        // abs_pos tracks the column alignment position. The trailing
545                        // space strip only fires on last_data_col, after which no more
546                        // padding is needed, so the divergence is harmless.
547                        abs_pos += content_len;
548
549                        // Pad to next column boundary
550                        if col < last_data_col && !explicit_sep {
551                            let target = (col + 1) * col_width;
552                            if abs_pos < target {
553                                write_column_padding_buf(&mut out_buf, abs_pos, target);
554                                abs_pos = target;
555                            }
556                        }
557                    } else if col <= last_data_col {
558                        // Empty column before last data column
559                        if explicit_sep {
560                            if col > 0 {
561                                out_buf.extend_from_slice(col_sep_bytes);
562                                abs_pos += col_sep_bytes.len();
563                            }
564                        } else {
565                            let target = (col + 1) * col_width;
566                            write_column_padding_buf(&mut out_buf, abs_pos, target);
567                            abs_pos = target;
568                        }
569                    }
570                }
571                out_buf.push(b'\n');
572            }
573
574            // Pad remaining body lines
575            let body_lines_written = num_rows;
576            if show_header {
577                let pad = body_lines_per_page.saturating_sub(body_lines_written);
578                out_buf.resize(out_buf.len() + pad, b'\n');
579                write_footer(&mut out_buf, config)?;
580            }
581
582            if out_buf.len() >= 64 * 1024 * 1024 {
583                output.write_all(&out_buf)?;
584                out_buf.clear();
585            }
586        }
587
588        line_idx = page_end;
589        page_num += 1;
590    }
591
592    if !out_buf.is_empty() {
593        output.write_all(&out_buf)?;
594    }
595    Ok(())
596}
597
598/// Write column padding (tabs + spaces) directly into a Vec<u8> buffer.
599/// Avoids the Write trait overhead of write_column_padding.
600#[inline]
601fn write_column_padding_buf(buf: &mut Vec<u8>, abs_pos: usize, target: usize) {
602    let n = target.saturating_sub(abs_pos);
603    if n == 0 {
604        return;
605    }
606    let next_tab = (abs_pos / 8 + 1) * 8;
607    if next_tab > target {
608        // Gap doesn't reach next tab stop — spaces only
609        buf.resize(buf.len() + n, b' ');
610        return;
611    }
612    buf.push(b'\t');
613    let mut col = next_tab;
614    while col < target {
615        let nt = (col / 8 + 1) * 8;
616        if nt <= target {
617            buf.push(b'\t');
618            col = nt;
619        } else {
620            buf.resize(buf.len() + (target - col), b' ');
621            col = target;
622        }
623    }
624}
625
626/// Ultra-fast contiguous-write paginator for single-column, no-transform mode.
627/// Two-pass approach: find page boundaries via SIMD memchr, then build IoSlice
628/// references interleaving metadata (headers/footers) with zero-copy body slices
629/// from the original mmap data. Single writev call at the end.
630fn pr_data_contiguous<W: Write>(
631    data: &[u8],
632    output: &mut W,
633    config: &PrConfig,
634    filename: &str,
635    file_date: Option<SystemTime>,
636) -> io::Result<()> {
637    let date = file_date.unwrap_or_else(SystemTime::now);
638    let header_str = config.header.as_deref().unwrap_or(filename);
639    let date_str = format_header_date(&date, &config.date_format);
640
641    let suppress_header = !config.omit_header
642        && !config.omit_pagination
643        && config.page_length <= HEADER_LINES + FOOTER_LINES;
644    let body_lines_per_page = if config.omit_header || config.omit_pagination {
645        if config.page_length > 0 {
646            config.page_length
647        } else {
648            DEFAULT_PAGE_LENGTH
649        }
650    } else if suppress_header {
651        config.page_length
652    } else {
653        config.page_length - HEADER_LINES - FOOTER_LINES
654    };
655    let show_header = !config.omit_header && !config.omit_pagination && !suppress_header;
656
657    if data.is_empty() {
658        if show_header {
659            let mut page_buf: Vec<u8> = Vec::with_capacity(256);
660            write_header(&mut page_buf, &date_str, header_str, 1, config)?;
661            write_footer(&mut page_buf, config)?;
662            output.write_all(&page_buf)?;
663        }
664        return Ok(());
665    }
666
667    // Pass 1: find page boundaries (byte offsets where each page starts).
668    // Note: scans full file; early-exit for page ranges is a future optimization.
669    let est_pages = data.len() / (body_lines_per_page * 40) + 2;
670    let mut page_bounds: Vec<usize> = Vec::with_capacity(est_pages + 1);
671    page_bounds.push(0);
672    let mut lines_count = 0usize;
673    for nl_pos in memchr::memchr_iter(b'\n', data) {
674        lines_count += 1;
675        if lines_count >= body_lines_per_page {
676            page_bounds.push(nl_pos + 1);
677            lines_count = 0;
678        }
679    }
680    if *page_bounds.last().unwrap() < data.len() {
681        page_bounds.push(data.len());
682    }
683    let total_pages = page_bounds.len() - 1;
684
685    let first_visible = config.first_page.max(1) - 1;
686    let last_visible = if config.last_page == 0 {
687        total_pages
688    } else {
689        config.last_page.min(total_pages)
690    };
691    if first_visible >= total_pages {
692        return Ok(());
693    }
694
695    if !show_header {
696        // No headers/footers — write visible body pages contiguously
697        let start = page_bounds[first_visible];
698        let end = page_bounds[last_visible];
699        return output.write_all(&data[start..end]);
700    }
701
702    // Pass 2: build metadata buffer for all visible pages
703    let visible_count = last_visible - first_visible;
704    let header_est = visible_count * 200;
705    let mut meta_buf: Vec<u8> = Vec::with_capacity(header_est);
706    // (header_start, header_end, footer_start, footer_end) in meta_buf
707    let mut meta_ranges: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(visible_count);
708
709    for pi in first_visible..last_visible {
710        let page_num = pi + 1;
711        let body_start = page_bounds[pi];
712        let body_end = page_bounds[pi + 1];
713
714        let hdr_start = meta_buf.len();
715        write_header(&mut meta_buf, &date_str, header_str, page_num, config)?;
716        let hdr_end = meta_buf.len();
717
718        let ft_start = meta_buf.len();
719        let body_slice = &data[body_start..body_end];
720        // Note: re-counts newlines already seen in Pass 1; avoiding this would
721        // require carrying per-page line counts from the boundary scan above.
722        let actual_lines = memchr::memchr_iter(b'\n', body_slice).count();
723        let has_unterminated = !body_slice.is_empty() && body_slice[body_slice.len() - 1] != b'\n';
724        if has_unterminated {
725            meta_buf.push(b'\n');
726        }
727        let effective_lines = actual_lines + has_unterminated as usize;
728        let pad = body_lines_per_page.saturating_sub(effective_lines);
729        meta_buf.resize(meta_buf.len() + pad, b'\n');
730        write_footer(&mut meta_buf, config)?;
731        let ft_end = meta_buf.len();
732
733        meta_ranges.push((hdr_start, hdr_end, ft_start, ft_end));
734    }
735
736    // Pass 3: build IoSlice array interleaving metadata and zero-copy body slices
737    let mut slices: Vec<IoSlice<'_>> = Vec::with_capacity(visible_count * 3);
738    for (i, &(hs, he, fs, fe)) in meta_ranges.iter().enumerate() {
739        let pi = first_visible + i;
740        if he > hs {
741            slices.push(IoSlice::new(&meta_buf[hs..he]));
742        }
743        let body_start = page_bounds[pi];
744        let body_end = page_bounds[pi + 1];
745        if body_end > body_start {
746            slices.push(IoSlice::new(&data[body_start..body_end]));
747        }
748        if fe > fs {
749            slices.push(IoSlice::new(&meta_buf[fs..fe]));
750        }
751    }
752
753    write_all_ioslices(output, &slices)
754}
755
756/// Write all IoSlices, handling partial writes by advancing through slices.
757/// Attempts write_vectored on the original borrowed slices first; only clones
758/// into a mutable Vec on partial write (needed for IoSlice::advance_slices).
759fn write_all_ioslices<W: Write>(out: &mut W, slices: &[IoSlice<'_>]) -> io::Result<()> {
760    // Try writing all slices at once without cloning.
761    let written = out.write_vectored(slices)?;
762    if written == 0 && !slices.is_empty() {
763        return Err(io::Error::new(io::ErrorKind::WriteZero, "write zero"));
764    }
765    // Check if everything was written (common case).
766    let total: usize = slices.iter().map(|s| s.len()).sum();
767    if written >= total {
768        return Ok(());
769    }
770    // Partial write: clone into mutable Vec and advance.
771    let mut bufs = slices.to_vec();
772    let mut remaining: &mut [IoSlice<'_>] = &mut bufs;
773    IoSlice::advance_slices(&mut remaining, written);
774    while !remaining.is_empty() {
775        let n = out.write_vectored(remaining)?;
776        if n == 0 {
777            return Err(io::Error::new(io::ErrorKind::WriteZero, "write zero"));
778        }
779        IoSlice::advance_slices(&mut remaining, n);
780    }
781    Ok(())
782}
783
784/// Fast numbered single-column paginator.
785/// Uses unsafe pointer arithmetic to format numbered lines directly into
786/// a pre-allocated buffer, avoiding per-line write_all overhead.
787fn pr_data_numbered<W: Write>(
788    data: &[u8],
789    output: &mut W,
790    config: &PrConfig,
791    filename: &str,
792    file_date: Option<SystemTime>,
793) -> io::Result<()> {
794    let date = file_date.unwrap_or_else(SystemTime::now);
795    let header_str = config.header.as_deref().unwrap_or(filename);
796    let date_str = format_header_date(&date, &config.date_format);
797
798    let (sep_char, digits) = config.number_lines.unwrap_or(('\t', 5));
799    debug_assert!(sep_char.is_ascii(), "number separator must be ASCII");
800    let sep_byte = sep_char as u8;
801    let suppress_header = !config.omit_header
802        && !config.omit_pagination
803        && config.page_length <= HEADER_LINES + FOOTER_LINES;
804    let body_lines_per_page = if config.omit_header || config.omit_pagination {
805        if config.page_length > 0 {
806            config.page_length
807        } else {
808            DEFAULT_PAGE_LENGTH
809        }
810    } else if suppress_header {
811        config.page_length
812    } else {
813        config.page_length - HEADER_LINES - FOOTER_LINES
814    };
815    let show_header = !config.omit_header && !config.omit_pagination && !suppress_header;
816
817    // Pre-split lines using SIMD memchr for fast iteration
818    let mut line_starts: Vec<usize> = Vec::with_capacity(data.len() / 40 + 64);
819    line_starts.push(0);
820    for pos in memchr::memchr_iter(b'\n', data) {
821        line_starts.push(pos + 1);
822    }
823    let total_lines = if !data.is_empty() && data[data.len() - 1] == b'\n' {
824        line_starts.len() - 1
825    } else {
826        line_starts.len()
827    };
828
829    // Single output buffer — avoids per-page write_all syscalls.
830    // Initial capacity capped at 64MB; Vec grows on demand for larger inputs.
831    let num_prefix_est = digits + 2; // padding + digits + separator
832    let out_cap =
833        (data.len() + total_lines * num_prefix_est + total_lines / 5 + 4096).min(64 * 1024 * 1024);
834    let mut out_buf: Vec<u8> = Vec::with_capacity(out_cap);
835
836    let mut line_number = config.first_line_number;
837    let mut page_num = 1usize;
838    let mut line_idx = 0;
839
840    while line_idx < total_lines {
841        let page_end = (line_idx + body_lines_per_page).min(total_lines);
842        let in_range = page_num >= config.first_page
843            && (config.last_page == 0 || page_num <= config.last_page);
844
845        if in_range {
846            if show_header {
847                write_header(&mut out_buf, &date_str, header_str, page_num, config)?;
848            }
849
850            // Write numbered lines using unsafe pointer arithmetic.
851            // SAFETY: `src` points into `data` (a borrowed &[u8]) and is
852            // independent of `out_buf`. Reallocations of `out_buf` cannot
853            // invalidate `src` because they are separate allocations.
854            let src = data.as_ptr();
855            for li in line_idx..page_end {
856                let line_start = line_starts[li];
857                let line_end = if li + 1 < line_starts.len() {
858                    let end = line_starts[li + 1] - 1;
859                    if end > line_start && data[end - 1] == b'\r' {
860                        end - 1
861                    } else {
862                        end
863                    }
864                } else {
865                    data.len()
866                };
867                let line_len = line_end - line_start;
868
869                let wp = out_buf.len();
870
871                let mut n = line_number;
872                let mut num_pos = 19usize;
873                let mut num_tmp = [0u8; 20];
874                loop {
875                    num_tmp[num_pos] = b'0' + (n % 10) as u8;
876                    n /= 10;
877                    if n == 0 || num_pos == 0 {
878                        break;
879                    }
880                    num_pos -= 1;
881                }
882                let num_digits = 20 - num_pos;
883                let padding = digits.saturating_sub(num_digits);
884                let actual_prefix = padding + num_digits + 1;
885
886                let needed = actual_prefix + line_len + 1;
887                if out_buf.len() + needed > out_buf.capacity() {
888                    out_buf.reserve(needed);
889                }
890                let base = out_buf.as_mut_ptr();
891
892                unsafe {
893                    let dst = base.add(wp);
894                    std::ptr::write_bytes(dst, b' ', padding);
895                    std::ptr::copy_nonoverlapping(
896                        num_tmp.as_ptr().add(num_pos),
897                        dst.add(padding),
898                        num_digits,
899                    );
900                    *dst.add(padding + num_digits) = sep_byte;
901                    if line_len > 0 {
902                        std::ptr::copy_nonoverlapping(
903                            src.add(line_start),
904                            dst.add(actual_prefix),
905                            line_len,
906                        );
907                    }
908                    *dst.add(actual_prefix + line_len) = b'\n';
909                    out_buf.set_len(wp + actual_prefix + line_len + 1);
910                }
911
912                line_number += 1;
913            }
914
915            if show_header {
916                let body_lines_written = page_end - line_idx;
917                let pad = body_lines_per_page.saturating_sub(body_lines_written);
918                out_buf.resize(out_buf.len() + pad, b'\n');
919                write_footer(&mut out_buf, config)?;
920            }
921
922            // Flush to writer when buffer exceeds 64MB to bound memory usage.
923            if out_buf.len() >= 64 * 1024 * 1024 {
924                output.write_all(&out_buf)?;
925                out_buf.clear();
926            }
927        } else {
928            line_number += page_end - line_idx;
929        }
930
931        line_idx = page_end;
932        page_num += 1;
933    }
934
935    if !out_buf.is_empty() {
936        output.write_all(&out_buf)?;
937    }
938    Ok(())
939}
940
941/// Paginate a single file and write output.
942pub fn pr_file<R: BufRead, W: Write>(
943    input: R,
944    output: &mut W,
945    config: &PrConfig,
946    filename: &str,
947    file_date: Option<SystemTime>,
948) -> io::Result<()> {
949    // Read all lines with transforms applied
950    let mut all_lines: Vec<String> = Vec::new();
951    for line_result in input.lines() {
952        let line = line_result?;
953        let mut line = line;
954
955        // Expand tabs if requested
956        if let Some((tab_char, tab_width)) = config.expand_tabs {
957            line = expand_tabs_in_line(&line, tab_char, tab_width);
958        }
959
960        // Process control characters (skip when not needed to avoid copying)
961        if config.show_control_chars || config.show_nonprinting {
962            line = process_control_chars(&line, config.show_control_chars, config.show_nonprinting);
963        }
964
965        all_lines.push(line);
966    }
967
968    // Convert to &[u8] slices for the byte-based paginator
969    let refs: Vec<&[u8]> = all_lines.iter().map(|s| s.as_bytes()).collect();
970    pr_lines_generic(&refs, output, config, filename, file_date)
971}
972
973/// Core paginator that works on a slice of byte slices (zero-copy).
974fn pr_lines_generic<W: Write>(
975    all_lines: &[&[u8]],
976    output: &mut W,
977    config: &PrConfig,
978    filename: &str,
979    file_date: Option<SystemTime>,
980) -> io::Result<()> {
981    let date = file_date.unwrap_or_else(SystemTime::now);
982
983    let header_str = config.header.as_deref().unwrap_or(filename);
984    let date_str = format_header_date(&date, &config.date_format);
985
986    // Calculate body lines per page
987    // When page_length is too small for header+footer, GNU pr suppresses
988    // headers/footers and uses page_length as the body size.
989    let suppress_header = !config.omit_header
990        && !config.omit_pagination
991        && config.page_length <= HEADER_LINES + FOOTER_LINES;
992    // When suppress_header is active, create a config view with omit_header set
993    // so that sub-functions skip padding to body_lines_per_page.
994    let suppressed_config;
995    let effective_config = if suppress_header {
996        suppressed_config = PrConfig {
997            omit_header: true,
998            ..config.clone()
999        };
1000        &suppressed_config
1001    } else {
1002        config
1003    };
1004    let body_lines_per_page = if config.omit_header || config.omit_pagination {
1005        if config.page_length > 0 {
1006            config.page_length
1007        } else {
1008            DEFAULT_PAGE_LENGTH
1009        }
1010    } else if suppress_header {
1011        config.page_length
1012    } else {
1013        config.page_length - HEADER_LINES - FOOTER_LINES
1014    };
1015
1016    // Account for double spacing: each input line takes 2 output lines
1017    let input_lines_per_page = if config.double_space {
1018        (body_lines_per_page + 1) / 2
1019    } else {
1020        body_lines_per_page
1021    };
1022
1023    // Handle multi-column mode
1024    let columns = config.columns.max(1);
1025
1026    // GNU pr in multi-column down mode: each page has body_lines_per_page rows,
1027    // each row shows one value from each column. So up to
1028    // input_lines_per_page * columns input lines can be consumed per page.
1029    // actual_lines_per_column = ceil(page_lines / columns) for each page.
1030    let lines_consumed_per_page = if columns > 1 && !config.across {
1031        input_lines_per_page * columns
1032    } else {
1033        input_lines_per_page
1034    };
1035
1036    // Single output buffer for all pages — one write_all at the end
1037    let total_lines = all_lines.len();
1038    let mut line_number = config.first_line_number;
1039    let mut page_num = 1usize;
1040    let mut line_idx = 0;
1041    let total_bytes: usize = all_lines.iter().map(|l| l.len() + 1).sum();
1042    // Initial capacity capped at 64MB; Vec grows on demand for larger inputs.
1043    let out_cap = (total_bytes + total_bytes / 5 + 4096).min(64 * 1024 * 1024);
1044    let mut out_buf: Vec<u8> = Vec::with_capacity(out_cap);
1045
1046    while line_idx < total_lines || (line_idx == 0 && total_lines == 0) {
1047        if total_lines == 0 && line_idx == 0 {
1048            if page_num >= config.first_page
1049                && (config.last_page == 0 || page_num <= config.last_page)
1050            {
1051                if !config.omit_header && !config.omit_pagination && !suppress_header {
1052                    write_header(&mut out_buf, &date_str, header_str, page_num, config)?;
1053                    write_footer(&mut out_buf, config)?;
1054                }
1055            }
1056            break;
1057        }
1058
1059        let page_end = (line_idx + lines_consumed_per_page).min(total_lines);
1060
1061        if page_num >= config.first_page && (config.last_page == 0 || page_num <= config.last_page)
1062        {
1063            if !config.omit_header && !config.omit_pagination && !suppress_header {
1064                write_header(&mut out_buf, &date_str, header_str, page_num, config)?;
1065            }
1066
1067            if columns > 1 {
1068                write_multicolumn_body(
1069                    &mut out_buf,
1070                    &all_lines[line_idx..page_end],
1071                    effective_config,
1072                    columns,
1073                    &mut line_number,
1074                    body_lines_per_page,
1075                )?;
1076            } else {
1077                write_single_column_body(
1078                    &mut out_buf,
1079                    &all_lines[line_idx..page_end],
1080                    effective_config,
1081                    &mut line_number,
1082                    body_lines_per_page,
1083                )?;
1084            }
1085
1086            if !config.omit_header && !config.omit_pagination && !suppress_header {
1087                write_footer(&mut out_buf, config)?;
1088            }
1089
1090            // Flush to writer when buffer exceeds 64MB to bound memory usage.
1091            if out_buf.len() >= 64 * 1024 * 1024 {
1092                output.write_all(&out_buf)?;
1093                out_buf.clear();
1094            }
1095        }
1096
1097        line_idx = page_end;
1098        page_num += 1;
1099
1100        if line_idx >= total_lines {
1101            break;
1102        }
1103    }
1104
1105    if !out_buf.is_empty() {
1106        output.write_all(&out_buf)?;
1107    }
1108    Ok(())
1109}
1110
1111/// Paginate multiple files merged side by side (-m mode).
1112pub fn pr_merge<W: Write>(
1113    inputs: &[Vec<String>],
1114    output: &mut W,
1115    config: &PrConfig,
1116    _filenames: &[&str],
1117    file_dates: &[SystemTime],
1118) -> io::Result<()> {
1119    let date = file_dates.first().copied().unwrap_or_else(SystemTime::now);
1120    let date_str = format_header_date(&date, &config.date_format);
1121    let header_str = config.header.as_deref().unwrap_or("");
1122
1123    let suppress_header = !config.omit_header
1124        && !config.omit_pagination
1125        && config.page_length <= HEADER_LINES + FOOTER_LINES;
1126    let body_lines_per_page = if config.omit_header || config.omit_pagination {
1127        if config.page_length > 0 {
1128            config.page_length
1129        } else {
1130            DEFAULT_PAGE_LENGTH
1131        }
1132    } else if suppress_header {
1133        config.page_length
1134    } else {
1135        config.page_length - HEADER_LINES - FOOTER_LINES
1136    };
1137
1138    let input_lines_per_page = if config.double_space {
1139        (body_lines_per_page + 1) / 2
1140    } else {
1141        body_lines_per_page
1142    };
1143
1144    let num_files = inputs.len();
1145    let explicit_sep = has_explicit_separator(config);
1146    let col_sep = get_column_separator(config);
1147    let col_width = if explicit_sep {
1148        if num_files > 1 {
1149            (config
1150                .page_width
1151                .saturating_sub(col_sep.len() * (num_files - 1)))
1152                / num_files
1153        } else {
1154            config.page_width
1155        }
1156    } else {
1157        config.page_width / num_files
1158    };
1159
1160    let max_lines = inputs.iter().map(|f| f.len()).max().unwrap_or(0);
1161    let mut page_num = 1usize;
1162    let mut line_idx = 0;
1163    let mut line_number = config.first_line_number;
1164
1165    let col_sep_bytes = col_sep.as_bytes();
1166    let mut page_buf: Vec<u8> = Vec::with_capacity(128 * 1024);
1167    let mut num_buf = [0u8; 32];
1168
1169    while line_idx < max_lines {
1170        let page_end = (line_idx + input_lines_per_page).min(max_lines);
1171
1172        if page_num >= config.first_page && (config.last_page == 0 || page_num <= config.last_page)
1173        {
1174            page_buf.clear();
1175
1176            if !config.omit_header && !config.omit_pagination && !suppress_header {
1177                write_header(&mut page_buf, &date_str, header_str, page_num, config)?;
1178            }
1179
1180            let indent_str = " ".repeat(config.indent);
1181            let mut body_lines_written = 0;
1182            for i in line_idx..page_end {
1183                if config.double_space && body_lines_written > 0 {
1184                    page_buf.push(b'\n');
1185                    body_lines_written += 1;
1186                }
1187
1188                page_buf.extend_from_slice(indent_str.as_bytes());
1189                let mut abs_pos = config.indent;
1190
1191                if let Some((sep, digits)) = config.number_lines {
1192                    let num_str = format_line_number(line_number, sep, digits, &mut num_buf);
1193                    page_buf.extend_from_slice(num_str);
1194                    abs_pos += digits + 1;
1195                    line_number += 1;
1196                }
1197
1198                for (fi, file_lines) in inputs.iter().enumerate() {
1199                    let content = if i < file_lines.len() {
1200                        file_lines[i].as_bytes()
1201                    } else {
1202                        b"" as &[u8]
1203                    };
1204                    let truncated = if !explicit_sep && content.len() > col_width.saturating_sub(1)
1205                    {
1206                        &content[..col_width.saturating_sub(1)]
1207                    } else if explicit_sep && config.truncate_lines && content.len() > col_width {
1208                        &content[..col_width]
1209                    } else {
1210                        content
1211                    };
1212                    if fi < num_files - 1 {
1213                        if explicit_sep {
1214                            if fi > 0 {
1215                                page_buf.extend_from_slice(col_sep_bytes);
1216                            }
1217                            page_buf.extend_from_slice(truncated);
1218                            abs_pos +=
1219                                truncated.len() + if fi > 0 { col_sep_bytes.len() } else { 0 };
1220                        } else {
1221                            page_buf.extend_from_slice(truncated);
1222                            abs_pos += truncated.len();
1223                            let target = (fi + 1) * col_width + config.indent;
1224                            write_column_padding(&mut page_buf, abs_pos, target)?;
1225                            abs_pos = target;
1226                        }
1227                    } else {
1228                        if explicit_sep && fi > 0 {
1229                            page_buf.extend_from_slice(col_sep_bytes);
1230                        }
1231                        page_buf.extend_from_slice(truncated);
1232                    }
1233                }
1234                page_buf.push(b'\n');
1235                body_lines_written += 1;
1236            }
1237
1238            // Pad remaining body lines
1239            while body_lines_written < body_lines_per_page {
1240                page_buf.push(b'\n');
1241                body_lines_written += 1;
1242            }
1243
1244            if !config.omit_header && !config.omit_pagination && !suppress_header {
1245                write_footer(&mut page_buf, config)?;
1246            }
1247
1248            output.write_all(&page_buf)?;
1249        }
1250
1251        line_idx = page_end;
1252        page_num += 1;
1253    }
1254
1255    Ok(())
1256}
1257
1258/// Write page header: 2 blank lines, date/header/page line, 2 blank lines.
1259fn write_header<W: Write>(
1260    output: &mut W,
1261    date_str: &str,
1262    header: &str,
1263    page_num: usize,
1264    config: &PrConfig,
1265) -> io::Result<()> {
1266    // 2 blank lines
1267    output.write_all(b"\n\n")?;
1268
1269    // Header line: date is left-aligned, header is centered, Page N is right-aligned.
1270    let line_width = config.page_width;
1271
1272    let left = date_str;
1273    let center = header;
1274    let left_len = left.len();
1275    let center_len = center.len();
1276
1277    // Format "Page N" without allocation for small page numbers
1278    let mut page_buf = [0u8; 32];
1279    let page_str = format_page_number(page_num, &mut page_buf);
1280    let right_len = page_str.len();
1281
1282    // GNU pr centers the header title within the line.
1283    if left_len + center_len + right_len + 2 >= line_width {
1284        output.write_all(left.as_bytes())?;
1285        output.write_all(b" ")?;
1286        output.write_all(center.as_bytes())?;
1287        output.write_all(b" ")?;
1288        output.write_all(page_str)?;
1289        output.write_all(b"\n")?;
1290    } else {
1291        let total_spaces = line_width - left_len - center_len - right_len;
1292        let left_spaces = total_spaces / 2;
1293        let right_spaces = total_spaces - left_spaces;
1294        output.write_all(left.as_bytes())?;
1295        write_spaces(output, left_spaces)?;
1296        output.write_all(center.as_bytes())?;
1297        write_spaces(output, right_spaces)?;
1298        output.write_all(page_str)?;
1299        output.write_all(b"\n")?;
1300    }
1301
1302    // 2 blank lines
1303    output.write_all(b"\n\n")?;
1304
1305    Ok(())
1306}
1307
1308/// Format "Page N" into a stack buffer, returning the used slice.
1309#[inline]
1310fn format_page_number(page_num: usize, buf: &mut [u8; 32]) -> &[u8] {
1311    const PREFIX: &[u8] = b"Page ";
1312    let prefix_len = PREFIX.len();
1313    buf[..prefix_len].copy_from_slice(PREFIX);
1314    // Format number into a separate stack buffer to avoid overlapping borrow
1315    let mut num_buf = [0u8; 20];
1316    let mut n = page_num;
1317    let mut pos = 19;
1318    loop {
1319        num_buf[pos] = b'0' + (n % 10) as u8;
1320        n /= 10;
1321        if n == 0 {
1322            break;
1323        }
1324        pos -= 1;
1325    }
1326    let num_len = 20 - pos;
1327    buf[prefix_len..prefix_len + num_len].copy_from_slice(&num_buf[pos..20]);
1328    &buf[..prefix_len + num_len]
1329}
1330
1331/// Write page footer: 5 blank lines (or form feed).
1332fn write_footer<W: Write>(output: &mut W, config: &PrConfig) -> io::Result<()> {
1333    if config.form_feed {
1334        output.write_all(b"\x0c")?;
1335    } else {
1336        output.write_all(b"\n\n\n\n\n")?;
1337    }
1338    Ok(())
1339}
1340
1341/// Write body for single column mode.
1342fn write_single_column_body<W: Write>(
1343    output: &mut W,
1344    lines: &[&[u8]],
1345    config: &PrConfig,
1346    line_number: &mut usize,
1347    body_lines_per_page: usize,
1348) -> io::Result<()> {
1349    let indent_str = " ".repeat(config.indent);
1350    let content_width = if config.truncate_lines {
1351        compute_content_width(config)
1352    } else {
1353        0
1354    };
1355    let mut body_lines_written = 0;
1356    // Pre-allocate line number buffer to avoid per-line write! formatting
1357    let mut num_buf = [0u8; 32];
1358
1359    for line in lines.iter() {
1360        output.write_all(indent_str.as_bytes())?;
1361
1362        if let Some((sep, digits)) = config.number_lines {
1363            // Format line number directly into buffer, avoiding write! overhead
1364            let num_str = format_line_number(*line_number, sep, digits, &mut num_buf);
1365            output.write_all(num_str)?;
1366            *line_number += 1;
1367        }
1368
1369        let content: &[u8] = if config.truncate_lines {
1370            if line.len() > content_width {
1371                &line[..content_width]
1372            } else {
1373                line
1374            }
1375        } else {
1376            line
1377        };
1378
1379        // Direct write_all of byte slice — no format dispatch or UTF-8 overhead
1380        output.write_all(content)?;
1381        output.write_all(b"\n")?;
1382        body_lines_written += 1;
1383        if body_lines_written >= body_lines_per_page {
1384            break;
1385        }
1386
1387        // Double-space: write blank line AFTER each content line
1388        if config.double_space {
1389            output.write_all(b"\n")?;
1390            body_lines_written += 1;
1391            if body_lines_written >= body_lines_per_page {
1392                break;
1393            }
1394        }
1395    }
1396
1397    // Pad remaining body lines if not omitting headers
1398    if !config.omit_header && !config.omit_pagination {
1399        while body_lines_written < body_lines_per_page {
1400            output.write_all(b"\n")?;
1401            body_lines_written += 1;
1402        }
1403    }
1404
1405    Ok(())
1406}
1407
1408/// Format a line number with right-aligned padding and separator into a stack buffer.
1409/// Returns the formatted slice. Avoids write!() per-line overhead.
1410#[inline]
1411fn format_line_number(num: usize, sep: char, digits: usize, buf: &mut [u8; 32]) -> &[u8] {
1412    // Format the number
1413    let mut n = num;
1414    let mut pos = 31;
1415    loop {
1416        buf[pos] = b'0' + (n % 10) as u8;
1417        n /= 10;
1418        if n == 0 || pos == 0 {
1419            break;
1420        }
1421        pos -= 1;
1422    }
1423    let num_digits = 32 - pos;
1424    // Build the output: spaces for padding + number + separator
1425    let padding = if digits > num_digits {
1426        digits - num_digits
1427    } else {
1428        0
1429    };
1430    let total_len = padding + num_digits + sep.len_utf8();
1431    // We need a separate output buffer since we're using buf for the number
1432    // Just use the write_all approach with two calls for simplicity
1433    let start = 32 - num_digits;
1434    // Return just the number portion; caller handles padding via spaces
1435    // Actually, let's format properly into a contiguous buffer
1436    let sep_byte = sep as u8; // ASCII separator assumed
1437    let out_start = 32usize.saturating_sub(total_len);
1438    // Fill padding
1439    for i in out_start..out_start + padding {
1440        buf[i] = b' ';
1441    }
1442    // Number is already at positions [start..32], shift if needed
1443    if out_start + padding != start {
1444        let src = start;
1445        let dst = out_start + padding;
1446        for i in 0..num_digits {
1447            buf[dst + i] = buf[src + i];
1448        }
1449    }
1450    // Add separator
1451    buf[out_start + padding + num_digits] = sep_byte;
1452    &buf[out_start..out_start + total_len]
1453}
1454
1455/// Compute available content width after accounting for numbering and indent.
1456fn compute_content_width(config: &PrConfig) -> usize {
1457    let mut w = config.page_width;
1458    w = w.saturating_sub(config.indent);
1459    if let Some((_, digits)) = config.number_lines {
1460        w = w.saturating_sub(digits + 1); // digits + separator
1461    }
1462    w
1463}
1464
1465/// Write body for multi-column mode.
1466fn write_multicolumn_body<W: Write>(
1467    output: &mut W,
1468    lines: &[&[u8]],
1469    config: &PrConfig,
1470    columns: usize,
1471    line_number: &mut usize,
1472    body_lines_per_page: usize,
1473) -> io::Result<()> {
1474    let explicit_sep = has_explicit_separator(config);
1475    let col_sep = get_column_separator(config);
1476    // When no explicit separator, GNU pr uses the full page_width / columns as column width
1477    // and pads with tabs. When separator is explicit, use sep width in calculation.
1478    let col_width = if explicit_sep {
1479        if columns > 1 {
1480            (config
1481                .page_width
1482                .saturating_sub(col_sep.len() * (columns - 1)))
1483                / columns
1484        } else {
1485            config.page_width
1486        }
1487    } else {
1488        config.page_width / columns
1489    };
1490    // GNU pr truncates lines in multi-column mode by default, unless -J (join_lines) is set.
1491    // For non-explicit separator, truncate to col_width - 1 to leave room for padding.
1492    let do_truncate = !config.join_lines;
1493    let content_width = if explicit_sep {
1494        col_width
1495    } else {
1496        col_width.saturating_sub(1)
1497    };
1498
1499    let indent_str = " ".repeat(config.indent);
1500    let col_sep_bytes = col_sep.as_bytes();
1501    let mut body_lines_written = 0;
1502    let mut num_buf = [0u8; 32];
1503
1504    if config.across {
1505        // Print columns across: line 0 fills col0, line 1 fills col1, etc.
1506        let mut i = 0;
1507        while i < lines.len() {
1508            if config.double_space && body_lines_written > 0 {
1509                output.write_all(b"\n")?;
1510                body_lines_written += 1;
1511                if body_lines_written >= body_lines_per_page {
1512                    break;
1513                }
1514            }
1515
1516            output.write_all(indent_str.as_bytes())?;
1517            let mut abs_pos = config.indent;
1518
1519            // Find the last column with data on this row
1520            let mut last_data_col = 0;
1521            for col in 0..columns {
1522                let li = i + col;
1523                if li < lines.len() {
1524                    last_data_col = col;
1525                }
1526            }
1527
1528            for col in 0..columns {
1529                let li = i + col;
1530                if li < lines.len() {
1531                    if explicit_sep && col > 0 {
1532                        output.write_all(col_sep_bytes)?;
1533                        abs_pos += col_sep_bytes.len();
1534                    }
1535                    if let Some((sep, digits)) = config.number_lines {
1536                        let num_str = format_line_number(*line_number, sep, digits, &mut num_buf);
1537                        output.write_all(num_str)?;
1538                        abs_pos += digits + 1;
1539                        *line_number += 1;
1540                    }
1541                    let content: &[u8] = lines[li];
1542                    let mut truncated = if do_truncate && content.len() > content_width {
1543                        &content[..content_width]
1544                    } else {
1545                        content
1546                    };
1547                    // GNU pr strips trailing spaces from the last column
1548                    if col == last_data_col && !explicit_sep {
1549                        while truncated.last() == Some(&b' ') {
1550                            truncated = &truncated[..truncated.len() - 1];
1551                        }
1552                    }
1553                    output.write_all(truncated)?;
1554                    abs_pos += truncated.len();
1555                    if col < last_data_col && !explicit_sep {
1556                        let target = (col + 1) * col_width + config.indent;
1557                        write_column_padding(output, abs_pos, target)?;
1558                        abs_pos = target;
1559                    }
1560                }
1561            }
1562            output.write_all(b"\n")?;
1563            body_lines_written += 1;
1564            i += columns;
1565        }
1566    } else {
1567        // Print columns down: distribute lines across columns.
1568        // GNU pr distributes evenly: base = lines/cols, extra = lines%cols.
1569        // First 'extra' columns get base+1 lines, rest get base lines.
1570        let n = lines.len();
1571        let base = n / columns;
1572        let extra = n % columns;
1573
1574        // Compute start offset of each column
1575        let mut col_starts = vec![0usize; columns + 1];
1576        for col in 0..columns {
1577            let col_lines = base + if col < extra { 1 } else { 0 };
1578            col_starts[col + 1] = col_starts[col] + col_lines;
1579        }
1580
1581        // Number of rows = max lines in any column
1582        let num_rows = if extra > 0 { base + 1 } else { base };
1583
1584        for row in 0..num_rows {
1585            if config.double_space && row > 0 {
1586                output.write_all(b"\n")?;
1587                body_lines_written += 1;
1588                if body_lines_written >= body_lines_per_page {
1589                    break;
1590                }
1591            }
1592
1593            output.write_all(indent_str.as_bytes())?;
1594            let mut abs_pos = config.indent;
1595
1596            // Find the last column with data for this row
1597            let mut last_data_col = 0;
1598            for col in 0..columns {
1599                let col_lines = col_starts[col + 1] - col_starts[col];
1600                if row < col_lines {
1601                    last_data_col = col;
1602                }
1603            }
1604
1605            for col in 0..columns {
1606                let col_lines = col_starts[col + 1] - col_starts[col];
1607                let li = col_starts[col] + row;
1608                if row < col_lines {
1609                    if explicit_sep && col > 0 {
1610                        output.write_all(col_sep_bytes)?;
1611                        abs_pos += col_sep_bytes.len();
1612                    }
1613                    if let Some((sep, digits)) = config.number_lines {
1614                        let num = config.first_line_number + li;
1615                        let num_str = format_line_number(num, sep, digits, &mut num_buf);
1616                        output.write_all(num_str)?;
1617                        abs_pos += digits + 1;
1618                    }
1619                    let content: &[u8] = lines[li];
1620                    let mut truncated = if do_truncate && content.len() > content_width {
1621                        &content[..content_width]
1622                    } else {
1623                        content
1624                    };
1625                    // GNU pr strips trailing spaces from the last column
1626                    if col == last_data_col && !explicit_sep {
1627                        while truncated.last() == Some(&b' ') {
1628                            truncated = &truncated[..truncated.len() - 1];
1629                        }
1630                    }
1631                    output.write_all(truncated)?;
1632                    abs_pos += truncated.len();
1633                    if col < last_data_col && !explicit_sep {
1634                        // Not the last column with data: pad to next column boundary
1635                        let target = (col + 1) * col_width + config.indent;
1636                        write_column_padding(output, abs_pos, target)?;
1637                        abs_pos = target;
1638                    }
1639                } else if col <= last_data_col {
1640                    // Empty column before the last data column: pad to next boundary
1641                    if explicit_sep {
1642                        if col > 0 {
1643                            output.write_all(col_sep_bytes)?;
1644                            abs_pos += col_sep_bytes.len();
1645                        }
1646                        // For explicit separator, just write separator, no padding
1647                    } else {
1648                        let target = (col + 1) * col_width + config.indent;
1649                        write_column_padding(output, abs_pos, target)?;
1650                        abs_pos = target;
1651                    }
1652                }
1653                // Empty columns after last data column: skip entirely
1654            }
1655            output.write_all(b"\n")?;
1656            body_lines_written += 1;
1657        }
1658        // Update line_number for the lines we processed
1659        if config.number_lines.is_some() {
1660            *line_number += lines.len();
1661        }
1662    }
1663
1664    // Pad remaining body lines
1665    if !config.omit_header && !config.omit_pagination {
1666        while body_lines_written < body_lines_per_page {
1667            output.write_all(b"\n")?;
1668            body_lines_written += 1;
1669        }
1670    }
1671
1672    Ok(())
1673}