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