conf

conf - my dotfiles manager
git clone git://git.alex.balgavy.eu/conf.git
Log | Files | Refs | README | LICENSE

conf (11386B)


      1 #!/usr/bin/perl
      2 use 5.006;
      3 use strict;
      4 use warnings;
      5 use Getopt::Long 'GetOptions';
      6 use Pod::Usage 'pod2usage';
      7 use Cwd 'realpath';
      8 use File::Copy 'move';
      9 use File::Path 'make_path';
     10 
     11 # Set these however you want
     12 my $DOTFILES = $ENV{'DOTFILES'};
     13 my $MAPFILE = $DOTFILES."/dot.map";
     14 
     15 =head1 NAME
     16 
     17 conf - Manage your dotfiles.
     18 
     19 =head1 SYNOPSIS
     20 
     21   conf [options] command [entry_1 [entry_2 ...]]
     22 
     23   Help Options:
     24     --help, -h        Show this script's help information.
     25     --manual, -man    Read this script's manual.
     26 
     27   Arguments:
     28     link              Link an entry.
     29     unlink            Unlink an entry.
     30     check             Check an entry.
     31     list              List entries.
     32     edit              Edit the map file.
     33 
     34 =cut
     35 
     36 # Some preliminary checks
     37 die "\$DOTFILES not set." if not $DOTFILES;
     38 chdir "$DOTFILES" or die "Cannot cd into $DOTFILES.";
     39 (-e $MAPFILE) or die "Mapfile $MAPFILE does not exist.";
     40 
     41 # Print with a newline
     42 sub puts {
     43   print $_[0]."\n";
     44 }
     45 
     46 # Ask a confirmation question, return the boolean answer
     47 sub confirm {
     48   # Print the question
     49   my $question = $_[0];
     50   print $question." [Y/n] ";
     51 
     52   # Obtain and clean the response
     53   my $response = <>;
     54   chomp $response;
     55 
     56   # Check if the response is a "yes", return a boolean
     57   return ($response =~ /^\s*([Yy]|[Yy][Ee][Ss])\b/);
     58 }
     59 
     60 # Print a message to stderr and exit
     61 sub Die {
     62   print STDERR $_[0]."\n";
     63   exit 1;
     64 }
     65 
     66 # Does a line contain a mapping?
     67 sub IsMapLine {
     68   # It's a mapping if it's not blank or a comment
     69   return ($_[0] !~ /^\s*$/ and $_[0] !~ /^\s*\#/ and $_[0] !~ /^$/)
     70 }
     71 
     72 # Parse a single map line into a standalone key-value mapping
     73 sub ParseLine {
     74   # Split the line on colons
     75   my @mapline = split(':', $_[0]);
     76 
     77   # Grab the home directory
     78   my $homedir = $ENV{'HOME'};
     79 
     80   # Remove leading/trailing whitespace for both parts of the mapline
     81   s/^\s*|\s*$//g for @mapline;
     82 
     83   # Replace '~' with the full path of $HOME
     84   $mapline[1] =~ s/\~/$homedir/;
     85 
     86   # Return a reference to the parsed mapline
     87   return \@mapline;
     88 }
     89 
     90 # Parse the whole mapfile, return a reference to the key-value hash
     91 sub ParseMapfile {
     92   my (@nestdir, %mappings);
     93   my $lineno = 1;
     94 
     95   # Open up the mapfile for reading, using the handle MAP
     96   open(MAP, "<$_[0]") or Die("Couldn't open the file $_[0], $!");
     97 
     98   # While not EOF
     99   while(my $line = <MAP>) {
    100     if (IsMapLine($line)) {
    101       # Parse the mapline
    102       my ($src, $dst) = @{ParseLine($line)};
    103 
    104       # If top level mapping
    105       if ($src !~ /^-+/) {
    106         # Zero out the nesting directory
    107         @nestdir = ();
    108 
    109         # If the line defines a new nesting dir, remember it
    110         if (not defined $dst or $dst eq '') {
    111           push @nestdir, $src
    112         }
    113 
    114         # Otherwise, it's a one-off mapping (doesn't have related nested mappings below)
    115         else {
    116           $mappings{"$src"} = $dst;
    117         }
    118       }
    119 
    120       # Nested mappings
    121       else {
    122         # Number of dashes == number of levels
    123         my $levels = length( ($src =~ /^(-+)/)[0] );
    124 
    125         # Remove the punctuation to get the actual source
    126         $src =~ s/(^-* *)|://g;
    127 
    128         # If/while the mapping is at a higher level than the previous, unnest
    129         if ($levels < scalar @nestdir) {
    130           pop @nestdir while ($levels < scalar @nestdir);
    131         }
    132 
    133         # If the mapping's another nesting dir, add it
    134         if (not defined $dst or $dst eq '') {
    135           push @nestdir, $src
    136         }
    137         # Otherwise, if it's a mapping, save it with the full path
    138         else {
    139           my $fullsrc = join("/", @nestdir)."/$src";
    140           # If it doesn't exist, can't map
    141           $mappings{"$fullsrc"} = $dst;
    142         }
    143       }
    144     }
    145     # Increase the line number to keep track of where we are in the mapfile (error reporting)
    146     $lineno++;
    147   }
    148 
    149   # Return a ref to the hash of mappings
    150   return \%mappings;
    151 }
    152 
    153 # Dump a hash, for debugging purposes
    154 sub PrettyPrint {
    155   puts "hash length: ".keys(%{$_[0]});
    156   use Data::Dumper;
    157   print Dumper $_[0];
    158 }
    159 
    160 # Link to a source path at a destination path
    161 sub MakeLinkOp {
    162   my ($src, $dst) = @_;
    163 
    164   if (not -e $src) {
    165     Die("error in mapfile: $src does not exist");
    166   }
    167 
    168   # If the destination exists
    169   if (-e $dst) {
    170     # If it's a link and points to the source, nothing to do here
    171     if (-l $dst and realpath($dst) eq $DOTFILES."/".$src) {
    172       puts "$dst is already linked to $src";
    173       return;
    174     }
    175     # Otherwise, if it's not a link, back it up
    176     else {
    177       puts "$dst already exists, renaming to $dst.bak";
    178       move $dst, $dst.".bak";
    179     }
    180   }
    181 
    182   # Create the intermediary dirs if they don't exist
    183   (my $linkpath = $dst) =~ s:/[^/]*$::;
    184   if (! -d $linkpath) {
    185     make_path($linkpath) or die "Couldn't make path $linkpath, $!";
    186   }
    187 
    188   # Print a message and link to the source at the destination
    189   puts "$src ==> $dst";
    190   symlink($DOTFILES."/".$src, $dst) or die "Couldn't symlink, $!";
    191 }
    192 
    193 # Remove a linked source path
    194 sub RmLinkOp {
    195   my ($src, $dst) = @_;
    196 
    197   if (not -e $src) {
    198     Die("error in mapfile: $src does not exist");
    199   }
    200 
    201   # If the destination doesn't exist, nothing to do here.
    202   if (! -e "$dst") {
    203     puts "$dst does not exist.";
    204   }
    205   # If it exists, but it's not a link, don't do anything.
    206   elsif (! -l "$dst") {
    207     puts "$dst is not a link, not removing.";
    208   }
    209   # If it's a link, but doesn't point to the config file, don't do anything.
    210   elsif (not realpath($dst) eq $DOTFILES."/".$src) {
    211     puts "$dst does not point to $src, not removing.";
    212   }
    213   # Otherwise, it's a link that points to the config file, so remove it.
    214   else {
    215     puts "Removing link: $dst";
    216     unlink "$dst" or die "Failed to remove file $dst: $!\n";
    217   }
    218 }
    219 
    220 # Check whether a link points to the config file
    221 sub CheckLinkOp {
    222   my ($src, $dst) = @_;
    223   if (not -e $src) {
    224     Die("error in mapfile: $src does not exist");
    225   }
    226 
    227   if (-e $dst) {
    228     if (-l $_[1] and realpath($_[1]) eq $DOTFILES."/".$_[0]) {
    229       puts "[ OK ] $src is linked at $dst.";
    230     }
    231     elsif (-l $dst) {
    232       puts "[ XX ] $dst is a link but does not point to $src.";
    233     }
    234     else {
    235       puts "[ XX ] $dst exists but does not point to $src.";
    236     }
    237   }
    238   else {
    239     puts "[ XX ] $src is not linked.";
    240   }
    241 }
    242 
    243 # Execute a link operation on maps in ARGV
    244 sub ExecLinkOp {
    245   # Retrieve the reference to map hash and the function to run
    246   my ($maps, $opref) = @_;
    247 
    248   # If operation should be on all tracked files
    249   if (not @ARGV) {
    250     keys %$maps; # reset `each` iterator
    251     foreach my $src (keys %$maps) {
    252       $opref->($src, $maps->{$src});
    253     }
    254   }
    255   # If the user specified what to operate on
    256   else {
    257     while (@ARGV) {
    258       my $src_part = shift @ARGV;
    259 
    260       # if there's a mapping that matches exactly, operate on that
    261       if (exists $maps->{$src_part}) {
    262         $opref->($src_part, $maps->{$src_part});
    263       }
    264       # otherwise, operate on everything starting with whatever's passed
    265       elsif ( my @matching = grep(/^$src_part/, keys %$maps) ) {
    266         foreach my $src(@matching) {
    267           $opref->($src, $maps->{$src});
    268         }
    269       }
    270       # if nothing matches, fail
    271       else {
    272         my %opnametab = (\&MakeLinkOp => 'link', \&RmLinkOp => 'unlink', \&CheckLinkOp => 'check');
    273         Die "Error: $src_part not present in mapfile, don't know how to ".$opnametab{$opref}.'.';
    274       }
    275     }
    276   }
    277 }
    278 
    279 # Link command
    280 sub LinkCmd {
    281   # If no args, default to linking all but confirm with user
    282   if (not @ARGV and !confirm("Link all?")) {
    283       Die "User cancelled.";
    284   }
    285 
    286   ExecLinkOp($_[0], \&MakeLinkOp);
    287 }
    288 
    289 # Unlink command
    290 sub UnlinkCmd {
    291   # If no args, default to unlinking all but confirm with user
    292   if (not @ARGV and !confirm("Unlink all?")) {
    293     Die "User cancelled";
    294   }
    295   ExecLinkOp($_[0], \&RmLinkOp);
    296 }
    297 
    298 # Edit command
    299 sub EditCmd {
    300   # Set a default editor value
    301   $ENV{EDITOR} ||= 'vim';
    302 
    303   # system() doesn't capture stdout
    304   system "$ENV{EDITOR} $MAPFILE";
    305 }
    306 
    307 sub CheckCmd {
    308   ExecLinkOp($_[0], \&CheckLinkOp);
    309 }
    310 
    311 sub ListCmd {
    312   puts "Mappings (from $MAPFILE), paths relative to $DOTFILES";
    313   puts "(format: source => name_of_symlink)\n";
    314 
    315   my $hash = $_[0];
    316   my @output = ();
    317   foreach my $k (keys %$hash) {
    318     push @output, "$k => $hash->{$k}";
    319   }
    320   foreach my $l (sort @output) {
    321     puts $l;
    322   }
    323 }
    324 
    325 # Run a subcommand based on string
    326 sub RunSubcommand {
    327   # Dispatch table
    328   my %subcommands = (
    329     link => \&LinkCmd,
    330     unlink => \&UnlinkCmd,
    331     edit => \&EditCmd,
    332     list => \&ListCmd,
    333     check => \&CheckCmd
    334   );
    335 
    336   # If the command isn't in the table, exit and print usage
    337   $subcommands{$_[0]} or pod2usage(2);
    338 
    339   if ($_[0] eq 'edit') {
    340     $subcommands{$_[0]}->();
    341   }
    342   else {
    343     # Parse the file
    344     my $mappings = ParseMapfile($MAPFILE);
    345     # Then execute the command on the extracted mappings
    346     $subcommands{$_[0]}->($mappings);
    347   }
    348 }
    349 
    350 # If no arguments, show usage
    351 scalar @ARGV > 0 or pod2usage(2);
    352 
    353 # Get the commandline options, only recognise help/manual, everything else gets sent to dispatch subroutine
    354 my ($help, $manual);
    355 my %optctl = (help => \$help, manual => \$manual, '<>' => \&RunSubcommand);
    356 GetOptions(\%optctl, 'help|h', 'manual|man', '<>') or pod2usage(2);
    357 
    358 =head1 OPTIONS
    359 
    360 =over 4
    361 
    362 =item B<--help>
    363 Show the brief help information.
    364 
    365 =item B<--manual>
    366 Read the manual, with examples.
    367 
    368 =back
    369 =cut
    370 
    371 # If help set, show usage
    372 pod2usage(2) if $help;
    373 # If manual set, show full documentation
    374 pod2usage({ -verbose => 2, -exitval => 1}) if $manual;
    375 
    376 =head1 DESCRIPTION
    377 
    378 This is a script to manage filesystem-wide symbolic links to your dotfiles (configuration), based on definitions in the map file.
    379 The location of the map file is set in the script itself, using the $MAPFILE variable.
    380 The default name of the mapfile is "dot.map", and it is located in the root of your dotfiles folder.
    381 The location of your dotfiles is set either using the $DOTFILES environment variable, or inside the script itself.
    382 
    383 Every existing file will be backed up by appending the extension '.bak', before being overwritten.
    384 If a directory in the destination path doesn't exist, it is automatically created.
    385 `conf` doesn't remove empty directories after unlinking.
    386 When linking/unlinking you either provide the name of the top directory, or the full name of the mapped path (see examples below for more information).
    387 
    388 =head1 ARGUMENTS
    389 
    390 The available commands are:
    391 
    392 =over 4
    393 
    394 =item * B<link [entry1 [entry2...]]>
    395 
    396 Link entries according to the map file.
    397 With no arguments, links all entries.
    398 
    399 =item * B<unlink [entry1 [entry2...]]>
    400 
    401 Unlink entries according to the map file.
    402 With no arguments, unlinks all entries.
    403 
    404 =item * B<check [entry1 [entry2...]]>
    405 
    406 Check that entries are linked accordint to the map file.
    407 With no arguments, checks all entries.
    408 
    409 =item * B<edit>
    410 
    411 Edit the map file with whatever you set as $EDITOR
    412 
    413 =item * B<list>
    414 
    415 List the current mappings.
    416 
    417 =back
    418 
    419 =head1 EXAMPLES
    420 
    421 Link everything in the mapfile:
    422 
    423   conf link
    424 
    425 Link all files in the "vim" directory:
    426 
    427   conf link vim
    428 
    429 Link specifically the shell/bashrc file:
    430 
    431   conf link shell/bashrc
    432 
    433 Link the shell/zprofile file and the vim/autoload directory:
    434 
    435   conf link shell/zprofile vim/autoload
    436 
    437 Check if everything is linked:
    438 
    439   conf check
    440 
    441 Check if everything in the "vim" directory is linked:
    442 
    443   conf check vim
    444 
    445 Remove the link to the "lf" directory:
    446 
    447   conf unlink lf
    448 
    449 Remove all links defined in the mapfile:
    450 
    451   conf unlink
    452 
    453 =head1 AUTHOR
    454 
    455 Alexander Balgavy (thezeroalpha), L<https://github.com/thezeroalpha>.
    456 
    457 =cut