#!/usr/bin/perl # SPDX-FileCopyrightText: Amolith # # SPDX-License-Identifier: GPL-3.0-or-later use strict; use warnings; my $WIDTH = 72; my $mode = shift @ARGV; unless (defined $mode && ($mode eq 'subject' || $mode eq 'message')) { print STDERR "Usage: format subject \"text\" | format message < file\n"; exit 1; } if ($mode eq 'subject') { my $subject = shift @ARGV; unless (defined $subject) { print STDERR "subject mode requires a subject argument\n"; exit 1; } if (length($subject) > 50) { print STDERR "Subject exceeds 50 characters (" . length($subject) . ")\n"; exit 1; } print $subject; exit 0; } # Message mode: read full message from stdin, format it, emit to stdout. my @all_lines = ; chomp @all_lines; unless (@all_lines) { print STDERR "Empty message\n"; exit 1; } # First line is the subject. my $subject = $all_lines[0]; if (length($subject) > 50) { print STDERR "Subject exceeds 50 characters (" . length($subject) . ")\n"; exit 1; } # Subject only — no body or trailers. if (@all_lines == 1) { print "$subject\n"; exit 0; } # Skip the blank line after the subject. my $start = 1; $start++ if $start < @all_lines && $all_lines[$start] =~ /^\s*$/; # Nothing after the subject (just trailing blanks). if ($start >= @all_lines) { print "$subject\n"; exit 0; } my @rest = @all_lines[$start .. $#all_lines]; # Detect trailer block by scanning backward. Trailers are Key: value lines at # the end of the message (after skipping trailing blank lines). The key must # start with a letter and contain only letters, digits, and hyphens. my $scan = $#rest; # Skip trailing blank lines. $scan-- while $scan >= 0 && $rest[$scan] =~ /^\s*$/; my $trailer_end = $scan; # last non-blank line # Collect trailer lines. while ($scan >= 0 && $rest[$scan] =~ /^[A-Za-z][A-Za-z0-9-]*: ./) { $scan--; } my $trailer_start = $scan + 1; my @trailers; if ($trailer_start <= $trailer_end) { @trailers = @rest[$trailer_start .. $trailer_end]; } # Body is everything before the trailer block, with trailing blanks trimmed. my @body_lines; if ($trailer_start > 0) { my $body_end = $trailer_start - 1; # Trim trailing blank lines from the body. $body_end-- while $body_end >= 0 && $rest[$body_end] =~ /^\s*$/; @body_lines = @rest[0 .. $body_end] if $body_end >= 0; } # Reflow the body. my $formatted_body = reflow_body(@body_lines); # Assemble output. print "$subject\n"; if ($formatted_body ne '') { print "\n$formatted_body\n"; } if (@trailers) { print "\n", join("\n", @trailers), "\n"; } exit 0; # --- Subroutines --- sub reflow_body { my @lines = @_; return '' unless @lines; my @result; my @plain_buffer; my $flush_plain = sub { if (@plain_buffer) { my $joined = join(' ', @plain_buffer); push @result, word_wrap($joined, $WIDTH); @plain_buffer = (); } }; for my $line (@lines) { # Code blocks: 4+ leading spaces or tab — preserve as-is. if ($line =~ /^(?: |\t)/) { $flush_plain->(); push @result, $line; next; } (my $trimmed = $line) =~ s/^\s+|\s+$//g; # Blank lines. if ($trimmed eq '') { $flush_plain->(); push @result, ''; next; } # Bullet lists (- or *). if ($trimmed =~ /^([*-] )(.*)/) { $flush_plain->(); my ($marker, $content) = ($1, $2); push @result, wrap_hanging($marker, ' ', $content, $WIDTH); next; } # Numbered lists (1. , 2. , 10. , etc.). if ($trimmed =~ /^(\d+\.\s)(.*)/) { $flush_plain->(); my $marker = $1; my $content = defined $2 ? $2 : ''; my $indent = ' ' x length($marker); push @result, wrap_hanging($marker, $indent, $content, $WIDTH); next; } # Plain text: buffer for paragraph reflow. push @plain_buffer, $trimmed; } $flush_plain->(); return join("\n", @result); } sub word_wrap { my ($text, $width) = @_; my @words = split /\s+/, $text; return '' unless @words; my @lines; my $current = ''; my $current_width = 0; for my $word (@words) { my $wlen = length($word); if ($current eq '') { $current = $word; $current_width = $wlen; } elsif ($current_width + 1 + $wlen <= $width) { $current .= " $word"; $current_width += 1 + $wlen; } else { push @lines, $current; $current = $word; $current_width = $wlen; } } push @lines, $current if $current ne ''; return join("\n", @lines); } sub wrap_hanging { my ($first_prefix, $cont_prefix, $text, $width) = @_; my $first_width = $width - length($first_prefix); my $cont_width = $width - length($cont_prefix); my @words = split /\s+/, $text; return $first_prefix unless @words; my @lines; my $current = ''; my $current_width = 0; my $is_first = 1; for my $word (@words) { my $wlen = length($word); my $max = $is_first ? $first_width : $cont_width; if ($current eq '') { $current = $word; $current_width = $wlen; } elsif ($current_width + 1 + $wlen <= $max) { $current .= " $word"; $current_width += 1 + $wlen; } else { my $prefix = $is_first ? $first_prefix : $cont_prefix; push @lines, $prefix . $current; $is_first = 0; $current = $word; $current_width = $wlen; } } if ($current ne '') { my $prefix = $is_first ? $first_prefix : $cont_prefix; push @lines, $prefix . $current; } return join("\n", @lines); }