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