#!/usr/bin/perl

# Copyright (c) 2021-2025, PostgreSQL Global Development Group

# Program to maintain uniform layout style in our C code.
# Exit codes:
#   0 -- all OK
#   1 -- error invoking pgindent, nothing done
#   2 -- --check mode and at least one file requires changes
#   3 -- pg_bsd_indent failed on at least one file

use strict;
use warnings FATAL => 'all';

use Cwd qw(abs_path getcwd);
use File::Find;
use File::Spec;
use File::Temp;
use IO::Handle;
use Getopt::Long;

# Update for pg_bsd_indent version
my $INDENT_VERSION = "2.1.2";

# Our standard indent settings
my $indent_opts =
  "-bad -bap -bbb -bc -bl -cli1 -cp33 -cdb -nce -d0 -di12 -nfc1 -i4 -l79 -lp -lpl -nip -npro -sac -tpg -ts4";

my $devnull = File::Spec->devnull;

my ($typedefs_file, $typedef_str, @excludes, $indent, $diff,
	$check, $help, @commits,);

$help = 0;

my %options = (
	"help" => \$help,
	"commit=s" => \@commits,
	"typedefs=s" => \$typedefs_file,
	"list-of-typedefs=s" => \$typedef_str,
	"excludes=s" => \@excludes,
	"indent=s" => \$indent,
	"diff" => \$diff,
	"check" => \$check,);
GetOptions(%options) || usage("bad command line argument");

usage() if $help;

usage("Cannot use --commit with command line file list")
  if (@commits && @ARGV);

# command line option wins, then environment, then locations based on current
# dir, then default location
$typedefs_file ||= $ENV{PGTYPEDEFS};

# get indent location for environment or default
$indent ||= $ENV{PGINDENT} || $ENV{INDENT} || "pg_bsd_indent";

my $sourcedir = locate_sourcedir();

# if it's the base of a postgres tree, we will exclude the files
# postgres wants excluded
if ($sourcedir)
{
	my $exclude_candidate = "$sourcedir/exclude_file_patterns";
	push(@excludes, $exclude_candidate) if -f $exclude_candidate;
}

# The typedef list that's mechanically extracted by the buildfarm may omit
# some names we want to treat like typedefs, e.g. "bool" (which is a macro
# according to <stdbool.h>), and may include some names we don't want
# treated as typedefs, although various headers that some builds include
# might make them so.  For the moment we just hardwire a list of names
# to add and a list of names to exclude; eventually this may need to be
# easier to configure.  Note that the typedefs need trailing newlines.
my @additional = map { "$_\n" } qw(
  bool regex_t regmatch_t regoff
);

my %excluded = map { +"$_\n" => 1 } qw(
  FD_SET LookupSet boolean date duration
  element_type inquiry iterator other
  pointer reference rep string timestamp type wrap
);

# globals
my @files;
my $filtered_typedefs_fh;

sub check_indent
{
	if (system("$indent -? < $devnull > $devnull 2>&1") != 0)
	{
		if ($? >> 8 != 1)
		{
			print STDERR
			  "You do not appear to have $indent installed on your system.\n";
			exit 1;
		}
	}

	if (`$indent --version` !~ m/ $INDENT_VERSION /)
	{
		print STDERR
		  "You do not appear to have $indent version $INDENT_VERSION installed on your system.\n";
		exit 1;
	}

	if (system("$indent -gnu < $devnull > $devnull 2>&1") == 0)
	{
		print STDERR
		  "You appear to have GNU indent rather than BSD indent.\n";
		exit 1;
	}

	return;
}

sub locate_sourcedir
{
	# try fairly hard to locate the sourcedir
	my $sub = "./src/tools/pgindent";
	return $sub if -d $sub;
	# try to find it from an ancestor directory
	$sub = "../src/tools/pgindent";
	foreach (1 .. 4)
	{
		return $sub if -d $sub;
		$sub = "../$sub";
	}
	return;    # undef if nothing found
}

