Files
webmin/perltidy.pl
Ilia Ross 07321eec44
Some checks failed
webmin.dev: webmin/webmin / build (push) Has been cancelled
Add self-contained perltidy wrapper for Webmin style formatting
2026-04-10 21:15:33 +02:00

358 lines
9.7 KiB
Perl
Executable File

#!/usr/bin/env perl
use strict;
use warnings;
use Perl::Tidy;
use PPI;
# Formats a Perl file using an embedded Webmin perltidy profile and then
# applies a few Webmin-specific cleanup passes that perltidy cannot express
# on its own.
#
# Usage:
# perltidy.pl path/to/file.pl
#
# The perltidy profile is embedded below so this script is self-contained and
# behaves the same regardless of the current working directory.
my $file = shift or die "usage: $0 file.pl\n";
my $embedded_perltidyrc = <<'END_PERLTIDYRC';
# Webmin perltidy configuration used by perltidy.pl
#
# Key characteristics:
# - 8-column hard tabs for indentation
# - Ratliff/Banner brace style (closing brace at body indentation)
# - Sub opening brace on its own line
# - Uncuddled else/elsif
# - Logical operators (|| &&) at end of line, not start of continuation
# - No spaces around string concatenation operator (.)
#
# Wrapper post-processing:
# - Restore named sub body indentation to Webmin style
# - Rewrite empty arrayrefs from [] to [ ]
# - Preserve alignment in multiline qw(...) lists
#
# Notes:
# - Line length is set to 88 so code inside named subs ends up near an
# effective 80 columns after the sub-body unindent pass runs.
# - Deeply nested UI helper calls (ui_table_row, ui_select, etc.) may
# still reflow differently than the hand-formatted originals. The
# -lp (line-up-parens) option was considered but produces worse
# results with Webmin's deeply nested call patterns.
# Indentation
-i=8 # 8 columns per indent level
-ci=4 # Continuation indent (half of indent level)
-et=8 # Entab leading whitespace, 8-column tab stops
# Line length
-l=88 # Max line length: 88 (includes 80 + 8 from sub-body unindent fix)
# Brace placement
-nbl # Control-structure opening brace stays on same line
-sbl # Sub opening brace goes on a new line by itself
-icb # Indent closing brace to body level (Ratliff/Banner)
# else / elsif
-nce # Uncuddled: else/elsif on its own line, not after }
# Container tightness
-pt=2 # Parens: keep all parens tight, like f($x) and ($a + $b)
-sbt=2 # Brackets: $a[0] not $a[ 0 ]
-bt=2 # Braces: $h{key} not $h{ key }
# Blank lines
-bbs # Blank line before subs
-mbl=2 # Max 2 consecutive blank lines
-kbl=1 # Keep existing blank lines
-nbboc # Don't add a blank line before opening comments in blocks
# Spacing
-nvc # Don't vertically align code like consecutive assignments
-nwls="." # No space to the left of . (concat)
-nwrs="." # No space to the right of . (concat)
# Line break positions
-wba="&& || and or ." # Break after these operators (keep at end of line)
# Other
-nolq # Don't outdent long quoted strings
END_PERLTIDYRC
my $source = read_source_file($file);
my $tidied = run_perltidy($source);
my $out = apply_webmin_post_tidy_fixes($source, $tidied, "\t");
write_source_file($file, $out);
# read_source_file(file)
# Reads a source file into memory.
sub read_source_file
{
my ($file) = @_;
open my $fh, '<', $file or die "open($file): $!";
local $/;
return <$fh>;
}
# write_source_file(file, code)
# Writes the final formatted source back to disk.
sub write_source_file
{
my ($file, $code) = @_;
open my $fh, '>', $file or die "write($file): $!";
print {$fh} $code;
close $fh or die "close($file): $!";
}
# run_perltidy(source)
# Runs perltidy with the embedded Webmin profile.
sub run_perltidy
{
my ($source) = @_;
my $tidied = '';
my $err = '';
my $rc = Perl::Tidy::perltidy(
source => \$source,
destination => \$tidied,
stderr => \$err,
# Feed the embedded profile directly to perltidy.
perltidyrc => \$embedded_perltidyrc,
);
die "perltidy failed:\n$err\n" if $rc;
return $tidied;
}
# apply_webmin_post_tidy_fixes(source, code, indent-unit)
# Applies all Webmin-specific post-processing passes.
sub apply_webmin_post_tidy_fixes
{
my ($source, $code, $indent_unit) = @_;
# Webmin's style keeps named sub bodies flush with the "sub" keyword, and
# prefers a space inside empty anonymous arrayrefs. Some hand-aligned
# multi-line qw(...) lists should also keep their original continuation
# columns.
$code = apply_webmin_sub_unindent($code, $indent_unit);
$code = apply_webmin_empty_array_spacing($code);
$code = apply_webmin_multiline_qw_alignment($source, $code);
return $code;
}
# apply_webmin_sub_unindent(code, indent-unit)
# Removes one body-indent level from named subroutines.
sub apply_webmin_sub_unindent
{
my ($code, $indent_unit) = @_;
$indent_unit //= "\t";
# Split with a negative limit so we preserve any trailing newline exactly.
my @lines = split /\n/, $code, -1;
my $doc = PPI::Document->new(\$code) or die "PPI parse failed\n";
my $subs = $doc->find(
sub {
my ($top, $node) = @_;
return 0 unless $node->isa('PPI::Statement::Sub');
return defined $node->name; # named subs only
}
);
return $code unless $subs && @$subs;
# Each named sub contributes one indentation level that perltidy adds but
# Webmin does not want. Track that per line so nested named subs are handled
# correctly even if they share lines with other code.
my %unindent_count;
for my $sub (@$subs) {
my $block = $sub->block or next;
my $start = $block->location or next; # opening brace line/col
my $finish = $block->last_token or next;
my $finish_loc = $finish->location or next;
# Start after the opening brace line and include the closing brace line.
my $start_line = $start->[0] + 1;
my $end_line = $finish_loc->[0];
next if $start_line > $end_line;
for my $line_no ($start_line .. $end_line) {
$unindent_count{$line_no}++;
}
}
for my $line_no (sort { $a <=> $b } keys %unindent_count) {
my $idx = $line_no - 1;
next if $idx < 0 || $idx > $#lines;
# Remove exactly one leading indent unit per containing named sub.
for (1 .. $unindent_count{$line_no}) {
if ($indent_unit eq "\t") {
$lines[$idx] =~ s/^\t//;
}
else {
my $q = quotemeta($indent_unit);
$lines[$idx] =~ s/^$q//;
}
}
}
return join "\n", @lines;
}
# apply_webmin_empty_array_spacing(code)
# Rewrites empty arrayrefs from [] to [ ].
sub apply_webmin_empty_array_spacing
{
my ($code) = @_;
my @lines = split /\n/, $code, -1;
my $doc = PPI::Document->new(\$code) or die "PPI parse failed\n";
my $constructors = $doc->find(
sub {
my ($top, $node) = @_;
return 0 unless $node->isa('PPI::Structure::Constructor');
# perltidy keeps empty arrayrefs as "[]"; Webmin style wants "[ ]".
return $node->content eq '[]';
}
);
return $code unless $constructors && @$constructors;
my %replacements;
for my $constructor (@$constructors) {
my $start = $constructor->start or next;
my $finish = $constructor->finish or next;
my $start_loc = $start->location or next;
my $finish_loc = $finish->location or next;
# Empty constructors are a single token pair on one line. Skip anything
# more complex rather than guessing.
next if $start_loc->[0] != $finish_loc->[0];
next if $finish_loc->[1] != $start_loc->[1] + 1;
push @{$replacements{$start_loc->[0]}}, [$start_loc->[1], 2];
}
for my $line_no (keys %replacements) {
my $idx = $line_no - 1;
next if $idx < 0 || $idx > $#lines;
# Rewrite from right to left so earlier column offsets stay valid.
for my $edit (sort { $b->[0] <=> $a->[0] } @{$replacements{$line_no}}) {
my ($column, $length) = @$edit;
substr($lines[$idx], $column - 1, $length, '[ ]');
}
}
return join "\n", @lines;
}
# apply_webmin_multiline_qw_alignment(source, code)
# Restores original alignment for multiline qw(...) lists.
sub apply_webmin_multiline_qw_alignment
{
my ($source, $code) = @_;
my $source_doc = PPI::Document->new(\$source) or die "PPI parse failed\n";
my $code_doc = PPI::Document->new(\$code) or die "PPI parse failed\n";
my @source_tokens = find_multiline_qw_tokens($source_doc);
my @code_tokens = find_multiline_qw_tokens($code_doc);
return $code unless @source_tokens && @code_tokens;
return $code unless @source_tokens == @code_tokens;
my $line_offsets = build_line_offsets($code);
my @replacements;
for my $i (0 .. $#code_tokens) {
my $source_token = $source_tokens[$i];
my $code_token = $code_tokens[$i];
next if $source_token->content eq $code_token->content;
next
unless normalize_qw_token($source_token->content) eq
normalize_qw_token($code_token->content);
my $start_loc = $code_token->location or next;
my $offset = location_to_offset($line_offsets, $start_loc);
push @replacements,
[$offset, length($code_token->content), $source_token->content];
}
return $code unless @replacements;
# Apply from the end of the file so earlier offsets remain valid.
for my $edit (sort { $b->[0] <=> $a->[0] } @replacements) {
my ($offset, $length, $replacement) = @$edit;
substr($code, $offset, $length, $replacement);
}
return $code;
}
# find_multiline_qw_tokens(doc)
# Returns quote-like word tokens that span multiple lines.
sub find_multiline_qw_tokens
{
my ($doc) = @_;
return
grep { $_->content =~ /\n/ }
grep { $_->isa('PPI::Token::QuoteLike::Words') } $doc->tokens;
}
# normalize_qw_token(content)
# Normalizes qw(...) token whitespace for comparisons.
sub normalize_qw_token
{
my ($content) = @_;
$content =~ s/\s+/ /g;
$content =~ s/^\s+//;
$content =~ s/\s+$//;
return $content;
}
# build_line_offsets(code)
# Builds 1-based line-start offsets for substring replacement.
sub build_line_offsets
{
my ($code) = @_;
my @lines = split /\n/, $code, -1;
my @offsets = (undef);
my $offset = 0;
for my $i (0 .. $#lines) {
$offsets[$i + 1] = $offset;
$offset += length($lines[$i]) + 1;
}
return \@offsets;
}
# location_to_offset(line-offsets, location)
# Converts a PPI line and column to a string offset.
sub location_to_offset
{
my ($line_offsets, $location) = @_;
my ($line, $column) = @$location;
return $line_offsets->[$line] + ($column - 1);
}