#!/usr/bin/perl

use v5.36;
use File::Spec;
use File::Temp qw( tempdir );
use Debian::Debhelper::Dh_Lib;


my %SHELLS = (
	bash => '/usr/share/bash-completion/completions',
	fish => '/usr/share/fish/vendor_completions.d',
	zsh => '/usr/share/zsh/vendor-completions',
);

use constant MODE_UNKNOWN => '(unknown)';

=head1 NAME

dh_shell_completions - install shell completions for package

=head1 SYNOPSIS

B<dh_shell_completions> [S<I<debhelper options>>]

=head1 DESCRIPTION

dh_shell_completions is a debhelper addon for installing completion scripts for
multiple shells, respecting conventions of each shell.

It currently supports B<bash>, B<fish>, and B<zsh>.

To use it, B<Build-Depends> on it, and pass B<--with shell_completions> to
debhelper.

There are several modes of action, listed in B<MODES>. The first has highest
precedence, the last lowest.

For all modes, mentioned files can have comment lines, starting with `#`.

I<$package> in file names, if omitted, will be the first binary package defined
in debian/control.

=head1 MODES

=head2 Generation

If a file named I<debian/[$package.]shell-completions> exists, and its first
non-comment line is:

	mode=gen

Then the remaining lines should be one or more stanzas, each in the format:

	command=command-to-generate --completions _SHELL_ [--write-to _PATH_]
	shells=bash,fish,zsh
	name=foo

=over

=item I<command>

command to generate a completion script. Assumed to print to stdout
unless it contains the I<_PATH_> placeholder.

B<_SHELL_> is a placeholder for each shell in I<shells>.

B<_PATH_> is an optional placeholder for the path to write the completion
script to, if the command works that way.

=item I<shells>

a comma separated list of supported shell names to generate for.

=item I<name>

an optional line to specify the name of generated completions other than
I<$package>.

=back

I<command> will be run for each I<_SHELL_> specified in I<shells>, and installs
the resulting completions with specified I<name>.

=head2 Composite

If a file named I<debian/[$package.]shell-completions> exists, and is in the
format:

	$shell completions/foo      # installed as foo
	$shell completions/bar baz  # installed as baz

then for each line, the referenced file will be installed as a completion
script for I<$shell>, named as the comments suggest.

=head2 Separate

If a file named I<debian/[$package.]$shell-completions> exists for a supported
I<$shell>, then one of two actions are taken, depending on its content.

If it's of the following format:

	completions/foo               # installed as foo
	completions/bar baz           # installed as baz

then the referenced file(s) will be installed as completion(s) for I<$shell>,
named as the comments above suggest.

Otherwise, it's taken as a full completion script, and installed for I<$shell>,
named I<$package>. This is in line with dh_bash-completion.

=cut