sub load_typedefs
{
	# try fairly hard to find the typedefs file if it's not set

	foreach my $try ('.', $sourcedir, '/usr/local/etc')
	{
		last if $typedefs_file;
		next unless defined $try;
		$typedefs_file = "$try/typedefs.list"
		  if (-f "$try/typedefs.list");
	}

	die "cannot locate typedefs file \"$typedefs_file\"\n"
	  unless $typedefs_file && -f $typedefs_file;

	open(my $typedefs_fh, '<', $typedefs_file)
	  || die "cannot open typedefs file \"$typedefs_file\": $!\n";
	my @typedefs = <$typedefs_fh>;
	close($typedefs_fh);

	# add command-line-supplied typedefs?
	if (defined($typedef_str))
	{
		foreach my $typedef (split(m/[, \t\n]+/, $typedef_str))
		{
			push(@typedefs, $typedef . "\n");
		}
	}

	# add additional entries
	push(@typedefs, @additional);

	# remove excluded entries
	@typedefs = grep { !$excluded{$_} } @typedefs;

	# write filtered typedefs
	my $filter_typedefs_fh = new File::Temp(TEMPLATE => "pgtypedefXXXXX");
	print $filter_typedefs_fh @typedefs;
	$filter_typedefs_fh->close();

	# temp file remains because we return a file handle reference
	return $filter_typedefs_fh;
}

sub process_exclude
{
	foreach my $excl (@excludes)
	{
		last unless @files;
		open(my $eh, '<', $excl)
		  || die "cannot open exclude file \"$excl\"\n";
		while (my $line = <$eh>)
		{
			chomp $line;
			next if $line =~ m/^#/ || $line eq "";
			my $rgx = qr!$line!;
			@files = grep { $_ !~ /$rgx/ } @files if $rgx;
		}
		close($eh);
	}
	return;
}

sub read_source
{
	my $source_filename = shift;
	my $source;

	open(my $src_fd, '<', $source_filename)
	  || die "cannot open file \"$source_filename\": $!\n";
	local ($/) = undef;
	$source = <$src_fd>;
	close($src_fd);

	return $source;
}

sub write_source
{
	my $source = shift;
	my $source_filename = shift;

	open(my $src_fh, '>', $source_filename)
	  || die "cannot open file \"$source_filename\": $!\n";
	print $src_fh $source;
	close($src_fh);
	return;
}

