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/// Paginate a single file and write output.
212pub fn pr_file<R: BufRead, W: Write>(
213    input: R,
214    output: &mut W,
215    config: &PrConfig,
216    filename: &str,
217    file_date: Option<SystemTime>,
218) -> io::Result<()> {
219    let date = file_date.unwrap_or_else(SystemTime::now);
220
221    // Read all lines
222    let mut all_lines: Vec<String> = Vec::new();
223    for line_result in input.lines() {
224        let line = line_result?;
225        let mut line = line;
226
227        // Expand tabs if requested
228        if let Some((tab_char, tab_width)) = config.expand_tabs {
229            line = expand_tabs_in_line(&line, tab_char, tab_width);
230        }
231
232        // Process control characters
233        line = process_control_chars(&line, config.show_control_chars, config.show_nonprinting);
234
235        all_lines.push(line);
236    }
237
238    let header_str = config.header.as_deref().unwrap_or(filename);
239    let date_str = format_header_date(&date, &config.date_format);
240
241    // Calculate body lines per page
242    let body_lines_per_page = if config.omit_header || config.omit_pagination {
243        if config.page_length > 0 {
244            config.page_length
245        } else {
246            DEFAULT_PAGE_LENGTH
247        }
248    } else if config.page_length <= HEADER_LINES + FOOTER_LINES {
249        // If page is too small for header+footer, just use 1 body line
250        1
251    } else {
252        config.page_length - HEADER_LINES - FOOTER_LINES
253    };
254
255    // Account for double spacing: each input line takes 2 output lines
256    let input_lines_per_page = if config.double_space {
257        (body_lines_per_page + 1) / 2
258    } else {
259        body_lines_per_page
260    };
261
262    // Handle multi-column mode
263    let columns = config.columns.max(1);
264    let lines_per_column = if columns > 1 && !config.across {
265        input_lines_per_page / columns
266    } else {
267        input_lines_per_page
268    };
269
270    let lines_consumed_per_page = if columns > 1 && !config.across {
271        lines_per_column * columns
272    } else {
273        input_lines_per_page
274    };
275
276    // Split into pages
277    let total_lines = all_lines.len();
278    let mut line_number = config.first_line_number;
279    let mut page_num = 1usize;
280    let mut line_idx = 0;
281
282    while line_idx < total_lines || (line_idx == 0 && total_lines == 0) {
283        // For empty input, output one empty page (matching GNU behavior)
284        if total_lines == 0 && line_idx == 0 {
285            if page_num >= config.first_page
286                && (config.last_page == 0 || page_num <= config.last_page)
287            {
288                if !config.omit_header && !config.omit_pagination {
289                    write_header(output, &date_str, header_str, page_num, config)?;
290                }
291                if !config.omit_header && !config.omit_pagination {
292                    write_footer(output, config)?;
293                }
294            }
295            break;
296        }
297
298        let page_end = (line_idx + lines_consumed_per_page).min(total_lines);
299
300        if page_num >= config.first_page && (config.last_page == 0 || page_num <= config.last_page)
301        {
302            // Write header
303            if !config.omit_header && !config.omit_pagination {
304                write_header(output, &date_str, header_str, page_num, config)?;
305            }
306
307            // Write body
308            if columns > 1 {
309                write_multicolumn_body(
310                    output,
311                    &all_lines[line_idx..page_end],
312                    config,
313                    columns,
314                    lines_per_column,
315                    &mut line_number,
316                    body_lines_per_page,
317                )?;
318            } else {
319                write_single_column_body(
320                    output,
321                    &all_lines[line_idx..page_end],
322                    config,
323                    &mut line_number,
324                    body_lines_per_page,
325                )?;
326            }
327
328            // Write footer
329            if !config.omit_header && !config.omit_pagination {
330                write_footer(output, config)?;
331            }
332        }
333
334        line_idx = page_end;
335        page_num += 1;
336
337        // Break if we've consumed all lines
338        if line_idx >= total_lines {
339            break;
340        }
341    }
342
343    Ok(())
344}
345
346/// Paginate multiple files merged side by side (-m mode).
347pub fn pr_merge<W: Write>(
348    inputs: &[Vec<String>],
349    output: &mut W,
350    config: &PrConfig,
351    filenames: &[&str],
352    file_dates: &[SystemTime],
353) -> io::Result<()> {
354    let date = file_dates.first().copied().unwrap_or_else(SystemTime::now);
355    let date_str = format_header_date(&date, &config.date_format);
356    let header_str = config
357        .header
358        .as_deref()
359        .unwrap_or_else(|| filenames.first().copied().unwrap_or(""));
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 config.page_length <= HEADER_LINES + FOOTER_LINES {
368        1
369    } else {
370        config.page_length - HEADER_LINES - FOOTER_LINES
371    };
372
373    let input_lines_per_page = if config.double_space {
374        (body_lines_per_page + 1) / 2
375    } else {
376        body_lines_per_page
377    };
378
379    let num_files = inputs.len();
380    let col_sep = get_column_separator(config);
381    let col_width = if num_files > 1 {
382        (config
383            .page_width
384            .saturating_sub(col_sep.len() * (num_files - 1)))
385            / num_files
386    } else {
387        config.page_width
388    };
389
390    let max_lines = inputs.iter().map(|f| f.len()).max().unwrap_or(0);
391    let mut page_num = 1usize;
392    let mut line_idx = 0;
393    let mut line_number = config.first_line_number;
394
395    while line_idx < max_lines {
396        let page_end = (line_idx + input_lines_per_page).min(max_lines);
397
398        if page_num >= config.first_page && (config.last_page == 0 || page_num <= config.last_page)
399        {
400            if !config.omit_header && !config.omit_pagination {
401                write_header(output, &date_str, header_str, page_num, config)?;
402            }
403
404            let mut body_lines_written = 0;
405            for i in line_idx..page_end {
406                if config.double_space && body_lines_written > 0 {
407                    writeln!(output)?;
408                    body_lines_written += 1;
409                }
410
411                let indent_str = " ".repeat(config.indent);
412                write!(output, "{}", indent_str)?;
413
414                if let Some((sep, digits)) = config.number_lines {
415                    write!(output, "{:>width$}{}", line_number, sep, width = digits)?;
416                    line_number += 1;
417                }
418
419                for (fi, file_lines) in inputs.iter().enumerate() {
420                    let content = if i < file_lines.len() {
421                        &file_lines[i]
422                    } else {
423                        ""
424                    };
425                    let truncated = if config.truncate_lines && content.len() > col_width {
426                        &content[..col_width]
427                    } else {
428                        content
429                    };
430                    if fi > 0 {
431                        write!(output, "{}", col_sep)?;
432                    }
433                    write!(output, "{:<width$}", truncated, width = col_width)?;
434                }
435                writeln!(output)?;
436                body_lines_written += 1;
437            }
438
439            // Pad remaining body lines
440            while body_lines_written < body_lines_per_page {
441                writeln!(output)?;
442                body_lines_written += 1;
443            }
444
445            if !config.omit_header && !config.omit_pagination {
446                write_footer(output, config)?;
447            }
448        }
449
450        line_idx = page_end;
451        page_num += 1;
452    }
453
454    Ok(())
455}
456
457/// Write page header: 2 blank lines, date/header/page line, 2 blank lines.
458fn write_header<W: Write>(
459    output: &mut W,
460    date_str: &str,
461    header: &str,
462    page_num: usize,
463    config: &PrConfig,
464) -> io::Result<()> {
465    // 2 blank lines
466    writeln!(output)?;
467    writeln!(output)?;
468
469    // Header line: date is left-aligned, header is centered, Page N is right-aligned.
470    // Total width is page_width (default 72).
471    let page_str = format!("Page {}", page_num);
472    let line_width = config.page_width;
473
474    let left = date_str;
475    let right = &page_str;
476    let center = header;
477
478    // Available space for center text between left and right.
479    let left_len = left.len();
480    let right_len = right.len();
481    let center_len = center.len();
482
483    // GNU pr centers the header title within the line.
484    // The layout is: LEFT + spaces + CENTER + spaces + RIGHT
485    // where the total is exactly line_width characters.
486    if left_len + center_len + right_len + 2 >= line_width {
487        // Not enough space to center; just concatenate.
488        writeln!(output, "{} {} {}", left, center, right)?;
489    } else {
490        let total_spaces = line_width - left_len - center_len - right_len;
491        // Distribute spaces evenly around the center text.
492        let left_spaces = total_spaces / 2;
493        let right_spaces = total_spaces - left_spaces;
494        writeln!(
495            output,
496            "{}{}{}{}{}",
497            left,
498            " ".repeat(left_spaces),
499            center,
500            " ".repeat(right_spaces),
501            right
502        )?;
503    }
504
505    // 2 blank lines
506    writeln!(output)?;
507    writeln!(output)?;
508
509    Ok(())
510}
511
512/// Write page footer: 5 blank lines (or form feed).
513fn write_footer<W: Write>(output: &mut W, config: &PrConfig) -> io::Result<()> {
514    if config.form_feed {
515        write!(output, "\x0c")?;
516    } else {
517        for _ in 0..FOOTER_LINES {
518            writeln!(output)?;
519        }
520    }
521    Ok(())
522}
523
524/// Write body for single column mode.
525fn write_single_column_body<W: Write>(
526    output: &mut W,
527    lines: &[String],
528    config: &PrConfig,
529    line_number: &mut usize,
530    body_lines_per_page: usize,
531) -> io::Result<()> {
532    let indent_str = " ".repeat(config.indent);
533    let mut body_lines_written = 0;
534
535    for (i, line) in lines.iter().enumerate() {
536        if config.double_space && i > 0 {
537            writeln!(output)?;
538            body_lines_written += 1;
539            if body_lines_written >= body_lines_per_page {
540                break;
541            }
542        }
543
544        write!(output, "{}", indent_str)?;
545
546        if let Some((sep, digits)) = config.number_lines {
547            write!(output, "{:>width$}{}", line_number, sep, width = digits)?;
548            *line_number += 1;
549        }
550
551        let content = if config.truncate_lines {
552            let max_w = compute_content_width(config);
553            if line.len() > max_w {
554                &line[..max_w]
555            } else {
556                line.as_str()
557            }
558        } else {
559            line.as_str()
560        };
561
562        writeln!(output, "{}", content)?;
563        body_lines_written += 1;
564    }
565
566    // Pad remaining body lines if not omitting headers
567    if !config.omit_header && !config.omit_pagination {
568        while body_lines_written < body_lines_per_page {
569            writeln!(output)?;
570            body_lines_written += 1;
571        }
572    }
573
574    Ok(())
575}
576
577/// Compute available content width after accounting for numbering and indent.
578fn compute_content_width(config: &PrConfig) -> usize {
579    let mut w = config.page_width;
580    w = w.saturating_sub(config.indent);
581    if let Some((_, digits)) = config.number_lines {
582        w = w.saturating_sub(digits + 1); // digits + separator
583    }
584    w
585}
586
587/// Write body for multi-column mode.
588fn write_multicolumn_body<W: Write>(
589    output: &mut W,
590    lines: &[String],
591    config: &PrConfig,
592    columns: usize,
593    lines_per_column: usize,
594    line_number: &mut usize,
595    body_lines_per_page: usize,
596) -> io::Result<()> {
597    let col_sep = get_column_separator(config);
598    let col_width = if columns > 1 {
599        (config
600            .page_width
601            .saturating_sub(col_sep.len() * (columns - 1)))
602            / columns
603    } else {
604        config.page_width
605    };
606
607    let indent_str = " ".repeat(config.indent);
608    let mut body_lines_written = 0;
609
610    if config.across {
611        // Print columns across: line 0 fills col0, line 1 fills col1, etc.
612        let mut i = 0;
613        while i < lines.len() {
614            if config.double_space && body_lines_written > 0 {
615                writeln!(output)?;
616                body_lines_written += 1;
617                if body_lines_written >= body_lines_per_page {
618                    break;
619                }
620            }
621
622            write!(output, "{}", indent_str)?;
623
624            for col in 0..columns {
625                let li = i + col;
626                if col > 0 {
627                    write!(output, "{}", col_sep)?;
628                }
629                if li < lines.len() {
630                    if let Some((sep, digits)) = config.number_lines {
631                        write!(output, "{:>width$}{}", line_number, sep, width = digits)?;
632                        *line_number += 1;
633                    }
634                    let content = &lines[li];
635                    let truncated = if config.truncate_lines && content.len() > col_width {
636                        &content[..col_width]
637                    } else {
638                        content.as_str()
639                    };
640                    if col < columns - 1 {
641                        write!(output, "{:<width$}", truncated, width = col_width)?;
642                    } else {
643                        write!(output, "{}", truncated)?;
644                    }
645                }
646            }
647            writeln!(output)?;
648            body_lines_written += 1;
649            i += columns;
650        }
651    } else {
652        // Print columns down: first lines_per_column lines in col0, next in col1, etc.
653        for row in 0..lines_per_column {
654            if config.double_space && row > 0 {
655                writeln!(output)?;
656                body_lines_written += 1;
657                if body_lines_written >= body_lines_per_page {
658                    break;
659                }
660            }
661
662            write!(output, "{}", indent_str)?;
663
664            for col in 0..columns {
665                let li = col * lines_per_column + row;
666                if col > 0 {
667                    write!(output, "{}", col_sep)?;
668                }
669                if li < lines.len() {
670                    if let Some((sep, digits)) = config.number_lines {
671                        let num = config.first_line_number + li;
672                        write!(output, "{:>width$}{}", num, sep, width = digits)?;
673                    }
674                    let content = &lines[li];
675                    let truncated = if config.truncate_lines && content.len() > col_width {
676                        &content[..col_width]
677                    } else {
678                        content.as_str()
679                    };
680                    if col < columns - 1 {
681                        write!(output, "{:<width$}", truncated, width = col_width)?;
682                    } else {
683                        write!(output, "{}", truncated)?;
684                    }
685                } else if col < columns - 1 {
686                    write!(output, "{:<width$}", "", width = col_width)?;
687                }
688            }
689            writeln!(output)?;
690            body_lines_written += 1;
691        }
692        // Update line_number for the lines we processed
693        if config.number_lines.is_some() {
694            *line_number += lines.len();
695        }
696    }
697
698    // Pad remaining body lines
699    if !config.omit_header && !config.omit_pagination {
700        while body_lines_written < body_lines_per_page {
701            writeln!(output)?;
702            body_lines_written += 1;
703        }
704    }
705
706    Ok(())
707}