From 07321eec44709c8fa8b37537be6b16fcf4694a3a Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Fri, 10 Apr 2026 21:15:33 +0200 Subject: [PATCH] Add self-contained perltidy wrapper for Webmin style formatting --- perltidy.pl | 357 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100755 perltidy.pl diff --git a/perltidy.pl b/perltidy.pl new file mode 100755 index 000000000..2530df695 --- /dev/null +++ b/perltidy.pl @@ -0,0 +1,357 @@ +#!/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); +}