sub pre_indent
{
	my $source = shift;

	## Comments

	# Convert // comments to /* */
	$source =~ s!^([ \t]*)//(.*)$!$1/* $2 */!gm;

	# Adjust dash-protected block comments so indent won't change them
	$source =~ s!/\* +---!/*---X_X!g;

	## Other

	# Prevent indenting of code in 'extern "C"' blocks.
	# we replace the braces with comments which we'll reverse later
	my $extern_c_start = '/* Open extern "C" */';
	my $extern_c_stop = '/* Close extern "C" */';
	$source =~
	  s!(^#ifdef[ \t]+__cplusplus.*\nextern[ \t]+"C"[ \t]*\n)\{[ \t]*$!$1$extern_c_start!gm;
	$source =~ s!(^#ifdef[ \t]+__cplusplus.*\n)\}[ \t]*$!$1$extern_c_stop!gm;

	# Protect wrapping in CATALOG()
	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;

	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
	# indentation. (The recursive regex comes from the perlre documentation; it
	# matches balanced parentheses as group $1 and the contents as group $2.)
	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
	$source =~
	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;

	return $source;
}

sub post_indent
{
	my $source = shift;

	# Restore CATALOG lines
	$source =~ s!^/\*(CATALOG\(.*)\*/$!$1!gm;

	# Put back braces for extern "C"
	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
	$source =~ s!^/\* Close extern "C" \*/$!}!gm;

	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
	# markers may have been re-indented.
	$source =~
	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;

	## Comments

	# Undo change of dash-protected block comments
	$source =~ s!/\*---X_X!/* ---!g;

	# Fix run-together comments to have a tab between them
	$source =~ s!\*/(/\*.*\*/)$!*/\t$1!gm;

	## Functions

	# Use a single space before '*' in function return types
	$source =~ s!^([A-Za-z_]\S*)[ \t]+\*$!$1 *!gm;

	return $source;
}

sub run_indent
{
	my $source = shift;
	my $error_message = shift;

	my $cmd = "$indent $indent_opts -U" . $filtered_typedefs_fh->filename;

	my $tmp_fh = new File::Temp(TEMPLATE => "pgsrcXXXXX");
	my $filename = $tmp_fh->filename;
	print $tmp_fh $source;
	$tmp_fh->close();

	$$error_message = `$cmd $filename 2>&1`;

	return "" if ($? || length($$error_message) > 0);

	unlink "$filename.BAK";

	open(my $src_out, '<', $filename) || die $!;
	local ($/) = undef;
	$source = <$src_out>;
	close($src_out);

	return $source;
}

sub diff
{
	my $indented = shift;
	my $source_filename = shift;

	my $post_fh = new File::Temp(TEMPLATE => "pgdiffXXXXX");
	my $post_fh_filename = $post_fh->filename;

	print $post_fh $indented;

	$post_fh->close();

	my $diff = `diff -upd "$source_filename" "$post_fh_filename"  2>&1`;
	return $diff;
}

sub usage
{
	my $message = shift;
	my $helptext = <<'EOF';
Usage:
pgindent [OPTION]... [FILE|DIR]...
Options:
	--help                  show this message and quit
	--commit=gitref         use files modified by the named commit
	--typedefs=FILE         file containing a list of typedefs
	--list-of-typedefs=STR  string containing typedefs, space separated
	--excludes=PATH         file containing list of filename patterns to ignore
	--indent=PATH           path to pg_bsd_indent program
	--diff                  show the changes that would be made
	--check                 exit with status 2 if any changes would be made
The --excludes and --commit options can be given more than once.
EOF
	if ($help)
	{
		print $helptext;
		exit 0;
	}
	else
	{
		print STDERR "$message\n", $helptext;
		exit 1;
	}
}

# main

$filtered_typedefs_fh = load_typedefs();

check_indent();

my $wanted = sub {
	my ($dev, $ino, $mode, $nlink, $uid, $gid);
	(($dev, $ino, $mode, $nlink, $uid, $gid) = lstat($_))
	  && -f _
	  && /^.*\.[ch]\z/s
	  && push(@files, $File::Find::name);
};

# any non-option arguments are files or directories to be processed
File::Find::find({ wanted => $wanted }, @ARGV) if @ARGV;

# commit file locations are relative to the source root
chdir "$sourcedir/../../.." if @commits && $sourcedir;

# process named commits by comparing each with their immediate ancestor
foreach my $commit (@commits)
{
	my $prev = "$commit~";
	my @affected = `git diff --diff-filter=ACMR --name-only $prev $commit`;
	die "git error" if $?;
	chomp(@affected);
	push(@files, @affected);
}

warn "No files to process" unless @files;

# remove excluded files from the file list
process_exclude();

my %processed;
my $status = 0;

foreach my $source_filename (@files)
{
	# skip duplicates
	next if $processed{$source_filename};
	$processed{$source_filename} = 1;

	# ignore anything that's not a .c or .h file
	next unless $source_filename =~ /\.[ch]$/;

	# don't try to indent a file that doesn't exist
	unless (-f $source_filename)
	{
		warn "Could not find $source_filename";
		next;
	}
	# Automatically ignore .c and .h files that correspond to a .y or .l
	# file.  indent tends to get badly confused by Bison/flex output,
	# and there's no value in indenting derived files anyway.
	my $otherfile = $source_filename;
	$otherfile =~ s/\.[ch]$/.y/;
	next if $otherfile ne $source_filename && -f $otherfile;
	$otherfile =~ s/\.y$/.l/;
	next if $otherfile ne $source_filename && -f $otherfile;

	my $source = read_source($source_filename);
	my $orig_source = $source;
	my $error_message = '';

	$source = pre_indent($source);

	$source = run_indent($source, \$error_message);
	if ($source eq "")
	{
		print STDERR "Failure in $source_filename: " . $error_message . "\n";
		$status = 3;
		next;
	}

	$source = post_indent($source);

	if ($source ne $orig_source)
	{
		if (!$diff && !$check)
		{
			write_source($source, $source_filename);
		}
		else
		{
			if ($diff)
			{
				print diff($source, $source_filename);
			}

			if ($check)
			{
				$status ||= 2;
				last unless $diff;
			}
		}
	}
}

exit $status;
