Skip to main content

coreutils_rs/pr/
core.rs

1use std::io::{self, BufRead, 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    let tab_size = 8;
263    let mut pos = abs_pos;
264    while pos < target_abs_pos {
265        let next_tab = ((pos / tab_size) + 1) * tab_size;
266        if next_tab <= target_abs_pos {
267            output.write_all(b"\t")?;
268            pos = next_tab;
269        } else {
270            let n = target_abs_pos - pos;
271            write_spaces(output, n)?;
272            pos = target_abs_pos;
273        }
274    }
275    Ok(())
276}
277
278/// Paginate raw byte data — fast path that avoids per-line String allocation.
279/// When no tab expansion or control char processing is needed, lines are
280/// extracted as &str slices directly from the input buffer (zero-copy).
281pub fn pr_data<W: Write>(
282    data: &[u8],
283    output: &mut W,
284    config: &PrConfig,
285    filename: &str,
286    file_date: Option<SystemTime>,
287) -> io::Result<()> {
288    let needs_transform =
289        config.expand_tabs.is_some() || config.show_control_chars || config.show_nonprinting;
290
291    if needs_transform {
292        // Fall back to the String-based path for transforms
293        let reader = io::Cursor::new(data);
294        return pr_file(reader, output, config, filename, file_date);
295    }
296
297    // Fast path: zero-copy line extraction from byte data
298    let text = String::from_utf8_lossy(data);
299    // Split into lines without trailing newlines (matching BufRead::lines behavior)
300    let all_lines: Vec<&str> = text
301        .split('\n')
302        .map(|l| l.strip_suffix('\r').unwrap_or(l))
303        .collect();
304    // Remove trailing empty line from final newline (matching lines() behavior)
305    let all_lines = if all_lines.last() == Some(&"") {
306        &all_lines[..all_lines.len() - 1]
307    } else {
308        &all_lines[..]
309    };
310
311    pr_lines_generic(all_lines, output, config, filename, file_date)
312}
313
314/// Paginate a single file and write output.
315pub fn pr_file<R: BufRead, W: Write>(
316    input: R,
317    output: &mut W,
318    config: &PrConfig,
319    filename: &str,
320    file_date: Option<SystemTime>,
321) -> io::Result<()> {
322    // Read all lines with transforms applied
323    let mut all_lines: Vec<String> = Vec::new();
324    for line_result in input.lines() {
325        let line = line_result?;
326        let mut line = line;
327
328        // Expand tabs if requested
329        if let Some((tab_char, tab_width)) = config.expand_tabs {
330            line = expand_tabs_in_line(&line, tab_char, tab_width);
331        }
332
333        // Process control characters (skip when not needed to avoid copying)
334        if config.show_control_chars || config.show_nonprinting {
335            line = process_control_chars(&line, config.show_control_chars, config.show_nonprinting);
336        }
337
338        all_lines.push(line);
339    }
340
341    // Convert to &str slice for the generic paginator
342    let refs: Vec<&str> = all_lines.iter().map(|s| s.as_str()).collect();
343    pr_lines_generic(&refs, output, config, filename, file_date)
344}
345
346/// Core paginator that works on a slice of string references.
347fn pr_lines_generic<W: Write>(
348    all_lines: &[&str],
349    output: &mut W,
350    config: &PrConfig,
351    filename: &str,
352    file_date: Option<SystemTime>,
353) -> io::Result<()> {
354    let date = file_date.unwrap_or_else(SystemTime::now);
355
356    let header_str = config.header.as_deref().unwrap_or(filename);
357    let date_str = format_header_date(&date, &config.date_format);
358
359    // Calculate body lines per page
360    // When page_length is too small for header+footer, GNU pr suppresses
361    // headers/footers and uses page_length as the body size.
362    let suppress_header = !config.omit_header
363        && !config.omit_pagination
364        && config.page_length <= HEADER_LINES + FOOTER_LINES;
365    // When suppress_header is active, create a config view with omit_header set
366    // so that sub-functions skip padding to body_lines_per_page.
367    let suppressed_config;
368    let effective_config = if suppress_header {
369        suppressed_config = PrConfig {
370            omit_header: true,
371            ..config.clone()
372        };
373        &suppressed_config
374    } else {
375        config
376    };
377    let body_lines_per_page = if config.omit_header || config.omit_pagination {
378        if config.page_length > 0 {
379            config.page_length
380        } else {
381            DEFAULT_PAGE_LENGTH
382        }
383    } else if suppress_header {
384        config.page_length
385    } else {
386        config.page_length - HEADER_LINES - FOOTER_LINES
387    };
388
389    // Account for double spacing: each input line takes 2 output lines
390    let input_lines_per_page = if config.double_space {
391        (body_lines_per_page + 1) / 2
392    } else {
393        body_lines_per_page
394    };
395
396    // Handle multi-column mode
397    let columns = config.columns.max(1);
398
399    // GNU pr in multi-column down mode: each page has body_lines_per_page rows,
400    // each row shows one value from each column. So up to
401    // input_lines_per_page * columns input lines can be consumed per page.
402    // actual_lines_per_column = ceil(page_lines / columns) for each page.
403    let lines_consumed_per_page = if columns > 1 && !config.across {
404        input_lines_per_page * columns
405    } else {
406        input_lines_per_page
407    };
408
409    // Split into pages
410    let total_lines = all_lines.len();
411    let mut line_number = config.first_line_number;
412    let mut page_num = 1usize;
413    let mut line_idx = 0;
414
415    while line_idx < total_lines || (line_idx == 0 && total_lines == 0) {
416        // For empty input, output one empty page (matching GNU behavior)
417        if total_lines == 0 && line_idx == 0 {
418            if page_num >= config.first_page
419                && (config.last_page == 0 || page_num <= config.last_page)
420            {
421                if !config.omit_header && !config.omit_pagination && !suppress_header {
422                    write_header(output, &date_str, header_str, page_num, config)?;
423                }
424                if !config.omit_header && !config.omit_pagination && !suppress_header {
425                    write_footer(output, config)?;
426                }
427            }
428            break;
429        }
430
431        let page_end = (line_idx + lines_consumed_per_page).min(total_lines);
432
433        if page_num >= config.first_page && (config.last_page == 0 || page_num <= config.last_page)
434        {
435            // Write header
436            if !config.omit_header && !config.omit_pagination && !suppress_header {
437                write_header(output, &date_str, header_str, page_num, config)?;
438            }
439
440            // Write body
441            if columns > 1 {
442                write_multicolumn_body(
443                    output,
444                    &all_lines[line_idx..page_end],
445                    effective_config,
446                    columns,
447                    &mut line_number,
448                    body_lines_per_page,
449                )?;
450            } else {
451                write_single_column_body(
452                    output,
453                    &all_lines[line_idx..page_end],
454                    effective_config,
455                    &mut line_number,
456                    body_lines_per_page,
457                )?;
458            }
459
460            // Write footer
461            if !config.omit_header && !config.omit_pagination && !suppress_header {
462                write_footer(output, config)?;
463            }
464        }
465
466        line_idx = page_end;
467        page_num += 1;
468
469        // Break if we've consumed all lines
470        if line_idx >= total_lines {
471            break;
472        }
473    }
474
475    Ok(())
476}
477
478/// Paginate multiple files merged side by side (-m mode).
479pub fn pr_merge<W: Write>(
480    inputs: &[Vec<String>],
481    output: &mut W,
482    config: &PrConfig,
483    _filenames: &[&str],
484    file_dates: &[SystemTime],
485) -> io::Result<()> {
486    let date = file_dates.first().copied().unwrap_or_else(SystemTime::now);
487    let date_str = format_header_date(&date, &config.date_format);
488    let header_str = config.header.as_deref().unwrap_or("");
489
490    let suppress_header = !config.omit_header
491        && !config.omit_pagination
492        && config.page_length <= HEADER_LINES + FOOTER_LINES;
493    let body_lines_per_page = if config.omit_header || config.omit_pagination {
494        if config.page_length > 0 {
495            config.page_length
496        } else {
497            DEFAULT_PAGE_LENGTH
498        }
499    } else if suppress_header {
500        config.page_length
501    } else {
502        config.page_length - HEADER_LINES - FOOTER_LINES
503    };
504
505    let input_lines_per_page = if config.double_space {
506        (body_lines_per_page + 1) / 2
507    } else {
508        body_lines_per_page
509    };
510
511    let num_files = inputs.len();
512    let explicit_sep = has_explicit_separator(config);
513    let col_sep = get_column_separator(config);
514    let col_width = if explicit_sep {
515        if num_files > 1 {
516            (config
517                .page_width
518                .saturating_sub(col_sep.len() * (num_files - 1)))
519                / num_files
520        } else {
521            config.page_width
522        }
523    } else {
524        config.page_width / num_files
525    };
526
527    let max_lines = inputs.iter().map(|f| f.len()).max().unwrap_or(0);
528    let mut page_num = 1usize;
529    let mut line_idx = 0;
530    let mut line_number = config.first_line_number;
531
532    while line_idx < max_lines {
533        let page_end = (line_idx + input_lines_per_page).min(max_lines);
534
535        if page_num >= config.first_page && (config.last_page == 0 || page_num <= config.last_page)
536        {
537            if !config.omit_header && !config.omit_pagination && !suppress_header {
538                write_header(output, &date_str, header_str, page_num, config)?;
539            }
540
541            let indent_str = " ".repeat(config.indent);
542            let mut body_lines_written = 0;
543            for i in line_idx..page_end {
544                if config.double_space && body_lines_written > 0 {
545                    writeln!(output)?;
546                    body_lines_written += 1;
547                }
548
549                output.write_all(indent_str.as_bytes())?;
550                let mut abs_pos = config.indent;
551
552                if let Some((sep, digits)) = config.number_lines {
553                    write!(output, "{:>width$}{}", line_number, sep, width = digits)?;
554                    abs_pos += digits + 1;
555                    line_number += 1;
556                }
557
558                for (fi, file_lines) in inputs.iter().enumerate() {
559                    let content = if i < file_lines.len() {
560                        &file_lines[i]
561                    } else {
562                        ""
563                    };
564                    let truncated = if !explicit_sep && content.len() > col_width.saturating_sub(1)
565                    {
566                        // Non-explicit separator: always truncate, leave room for separator
567                        &content[..col_width.saturating_sub(1)]
568                    } else if explicit_sep && config.truncate_lines && content.len() > col_width {
569                        // Explicit separator with -W: truncate to col_width
570                        &content[..col_width]
571                    } else {
572                        content
573                    };
574                    if fi < num_files - 1 {
575                        // Non-last column
576                        if explicit_sep {
577                            // GNU pr with explicit separator: no padding between columns
578                            if fi > 0 {
579                                write!(output, "{}", col_sep)?;
580                            }
581                            write!(output, "{}", truncated)?;
582                            abs_pos += truncated.len() + if fi > 0 { col_sep.len() } else { 0 };
583                        } else {
584                            write!(output, "{}", truncated)?;
585                            abs_pos += truncated.len();
586                            let target = (fi + 1) * col_width + config.indent;
587                            write_column_padding(output, abs_pos, target)?;
588                            abs_pos = target;
589                        }
590                    } else {
591                        // Last column: no padding
592                        if explicit_sep && fi > 0 {
593                            write!(output, "{}", col_sep)?;
594                        }
595                        write!(output, "{}", truncated)?;
596                    }
597                }
598                writeln!(output)?;
599                body_lines_written += 1;
600            }
601
602            // Pad remaining body lines
603            while body_lines_written < body_lines_per_page {
604                writeln!(output)?;
605                body_lines_written += 1;
606            }
607
608            if !config.omit_header && !config.omit_pagination && !suppress_header {
609                write_footer(output, config)?;
610            }
611        }
612
613        line_idx = page_end;
614        page_num += 1;
615    }
616
617    Ok(())
618}
619
620/// Write page header: 2 blank lines, date/header/page line, 2 blank lines.
621fn write_header<W: Write>(
622    output: &mut W,
623    date_str: &str,
624    header: &str,
625    page_num: usize,
626    config: &PrConfig,
627) -> io::Result<()> {
628    // 2 blank lines
629    output.write_all(b"\n\n")?;
630
631    // Header line: date is left-aligned, header is centered, Page N is right-aligned.
632    let line_width = config.page_width;
633
634    let left = date_str;
635    let center = header;
636    let left_len = left.len();
637    let center_len = center.len();
638
639    // Format "Page N" without allocation for small page numbers
640    let mut page_buf = [0u8; 32];
641    let page_str = format_page_number(page_num, &mut page_buf);
642    let right_len = page_str.len();
643
644    // GNU pr centers the header title within the line.
645    if left_len + center_len + right_len + 2 >= line_width {
646        output.write_all(left.as_bytes())?;
647        output.write_all(b" ")?;
648        output.write_all(center.as_bytes())?;
649        output.write_all(b" ")?;
650        output.write_all(page_str)?;
651        output.write_all(b"\n")?;
652    } else {
653        let total_spaces = line_width - left_len - center_len - right_len;
654        let left_spaces = total_spaces / 2;
655        let right_spaces = total_spaces - left_spaces;
656        output.write_all(left.as_bytes())?;
657        write_spaces(output, left_spaces)?;
658        output.write_all(center.as_bytes())?;
659        write_spaces(output, right_spaces)?;
660        output.write_all(page_str)?;
661        output.write_all(b"\n")?;
662    }
663
664    // 2 blank lines
665    output.write_all(b"\n\n")?;
666
667    Ok(())
668}
669
670/// Format "Page N" into a stack buffer, returning the used slice.
671#[inline]
672fn format_page_number(page_num: usize, buf: &mut [u8; 32]) -> &[u8] {
673    const PREFIX: &[u8] = b"Page ";
674    let prefix_len = PREFIX.len();
675    buf[..prefix_len].copy_from_slice(PREFIX);
676    // Format number into a separate stack buffer to avoid overlapping borrow
677    let mut num_buf = [0u8; 20];
678    let mut n = page_num;
679    let mut pos = 19;
680    loop {
681        num_buf[pos] = b'0' + (n % 10) as u8;
682        n /= 10;
683        if n == 0 {
684            break;
685        }
686        pos -= 1;
687    }
688    let num_len = 20 - pos;
689    buf[prefix_len..prefix_len + num_len].copy_from_slice(&num_buf[pos..20]);
690    &buf[..prefix_len + num_len]
691}
692
693/// Write page footer: 5 blank lines (or form feed).
694fn write_footer<W: Write>(output: &mut W, config: &PrConfig) -> io::Result<()> {
695    if config.form_feed {
696        output.write_all(b"\x0c")?;
697    } else {
698        output.write_all(b"\n\n\n\n\n")?;
699    }
700    Ok(())
701}
702
703/// Write body for single column mode.
704fn write_single_column_body<W: Write>(
705    output: &mut W,
706    lines: &[&str],
707    config: &PrConfig,
708    line_number: &mut usize,
709    body_lines_per_page: usize,
710) -> io::Result<()> {
711    let indent_str = " ".repeat(config.indent);
712    let content_width = if config.truncate_lines {
713        compute_content_width(config)
714    } else {
715        0
716    };
717    let mut body_lines_written = 0;
718    // Pre-allocate line number buffer to avoid per-line write! formatting
719    let mut num_buf = [0u8; 32];
720
721    for line in lines.iter() {
722        output.write_all(indent_str.as_bytes())?;
723
724        if let Some((sep, digits)) = config.number_lines {
725            // Format line number directly into buffer, avoiding write! overhead
726            let num_str = format_line_number(*line_number, sep, digits, &mut num_buf);
727            output.write_all(num_str)?;
728            *line_number += 1;
729        }
730
731        let content = if config.truncate_lines {
732            if line.len() > content_width {
733                &line[..content_width]
734            } else {
735                line
736            }
737        } else {
738            line
739        };
740
741        // Direct write_all avoids std::fmt Display dispatch overhead
742        output.write_all(content.as_bytes())?;
743        output.write_all(b"\n")?;
744        body_lines_written += 1;
745        if body_lines_written >= body_lines_per_page {
746            break;
747        }
748
749        // Double-space: write blank line AFTER each content line
750        if config.double_space {
751            output.write_all(b"\n")?;
752            body_lines_written += 1;
753            if body_lines_written >= body_lines_per_page {
754                break;
755            }
756        }
757    }
758
759    // Pad remaining body lines if not omitting headers
760    if !config.omit_header && !config.omit_pagination {
761        while body_lines_written < body_lines_per_page {
762            output.write_all(b"\n")?;
763            body_lines_written += 1;
764        }
765    }
766
767    Ok(())
768}
769
770/// Format a line number with right-aligned padding and separator into a stack buffer.
771/// Returns the formatted slice. Avoids write!() per-line overhead.
772#[inline]
773fn format_line_number(num: usize, sep: char, digits: usize, buf: &mut [u8; 32]) -> &[u8] {
774    // Format the number
775    let mut n = num;
776    let mut pos = 31;
777    loop {
778        buf[pos] = b'0' + (n % 10) as u8;
779        n /= 10;
780        if n == 0 || pos == 0 {
781            break;
782        }
783        pos -= 1;
784    }
785    let num_digits = 32 - pos;
786    // Build the output: spaces for padding + number + separator
787    let padding = if digits > num_digits {
788        digits - num_digits
789    } else {
790        0
791    };
792    let total_len = padding + num_digits + sep.len_utf8();
793    // We need a separate output buffer since we're using buf for the number
794    // Just use the write_all approach with two calls for simplicity
795    let start = 32 - num_digits;
796    // Return just the number portion; caller handles padding via spaces
797    // Actually, let's format properly into a contiguous buffer
798    let sep_byte = sep as u8; // ASCII separator assumed
799    let out_start = 32usize.saturating_sub(total_len);
800    // Fill padding
801    for i in out_start..out_start + padding {
802        buf[i] = b' ';
803    }
804    // Number is already at positions [start..32], shift if needed
805    if out_start + padding != start {
806        let src = start;
807        let dst = out_start + padding;
808        for i in 0..num_digits {
809            buf[dst + i] = buf[src + i];
810        }
811    }
812    // Add separator
813    buf[out_start + padding + num_digits] = sep_byte;
814    &buf[out_start..out_start + total_len]
815}
816
817/// Compute available content width after accounting for numbering and indent.
818fn compute_content_width(config: &PrConfig) -> usize {
819    let mut w = config.page_width;
820    w = w.saturating_sub(config.indent);
821    if let Some((_, digits)) = config.number_lines {
822        w = w.saturating_sub(digits + 1); // digits + separator
823    }
824    w
825}
826
827/// Write body for multi-column mode.
828fn write_multicolumn_body<W: Write>(
829    output: &mut W,
830    lines: &[&str],
831    config: &PrConfig,
832    columns: usize,
833    line_number: &mut usize,
834    body_lines_per_page: usize,
835) -> io::Result<()> {
836    let explicit_sep = has_explicit_separator(config);
837    let col_sep = get_column_separator(config);
838    // When no explicit separator, GNU pr uses the full page_width / columns as column width
839    // and pads with tabs. When separator is explicit, use sep width in calculation.
840    let col_width = if explicit_sep {
841        if columns > 1 {
842            (config
843                .page_width
844                .saturating_sub(col_sep.len() * (columns - 1)))
845                / columns
846        } else {
847            config.page_width
848        }
849    } else {
850        config.page_width / columns
851    };
852
853    let indent_str = " ".repeat(config.indent);
854    let mut body_lines_written = 0;
855
856    if config.across {
857        // Print columns across: line 0 fills col0, line 1 fills col1, etc.
858        let mut i = 0;
859        while i < lines.len() {
860            if config.double_space && body_lines_written > 0 {
861                writeln!(output)?;
862                body_lines_written += 1;
863                if body_lines_written >= body_lines_per_page {
864                    break;
865                }
866            }
867
868            output.write_all(indent_str.as_bytes())?;
869            let mut abs_pos = config.indent;
870
871            // Find the last column with data on this row
872            let mut last_data_col = 0;
873            for col in 0..columns {
874                let li = i + col;
875                if li < lines.len() {
876                    last_data_col = col;
877                }
878            }
879
880            for col in 0..columns {
881                let li = i + col;
882                if li < lines.len() {
883                    if explicit_sep && col > 0 {
884                        write!(output, "{}", col_sep)?;
885                        abs_pos += col_sep.len();
886                    }
887                    if let Some((sep, digits)) = config.number_lines {
888                        write!(output, "{:>width$}{}", line_number, sep, width = digits)?;
889                        abs_pos += digits + 1;
890                        *line_number += 1;
891                    }
892                    let content = lines[li];
893                    let truncated = if config.truncate_lines && content.len() > col_width {
894                        &content[..col_width]
895                    } else {
896                        content
897                    };
898                    output.write_all(truncated.as_bytes())?;
899                    abs_pos += truncated.len();
900                    if col < last_data_col && !explicit_sep {
901                        let target = (col + 1) * col_width + config.indent;
902                        write_column_padding(output, abs_pos, target)?;
903                        abs_pos = target;
904                    }
905                }
906            }
907            output.write_all(b"\n")?;
908            body_lines_written += 1;
909            i += columns;
910        }
911    } else {
912        // Print columns down: distribute lines across columns.
913        // GNU pr distributes evenly: base = lines/cols, extra = lines%cols.
914        // First 'extra' columns get base+1 lines, rest get base lines.
915        let n = lines.len();
916        let base = n / columns;
917        let extra = n % columns;
918
919        // Compute start offset of each column
920        let mut col_starts = vec![0usize; columns + 1];
921        for col in 0..columns {
922            let col_lines = base + if col < extra { 1 } else { 0 };
923            col_starts[col + 1] = col_starts[col] + col_lines;
924        }
925
926        // Number of rows = max lines in any column
927        let num_rows = if extra > 0 { base + 1 } else { base };
928
929        for row in 0..num_rows {
930            if config.double_space && row > 0 {
931                writeln!(output)?;
932                body_lines_written += 1;
933                if body_lines_written >= body_lines_per_page {
934                    break;
935                }
936            }
937
938            output.write_all(indent_str.as_bytes())?;
939            let mut abs_pos = config.indent;
940
941            // Find the last column with data for this row
942            let mut last_data_col = 0;
943            for col in 0..columns {
944                let col_lines = col_starts[col + 1] - col_starts[col];
945                if row < col_lines {
946                    last_data_col = col;
947                }
948            }
949
950            for col in 0..columns {
951                let col_lines = col_starts[col + 1] - col_starts[col];
952                let li = col_starts[col] + row;
953                if row < col_lines {
954                    if explicit_sep && col > 0 {
955                        write!(output, "{}", col_sep)?;
956                        abs_pos += col_sep.len();
957                    }
958                    if let Some((sep, digits)) = config.number_lines {
959                        let num = config.first_line_number + li;
960                        write!(output, "{:>width$}{}", num, sep, width = digits)?;
961                        abs_pos += digits + 1;
962                    }
963                    let content = lines[li];
964                    let truncated = if config.truncate_lines && content.len() > col_width {
965                        &content[..col_width]
966                    } else {
967                        content
968                    };
969                    output.write_all(truncated.as_bytes())?;
970                    abs_pos += truncated.len();
971                    if col < last_data_col && !explicit_sep {
972                        // Not the last column with data: pad to next column boundary
973                        let target = (col + 1) * col_width + config.indent;
974                        write_column_padding(output, abs_pos, target)?;
975                        abs_pos = target;
976                    }
977                } else if col <= last_data_col {
978                    // Empty column before the last data column: pad to next boundary
979                    if explicit_sep {
980                        if col > 0 {
981                            write!(output, "{}", col_sep)?;
982                            abs_pos += col_sep.len();
983                        }
984                        // For explicit separator, just write separator, no padding
985                    } else {
986                        let target = (col + 1) * col_width + config.indent;
987                        write_column_padding(output, abs_pos, target)?;
988                        abs_pos = target;
989                    }
990                }
991                // Empty columns after last data column: skip entirely
992            }
993            output.write_all(b"\n")?;
994            body_lines_written += 1;
995        }
996        // Update line_number for the lines we processed
997        if config.number_lines.is_some() {
998            *line_number += lines.len();
999        }
1000    }
1001
1002    // Pad remaining body lines
1003    if !config.omit_header && !config.omit_pagination {
1004        while body_lines_written < body_lines_per_page {
1005            output.write_all(b"\n")?;
1006            body_lines_written += 1;
1007        }
1008    }
1009
1010    Ok(())
1011}