commit 508693bbe10034212843e6ba004938a427829af3
parent 6e072dfa5439a38abedaf35d89b6d8b49e69deea
Author: Alex Balgavy <a.balgavy@gmail.com>
Date: Fri, 31 Jan 2020 20:41:44 +0100
conf: rewrite in Perl
Former-commit-id: 2c6952e0dfbfb9fb9d3fd251a356fdcf6d80ae49
Diffstat:
M | scripts/conf | | | 679 | +++++++++++++++++++++++++++++++++++++++++++++---------------------------------- |
1 file changed, 384 insertions(+), 295 deletions(-)
diff --git a/scripts/conf b/scripts/conf
@@ -1,331 +1,420 @@
-#!/usr/bin/env bash
-# TODO: fix linking e.g. "emacs/emacs" vs "emacs/emacs.d" vs "emacs",
-# convert to posix shell
-# Set the dir for your dotfiles, mine comes from the environment
-DOTFILES="${DOTFILES}";
-
-# Set the name of the mapfile, located in the root folder of your dotfiles
-mapfile="dot.map";
-
-# Begin script
-
-# Create the mappings assoc. array
-declare -A mappings;
-
-# Utility function to pretty-print mappings assoc array
-pp_mappings() {
- echo "{"
- for f in "${!mappings[@]}"; do
- echo " ${f} => ${mappings[${f}]}";
- done
- echo "}"
+#!/usr/bin/perl
+use strict;
+use warnings;
+use Getopt::Long 'GetOptions';
+use Pod::Usage 'pod2usage';
+use Cwd 'realpath';
+use File::Copy 'move';
+use File::Path 'make_path';
+
+# Set these however you want
+my $DOTFILES = $ENV{'DOTFILES'};
+my $MAPFILE = $DOTFILES."/dot.map";
+
+=head1 NAME
+
+conf - Manage your dotfiles.
+
+=head1 SYNOPSIS
+
+ conf [options] command [entry_1 [entry_2 ...]]
+
+ Help Options:
+ --help, -h Show this script's help information.
+ --manual, -man Read this script's manual.
+
+ Arguments:
+ link Link an entry.
+ unlink Unlink an entry.
+ check Check an entry.
+ list List entries.
+ edit Edit the map file.
+
+=cut
+
+# Some preliminary checks
+die "\$DOTFILES not set." if not $DOTFILES;
+chdir "$DOTFILES" or die "Cannot cd into $DOTFILES.";
+(-e $MAPFILE) or die "Mapfile $MAPFILE does not exist.";
+
+# Print with a newline
+sub puts {
+ print $_[0]."\n";
+}
+
+# Ask a confirmation question, return the boolean answer
+sub confirm {
+ # Print the question
+ my $question = $_[0];
+ print $question." [Y/n] ";
+
+ # Obtain and clean the response
+ my $response = <>;
+ chomp $response;
+
+ # Check if the response is a "yes", return a boolean
+ return ($response =~ /^\s*([Yy]|[Yy][Ee][Ss])\b/);
}
-die() {
- echo "$1" >&2;
+# Print a message to stderr and exit
+sub Die {
+ print STDERR $_[0]."\n";
exit 1;
}
-get_bash_version() {
- echo "${BASH_VERSION}" | cut -d'.' -f1;
+# Does a line contain a mapping?
+sub IsMapLine {
+ # It's a mapping if it's not blank or a comment
+ return ($_[0] !~ /^\s*$/ and $_[0] !~ /^\s*\#/ and $_[0] !~ /^$/)
}
-preliminary_checks() {
- [ "$(get_bash_version)" -ge 4 ] || die "Requires Bash >= 4";
+# Parse a single map line into a standalone key-value mapping
+sub ParseLine {
+ # Split the line on colons
+ my @mapline = split(':', $_[0]);
- # Don't want $DOTFILES expansion in the message
- # shellcheck disable=SC2016
- [ -z "${DOTFILES}" ] && die '$DOTFILES variable not set.';
- cd "${DOTFILES}" || die "Can't read ${DOTFILES}";
- [ -f "${mapfile}" ] || die "Mapfile ${mapfile} does not exist in $(pwd)";
+ # Grab the home directory
+ my $homedir = $ENV{'HOME'};
+
+ # Remove leading/trailing whitespace for both parts of the mapline
+ s/^\s*|\s*$//g for @mapline;
+
+ # Replace '~' with the full path of $HOME
+ $mapline[1] =~ s/\~/$homedir/;
+
+ # Return a reference to the parsed mapline
+ return \@mapline;
}
-parse_mapfile() {
- local nestdir="";
- local lineno=1;
- local nestlevel=0;
- homedir="$(echo -n "${HOME}" | tr -d '\n\r')";
-
- while read -r map; do
- if [ -n "${map}" ] && [[ ! "${map}" == "#"* ]]; then
-
- IFS=" " read -r -a mapping <<< "$(echo -n "${map}" | sed -e 's/^ *-* /-/' -e "s:~:${homedir}:" | awk -F ': ' '{ print $1 " " $2 }')";
-
- # Top level items
- if [[ ! "${mapping[0]}" == "-"* ]]; then
- nestdir="";
- nestlevel=0;
- if [ -z "${mapping[1]}" ]; then
- # nestdir
- nestdir="${mapping[0]/://}";
- else
- # one-off mapping
- if [ ! -e "${nestdir}${mapping[0]}" ]; then
- die "error in mapfile: ${nestdir}${mapping[0]} does not exist (line ${lineno})";
- fi
- mappings["${nestdir}${mapping[0]}"]="${mapping[1]}";
- fi
- else
- levels="$(count_nestlevels "${mapping[0]}")";
- pth="$(echo "${mapping[0]}" | sed -e 's/^-*//g' -e 's/://')";
- if [ "${levels}" -lt "${nestlevel}" ]; then
- while [ "${levels}" -lt "${nestlevel}" ]; do
- nestdir="${nestdir%/*/}/";
- ((nestlevel--));
- done
- [ "${nestdir}" = "/" ] && nestdir="";
- elif [ "${levels}" -gt "${nestlevel}" ]; then
- while [ "${levels}" -gt "${nestlevel}" ]; do
- ((nestlevel++));
- done
- fi
- if [ -z "${mapping[1]}" ]; then
- nestdir="${nestdir}${pth}/";
- else
- if [ ! -e "${nestdir}${mapping[0]/-/}" ]; then
- die "error in mapfile: ${nestdir}${pth} does not exist (line ${lineno})";
- fi
- mappings["${nestdir}${pth}"]="${mapping[1]}";
- fi
- fi
- fi
- ((lineno++));
- done < <(cat "$1");
+# Parse the whole mapfile, return a reference to the key-value hash
+sub ParseMapfile {
+ my (@nestdir, %mappings);
+ my $lineno = 1;
+
+ # Open up the mapfile for reading, using the handle MAP
+ open(MAP, "<$_[0]") or Die("Couldn't open the file $_[0], $!");
+
+ # While not EOF
+ while(my $line = <MAP>) {
+ if (IsMapLine($line)) {
+ # Parse the mapline
+ my ($src, $dst) = @{ParseLine($line)};
+
+ # If top level mapping
+ if ($src !~ /^-+/) {
+ # Zero out the nesting directory
+ @nestdir = ();
+
+ # If the line defines a new nesting dir, remember it
+ if (not defined $dst or $dst eq '') {
+ push @nestdir, $src
+ }
+
+ # Otherwise, it's a one-off mapping (doesn't have related nested mappings below)
+ else {
+ # If the source doesn't exist, can't map
+ if (not -e $src) {
+ Die("error in mapfile: $src does not exist (line $lineno)");
+ }
+ # If it does, add it as a mapping
+ else {
+ $mappings{"$src"} = $dst;
+ }
+ }
+ }
+
+ # Nested mappings
+ else {
+ # Number of dashes == number of levels
+ my $levels = length( ($src =~ /^(-+)/)[0] );
+
+ # Remove the punctuation to get the actual source
+ $src =~ s/(^-* *)|://g;
+
+ # If/while the mapping is at a higher level than the previous, unnest
+ if ($levels < scalar @nestdir) {
+ pop @nestdir while ($levels < scalar @nestdir);
+ }
+
+ # If the mapping's another nesting dir, add it
+ if (not defined $dst or $dst eq '') {
+ push @nestdir, $src
+ }
+ # Otherwise, if it's a mapping, save it with the full path
+ else {
+ my $fullsrc = join("/", @nestdir)."/$src";
+ # If it doesn't exist, can't map
+ if (not -e $fullsrc) {
+ Die("error in mapfile: $fullsrc does not exist (line $lineno)");
+ }
+ # Otherwise, add the mapping
+ else {
+ $mappings{"$fullsrc"} = $dst;
+ }
+ }
+ }
+ }
+ # Increase the line number to keep track of where we are in the mapfile (error reporting)
+ $lineno++;
+ }
+
+ # Return a ref to the hash of mappings
+ return \%mappings;
}
-link_all() {
- for f in "${!mappings[@]}"; do
- do_link "${f}" "${mappings[${f}]}";
- done
+# Dump a hash, for debugging purposes
+sub PrettyPrint {
+ puts "hash length: ".keys($_[0]);
+ use Data::Dumper;
+ print Dumper $_[0];
}
-unlink_all() {
- for f in "${!mappings[@]}"; do
- do_unlink "${mappings[${f}]}";
- done
+# Link to a source path at a destination path
+sub MakeLinkOp {
+ my ($src, $dst) = @_;
+
+ # If the destination exists
+ if (-e $dst) {
+ # If it's a link and points to the source, nothing to do here
+ if (-l $dst and realpath($dst) eq $DOTFILES."/".$src) {
+ puts "$dst is already linked to $src";
+ return;
+ }
+ # Otherwise, if it's not a link, back it up
+ else {
+ puts "$dst already exists, renaming to $dst.bak";
+ move $dst, $dst.".bak";
+ }
+ }
+
+ # Create the intermediary dirs if they don't exist
+ (my $linkpath = $dst) =~ s:/[^/]*$::;
+ if (! -d $linkpath) {
+ make_path($linkpath) or die "Couldn't make path $linkpath, $!";
+ }
+
+ # Print a message and link to the source at the destination
+ puts "$src ==> $dst";
+ symlink($DOTFILES."/".$src, $dst) or die "Couldn't symlink, $!";
}
-find_mapping() {
- ( IFS=$'\n'; echo "${!mappings[*]}" ) | grep "^$1";
+# Remove a linked source path
+sub RmLinkOp {
+ my ($src, $dst) = @_;
+
+ # If the destination doesn't exist, nothing to do here.
+ if (! -e "$dst") {
+ puts "$dst does not exist.";
+ }
+ # If it exists, but it's not a link, don't do anything.
+ elsif (! -l "$dst") {
+ puts "$dst is not a link, not removing.";
+ }
+ # If it's a link, but doesn't point to the config file, don't do anything.
+ elsif (not realpath($dst) eq $DOTFILES."/".$src) {
+ puts "$dst does not point to $src, not removing.";
+ }
+ # Otherwise, it's a link that points to the config file, so remove it.
+ else {
+ puts "Removing link: $dst";
+ unlink "$dst" or die "Failed to remove file $dst: $!\n";
+ }
}
-link_specific() {
- if [ -n "$(find_mapping "$1")" ]; then
- for f in "${!mappings[@]}"; do
- if [[ "${f%%/*}" == "$1" ]] || [ "${f}" = "$1" ]; then
- do_link "${f}" "${mappings[${f}]}";
- fi
- done
- else
- die "Error: $1 not present in mapfile, don't know how to link.";
- fi
+# Check whether a link points to the config file
+sub CheckLinkOp {
+ my ($src, $dst) = @_;
+ if (-e $dst) {
+ if (-l $_[1] and realpath($_[1]) eq $DOTFILES."/".$_[0]) {
+ puts "[ OK ] $src is linked at $dst.";
+ }
+ elsif (-l $dst) {
+ puts "[ XX ] $dst is a link but does not point to $src.";
+ }
+ else {
+ puts "[ XX ] $dst exists but does not point to $src.";
+ }
+ }
+ else {
+ puts "[ XX ] $src is not linked.";
+ }
}
-unlink_specific(){
- if [ -n "$(find_mapping "$1")" ]; then
- for f in "${!mappings[@]}"; do
- if [[ "${f}" == "${i}"* ]]; then
- do_unlink "${mappings[${f}]}";
- fi
- done
- else
- die "Error: $1 not present in mapfile, can't unlink.";
- fi
+# Execute a link operation on maps in ARGV
+sub ExecLinkOp {
+ # Retrieve the reference to map hash and the function to run
+ my ($maps, $opref) = @_;
+
+ # If operation should be on all tracked files
+ if (not @ARGV) {
+ keys %$maps; # reset `each` iterator
+ foreach my $src (keys $maps) {
+ $opref->($src, $maps->{$src});
+ }
+ }
+ # If the user specified what to operate on
+ else {
+ while (@ARGV) {
+ my $src_part = shift @ARGV;
+
+ # if there's a mapping that matches exactly, operate on that
+ if (exists $maps->{$src_part}) {
+ $opref->($src_part, $maps->{$src_part});
+ }
+ # otherwise, operate on everything starting with whatever's passed
+ elsif ( my @matching = grep(/^$src_part/, keys $maps) ) {
+ foreach my $src(@matching) {
+ $opref->($src, $maps->{$src});
+ }
+ }
+ # if nothing matches, fail
+ else {
+ my %opnametab = (\&MakeLinkOp => 'link', \&RmLinkOp => 'unlink', \&CheckLinkOp => 'check');
+ Die "Error: $src_part not present in mapfile, don't know how to ".$opnametab{$opref}.'.';
+ }
+ }
+ }
}
-do_link() {
- if [ -e "$2" ]; then
- if [ -L "$2" ] && [ "$(realpath "$2")" == "$(pwd)/$1" ]; then
- echo "$2 is already linked to $1.";
- return
- else
- echo "$2 already exists, renaming to $2.bak";
- mv "$2" "$2.bak";
- fi
- fi
- mkdir -vp "${2%/*}";
- ln -svf "$(pwd)/$1" "$2";
+# Link command
+sub LinkCmd {
+ # If no args, default to linking all but confirm with user
+ if (not @ARGV and !confirm("Link all?")) {
+ Die "User cancelled.";
+ }
+
+ ExecLinkOp($_[0], \&MakeLinkOp);
}
-do_unlink() {
- if [ ! -e "$1" ]; then
- echo "$1 does not exist.";
- elif [ ! -L "$1" ]; then
- echo "$1 is not a link, not removing.";
- else
- echo -n "Removing link: ";
- rm -v "$1";
- fi
+# Unlink command
+sub UnlinkCmd {
+ # If no args, default to unlinking all but confirm with user
+ if (not @ARGV and !confirm("Unlink all?")) {
+ Die "User cancelled";
+ }
+ ExecLinkOp($_[0], \&RmLinkOp);
}
-list_mappings() {
- mapstr="";
- for f in "${!mappings[@]}"; do
- mapstr="${mapstr}${f} => ${mappings[${f}]}\n";
- done
+# Edit command
+sub EditCmd {
+ # Set a default editor value
+ $ENV{EDITOR} ||= 'vim';
- echo -e "${mapstr}" | sort;
+ # system() doesn't capture stdout
+ system "$ENV{EDITOR} $MAPFILE";
}
-count_nestlevels() {
- echo -n "$1" | sed 's/^\([-]*\).*/\1/' | tr -d '\n' | wc -m | tr -d ' ';
+sub CheckCmd {
+ ExecLinkOp($_[0], \&CheckLinkOp);
}
-check_link() {
- if [ -n "$(find_mapping "$1")" ]; then
- for f in "${!mappings[@]}"; do
- if [[ "${f%%/*}" == "$1" ]] || [ "${f}" = "$1" ]; then
- src="$f";
- tgt="${mappings[${f}]}";
- if [ -e "$tgt" ]; then
- if [ -L "$tgt" ] && [ "$(realpath "$tgt")" == "$(pwd)/$src" ]; then
- echo -e "[ OK ] $src is linked at $tgt.";
- elif [ -L "$tgt" ]; then
- echo -e "[ XX ] $tgt is a link but does not point to $src.";
- else
- echo -e "[ XX ] $tgt exists but does not point to $src.";
- fi
- else
- echo -e "[ XX ] $src is not linked.";
- fi
- fi
- done
- else
- die "$1 does not map to anything.";
- fi
-}
+sub ListCmd {
+ puts "Mappings (from $MAPFILE), paths relative to $DOTFILES";
+ puts "(format: source => name_of_symlink)\n";
-check_all() {
- for f in "$@"; do
- check_link "$f"
- done
+ my $hash = $_[0];
+ my @output = ();
+ foreach my $k (keys $hash) {
+ push @output, "$k => $hash->{$k}";
+ }
+ foreach my $l (sort @output) {
+ puts $l;
+ }
}
-show_usage() {
- echo "Loading configuration from map file: $(realpath ${mapfile})";
- echo;
- echo "Usage:";
- echo "conf [options] [command] [entry1 [entry2...]]";
- echo;
- echo "Options:";
- echo " -h, --help Show help & usage";
- echo;
- echo "Commands:";
- echo " link [entry1 [entry2...]] Link entries according to the map file.";
- echo " With no arguments, links all entries.";
- echo;
- echo " unlink [entry1 [entry2...]] Unlink entries according to the map file.";
- echo " With no arguments, unlinks all entries.";
- echo;
- echo " edit Edit the map file with ${EDITOR:-whatever you set as \$EDITOR}";
- echo;
- echo " list List the current mappings.";
- echo;
+# Run a subcommand based on string
+sub RunSubcommand {
+ # Dispatch table
+ my %subcommands = (
+ link => \&LinkCmd,
+ unlink => \&UnlinkCmd,
+ edit => \&EditCmd,
+ list => \&ListCmd,
+ check => \&CheckCmd
+ );
+
+ # If the command isn't in the table, exit and print usage
+ $subcommands{$_[0]} or pod2usage(2);
+
+ # Parse the file
+ my $mappings = ParseMapfile($MAPFILE);
+ # Then execute the command on the extracted mappings
+ $subcommands{$_[0]}->($mappings);
}
-preliminary_checks;
-
-PARAMS="";
-mode="";
-while (( "$#" )); do
- case "$1" in
- -h|--help)
- show_usage
- exit 0
- ;;
- --) # end arg parsing
- shift;
- break
- ;;
- -*) # unsupported flags
- echo "Unsupported flag $1" >&2;
- show_usage
- exit 1
- ;;
- *) # preserve positional arguments
- PARAMS="${PARAMS} $1";
- shift
- ;;
- esac
-done
-eval set -- "${PARAMS}";
-
-case "$1" in
- "link")
- mode="link";
- shift
- ;;
- "unlink")
- mode="unlink";
- shift
- ;;
- "check")
- mode="check";
- shift
- ;;
- "list")
- parse_mapfile "./${mapfile}";
- echo "Mappings (from $(realpath ${mapfile})):";
- echo "(format: source => name_of_symlink)";
- list_mappings;
- exit 0
- ;;
- "edit")
- # $EDITOR is an environment variable
- # shellcheck disable=SC2154
- echo "Opening ${mapfile} with ${EDITOR}";
- "${EDITOR}" "${DOTFILES}/${mapfile}";
- exit 0
- ;;
- *)
- ;;
-esac
-
-# Don't want the command to expand so
-# shellcheck disable=SC2016
-[ -z "$mode" ] && echo 'Arguments required.' && show_usage && die;
-
-parse_mapfile "./${mapfile}";
-if [ "$mode" = "link" ]; then
- if [ $# -eq 0 ]; then
- read -srp "Link all dotfiles? [Y/n]" -n 1 -s conf;
- case "${conf}" in
- Y|y)
- echo;
- link_all
- ;;
- *)
- ;;
- esac
- else
- for i in "$@"; do
- link_specific "${i}";
- done
- fi
-elif [ "$mode" = "unlink" ]; then
- if [ $# -eq 0 ]; then
- read -srp "Unlink all dotfiles? [Y/n]" -n 1 -s conf;
- case "${conf}" in
- Y|y)
- echo
- unlink_all;
- ;;
- *)
- ;;
- esac
- else
- for i in "$@"; do
- unlink_specific "${i}";
- done
- fi
-elif [ "$mode" = "check" ]; then
- if [ $# -eq 0 ]; then
- check_all "${!mappings[@]}";
- else
- for i in "$@"; do
- check_link "${i}";
- done
- fi
-else
- die "Neither mode not specified.";
-fi
+# If no arguments, show usage
+scalar @ARGV > 0 or pod2usage(2);
+
+# Get the commandline options, only recognise help/manual, everything else gets sent to dispatch subroutine
+my ($help, $manual);
+my %optctl = (help => \$help, manual => \$manual, '<>' => \&RunSubcommand);
+GetOptions(\%optctl, 'help|h', 'manual|man', '<>') or pod2usage(2);
+
+=head1 OPTIONS
+
+=over 4
+
+=item B<--help>
+Show the brief help information.
+
+=item B<--manual>
+Read the manual, with examples.
+
+=back
+=cut
+
+# If help set, show usage
+pod2usage(2) if $help;
+# If manual set, show full documentation
+pod2usage({ -verbose => 2, -exitval => 1}) if $manual;
+
+=head1 ARGUMENTS
+
+=over 4
+
+=item * B<link [entry1 [entry2...]]>
+
+Link entries according to the map file.
+With no arguments, links all entries.
+
+=item * B<unlink [entry1 [entry2...]]>
+
+Unlink entries according to the map file.
+With no arguments, unlinks all entries.
+
+=item * B<edit>
+
+Edit the map file with whatever you set as $EDITOR
+
+=item * B<list>
+
+List the current mappings.
+
+=back
+
+=cut
+
+=head1 EXAMPLES
+
+ The following is an example of this script:
+
+ pod-usage.pl --help
+
+=cut
+
+
+=head1 DESCRIPTION
+
+
+ This is a simple demonstration program for Pod::Usage, this text
+ will be displayed if the script is invoked with '--manual'.
+
+=cut
+
+
+=head1 AUTHOR
+
+
+ This should be here.
+
+=cut