# A simple, probably naive heuristic: if no line in the file has more than 3
# "words" separated by whitespace, it's considered a file list. Shell scripts,
# especially completions scripts, should normally have lines that has more than
# 3 words.
sub is_file_list($path) {
	open(FILE, '<', $path) || error("cannot read $path: $!");
	while(<FILE>) {
		s/^\s+//;
		next if /^($|#)/;
		chomp;
		
		my @split = split(/\s+/, $_, 4);
		if(defined($split[3])) {
			close(FILE);
			verbose_print("$path is not file list");
			return 0;
		}
	}
	close(FILE);
	verbose_print("$path is file list");
	return 1;
}

sub check_mode($comp_list) {
	open(FILE, '<', $comp_list) || error("cannot read $comp_list: $!");
	while (<FILE>) {
		s/^\s+//;
		next if /^($|#)/;
		chomp;

		if(/^mode\s*=/) {
			s/^mode\s*=\s*//;
			return $_;
		}
	}
	close(FILE);
	return MODE_UNKNOWN;
}

sub install_completion($name, $shell, $comp_dir, $comp_file) {
	$name = "$name.fish" if $shell eq 'fish' and $name !~ /\.fish$/;
	$name = "_$name" if $shell eq 'zsh' and $name =~ /^[^_]/;
	install_dir($comp_dir);
	install_file($comp_file, File::Spec->catfile($comp_dir, $name));
}

sub process_completion_list($pkg, $shell, $comp_list) {
	if(defined($shell) and !is_file_list($comp_list)) {
		verbose_print("shell is defined: $shell, $comp_list is not file list, installing it as completion");
		my $comp_dir = File::Spec->catdir(tmpdir($pkg), $SHELLS{$shell});
		return install_completion($pkg, $shell, $comp_dir, $comp_list);
	}

	verbose_print("shell is undefined or $comp_list seems like file list, processing as file list");
	open(FILE, '<', $comp_list) || error("cannot read $comp_list: $!");
	while (<FILE>) {
		s/^\s+//;
		next if /^($|#)/;
		chomp;

		my @split = split(/\s+/, $_, 3);
		my $this_shell = $shell || shift(@split);
		error("$comp_list line $.: unknown shell $this_shell")
			unless defined($SHELLS{$this_shell});
		my $comp_file = $split[0];
		my $name = $split[1] || $pkg;
		verbose_print("parsed file list line: shell $this_shell, comp file $comp_file, install name $name");
		my $comp_dir = File::Spec->catdir(tmpdir($pkg), $SHELLS{$this_shell});
		install_completion($name, $this_shell, $comp_dir, $comp_file);
	}
	close(FILE);
}

sub process_gen($pkg, $comp_list) {
	open(FILE, '<', $comp_list) || error("cannot read $comp_list: $!");

	my @stanzas = ();

	my $command;
	my @shells;
	my $name;

	while(<FILE>) {
		s/^\s+//;
		next if /^($|#)/;
		next if /^mode\s*=/;
		chomp;

		error("$comp_list mode=gen line $.: empty value")
			if /=\s*$/;

		if(/^command\s*=/) {
			s/^command\s*=\s*//;
			error("$comp_list mode=gen line $.: command `$_` lacks placeholder _SHELL_")
				unless /_SHELL_/;

			$command = $_ and next
				unless defined($command);

			if(@shells == 0) {
				my $ln = $. - 1;
				error("$comp_list mode=gen line $ln: stanza lacks shells list");
			}
			$name = $pkg
				unless defined($name);

			my $stanza = {command => $command, name => $name};
			@{$stanza->{shells}} = @shells;
			push(@stanzas, $stanza);

			$command = $_;
			@shells = ();
			$name = undef;
			next;
		}

		if(/^shells\s*=/) {
			s/^shells\s*=\s*//;
			@shells = split(/,/, $_);
			foreach my $shell (@shells) {
				error("$comp_list mode=gen line $.: unknown shell $shell")
					unless defined($SHELLS{$shell});
			}
			next;
		}

		if(/^name\s*=/) {
			s/^name\s*=\s*//;
			$name = $_;
			next;
		}

		error("$comp_list mode=gen line $.: unknown line `$_`");
	}

	if(defined($command) and @shells != 0) {
		my $stanza = {command => $command, name => $name};
		@{$stanza->{shells}} = @shells;
		push(@stanzas, $stanza);
		my $name_print = $name || '';
		verbose_print("mode=gen stanza: name=$name_print, command=`$command`, shells=@shells");
	}

	error("$comp_list mode=gen: no stanza defined")
		if @stanzas == 0;

	my $dir = tempdir("dh-shell-completions.gen.XXXX", CLEANUP => 1, DIR => "debian");
	
	foreach my $stanza (@stanzas) {
		my $name = $stanza->{name} || $pkg;
		foreach my $shell (@{$stanza->{shells}}) {
			my $out = File::Spec->catdir($dir, "$name.$shell");
			my $err = File::Spec->catdir($dir, "$name.$shell.err");
			my $command = $stanza->{command};
			$command =~ s/_SHELL_/$shell/g;
			if($command =~ /_PATH_/) {
				$command =~ s/_PATH_/$out/g;
			} else {
				$command .= " >$out";
			}

			nonquiet_print("mode=gen run: name=$name command=`$command`");
			my $result = `$command 2>$err`;

			if($? == -1) {
				error("$comp_list mode=gen: command `$command` failed: $!");
			} if($?) {
				open(my $efh, '<', $err);
				$/ = undef;
				my $stderr = <$efh>;
				my $code = $? >> 8;
				error("$comp_list mode=gen: command `$command` failed, code $code:\n$stderr");
			}
			my $comp_dir = File::Spec->catdir(tmpdir($pkg), $SHELLS{$shell});
			install_completion($name, $shell, $comp_dir, $out);
		}
	}
}

sub main {
	foreach my $pkg (@{$dh{DOPACKAGES}}) {
		next if is_udeb($pkg);

		foreach my $shell (keys %SHELLS) {
			my $comp_list = pkgfile($pkg, "$shell-completions");
			next unless -f $comp_list;
			verbose_print("processing $comp_list");
			process_completion_list($pkg, $shell, $comp_list);
		}

		my $comp_list = pkgfile($pkg, "shell-completions");
		next unless -f $comp_list;
		verbose_print("processing $comp_list");
		my $mode = check_mode($comp_list);
		verbose_print("$comp_list mode is $mode");

		process_completion_list($pkg, undef, $comp_list)
			and next if $mode eq MODE_UNKNOWN;

		# given/when is deprecated so we resort to this for now.
		if($mode eq 'gen') {
			process_gen($pkg, $comp_list);
		} else {
			error("$comp_list: unknown mode `$mode`");
		}
	}
}

init();
main();

=head1 SEE ALSO

L<debhelper(1)>

=head1 AUTHOR

Blair Noctis <ncts@debian.org>

=cut
