dotfiles

My personal shell configs and stuff
git clone git://git.alex.balgavy.eu/dotfiles.git
Log | Files | Refs | Submodules | README | LICENSE

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:
Mscripts/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