4
\$\begingroup\$

I have this program for tagging directories and switching between the folders via tags. The front end is written in Bash (version 4.4.23) and the back end in Perl (5.26.1).

Critique request

Since this is my very first Perl program, I would like to hear any comments regarding how to improve it.

Code

ds_script.sh

out_file=$(mktemp);
perl ~/.ds/ds_engine.pl "$@" > $out_file
command_type=$(head -n 1 $out_file)

if [ $# -eq 0 ]; then
    next_path=$(tail -n 1 $out_file)
    perl ~/.ds/ds_engine.pl --update-previous $(pwd)
    cd "$next_path"
elif [ "$command_type" == "switch_directory" ]; then
    next_path=$(tail -n 1 $out_file)
    
    if [ ! -d "$next_path" ]; then 
        echo 2> "Directory \"$next_path\" does not exist."
    else 
        ~/.ds/ds_engine.pl --update-previous $(pwd)
        cd "$next_path"
    fi
elif [ "$command_type" == "list" ] || [ "$command_type" == "msg" ]; then 
    tail -n +2 $out_file
fi

rm $out_file

DSConstants.pm

package DSConstants;

use File::HomeDir;

use constant {
    SORT_BY_TAGS => "tags",
    SORT_BY_DIRS => "dirs",
    
    TAG_FILE_NAME              => File::HomeDir->my_home . "/.ds/tags",
    TMP_TAG_FILE_NAME_TEMPLATE => File::HomeDir->my_home . "/.ds/tags.XXXXX",
    
    OPERATION_SWITCH => "switch_directory",
    OPERATION_MSG    => "msg",
    OPERATION_LIST   => "list",
    
    PREVIOUS_DIRECTORY_TAG => "zzzPREV",
    
    COMMAND_LIST_TAGS             => "-l",
    COMMAND_LIST_TAGS_DIRS        => "-L",
    COMMAND_LIST_SORTED_TAGS      => "-s",
    COMMAND_LIST_SORTED_TAGS_DIRS => "-S",
    COMMAND_LIST_SORTED_DIRS      => "-d",
    
    COMMAND_HELP_SHORT => "-h",
    COMMAND_HELP_LONG  => "--help",
    COMMAND_HELP_WOORD => "help",
    
    COMMAND_VERSION_SHORT => "-v",
    COMMAND_VERSION_LONG  => "--version",
    COMMAND_VERSION_WORD  => "version",
    
    COMMAND_ADD_SHORT => "-a",
    COMMAND_ADD_LONG  => "--add-tag",
    COMMAND_ADD_WORD  => "add",
    
    COMMAND_REMOVE_SHORT => "-r",
    COMMAND_REMOVE_LONG  => "--remove-tag",
    COMMAND_REMOVE_WORD  => "remove",
    
    COMMAND_VERSION_SHORT => "-v",
    COMMAND_VERSION_LONG  => "--version",
    
    COMMAND_HELP_SHORT => "-h",
    COMMAND_HELP_LONG  => "--help",
    
    COMMAND_UPDATE_PREVIOUS => "--update-previous",
    
    EXIT_STATUS_TOO_MANY_ARGS => 1,
    EXIT_STATUS_BAD_COMMAND   => 2
    
};

1;  

DirectoryTagEntry.pm

package DirectoryTagEntry;
use warnings;
use strict;

sub minimum {
    my @sorted = sort { $a <=> $b } @_;
    return $sorted[0];
}

sub edit_distance_impl (\@\@);
sub edit_distance ($$);

sub edit_distance ($$) {      
    my ($ref_str1, $ref_str2) = @_;
    my @chars1 = split "", $ref_str1;
    my @chars2 = split "", $ref_str2;
    return edit_distance_impl(@chars1, @chars2);
}

sub edit_distance_impl (\@\@) {
    my ($ref_chars1, $ref_chars2) = @_;
    my $matrix_height = scalar @$ref_chars1 + 1;
    my $matrix_width  = scalar @$ref_chars2 + 1;
    my $matrix = [];
    
    # Initialize the matrix:
    for my $y (0 .. $matrix_height - 1) {
        my $row = [];
        # Make $row $matrix_width elements long:
        $row->[$matrix_width - 1] = 0;
        $matrix->[$y] = $row;
        
        for my $i (0 .. $matrix_width - 1) {
            $row->[$i] = 0;
        }
    }

    # Initialize the leftmost column:
    for my $y (0 .. $matrix_height - 1) {
        $matrix->[$y][0] = $y;
    }
    
    # Initialize the topmost row:
    for my $x (1 .. $matrix_width - 1) {
        $matrix->[0][$x] = $x;
    }
    
    my $substitution_cost;
    
    # Compute the distance:
    for my $x (1 .. $matrix_width - 1) {
        for my $y (1 .. $matrix_height - 1) {
            
            if ($$ref_chars1[$y - 1] eq $$ref_chars2[$x - 1]) {
                $substitution_cost = 0;
            } else {
                $substitution_cost = 1;
            }
            
            $matrix->[$y][$x] =
                minimum($matrix->[$y - 1][$x] + 1,
                        $matrix->[$y][$x - 1] + 1,
                        $matrix->[$y - 1][$x - 1] +
                            $substitution_cost);
        }
    }
    
    return $matrix->[$matrix_height - 1][$matrix_width - 1];
}

sub new {
    my $class = shift;
    my $self = {@_};
    bless ($self, $class);
    return $self;
}

sub tag {
    my $self = shift;
    my $val = shift;
    
    if (defined $val) {
        $self->{tag} = $val;
    }
    
    return $self->{tag};
}

sub dir {
    my $self = shift;
    my $val = shift;
    
    if (defined $val) {
        $self->{dir} = $val;
    }
    
    return $self->{dir};
}

sub get_edit_distance_to {
    my $self = shift;
    my $str = shift;
    return edit_distance($self->{tag}, $str);
}

1;

DirectoryTagEntryList.pm

package DirectoryTagEntryList;
use warnings;
use strict;
use Cwd;
use File::HomeDir;

use lib glob("~/.ds");

use DSConstants;
use DirectoryTagEntry;

sub get_previous_directory;

sub minimum {
    my @sorted = sort { $a <=> $b } @_;
    return $sorted[0];
}

sub maximum {
    my @sorted = sort { $a <=> $b } @_;
    return $sorted[$#sorted];
}

sub read_file($);

sub new {
    my $class = shift;
    my $self = [];
    bless ($self, $class);
    return $self;
}

sub get_size {
    my $self = shift;
    return scalar(@{$self});
}

sub read_file($) {
    my $self = shift;
    my $file_name = shift;
    open(TAG_FILE, "<", $file_name) or die "Cannot open $file_name: $!\n";
    
    while (<TAG_FILE>) {
        my $line = $_;
        chomp $line;
        
        if ($line =~ /^\s*(\w+)\s+(.*)$/g) {
            my $tag = $1;
            my $dir = $2;
            $dir =~ s/^\s+|\s+$//g;
            my $tag_entry = DirectoryTagEntry->new( tag => $tag,
                                                    dir => $dir );
            push @$self, $tag_entry;
        }   
    }
    
    close TAG_FILE;
}

sub write_file {
    my $self = shift;
    my $file_name = shift;
    open(TAG_FILE, ">", $file_name) or die "Cannot open $file_name: $!\n";
    
    for my $tag_entry (@$self) {
        print TAG_FILE $tag_entry->tag() . " " . $tag_entry->dir() . "\n";
    }
    
    close TAG_FILE;
}

sub add_tag_entry {
    my $self = shift;
    my $tag = shift;
    my $dir = shift;
    
    for my $tag_entry (@$self) {
        if ($tag_entry->tag() eq $tag) {
            # Tag already present. Update:
            $tag_entry->dir($dir);     
            return $tag_entry;
        }
    }
    
    my $tag_entry = DirectoryTagEntry->new(tag => $tag,
                                           dir => $dir);
    push @$self, $tag_entry;
    return $tag_entry;
}

sub remove_tag_entry {
    my $self = shift;
    my $tag = shift;
    my $i = 0;
    
    for my $tag_entry (@$self) {
        if ($tag_entry->tag() eq $tag) {
            splice @$self, $i, 1;    
            return $tag_entry;
        }
        
        $i++;    
    }
    
    return undef;
}

sub update_previous_directory {
    my $self = shift;
    my $prevous_directory_name = shift;
    
    for my $tag_entry (@$self) {
        if ($tag_entry->tag() eq DSConstants::PREVIOUS_DIRECTORY_TAG) {
            $tag_entry->dir($prevous_directory_name);
            return;
        }
    }
    
    my $prev_tag = DirectoryTagEntry->new(
                    tag => DSConstants::PREVIOUS_DIRECTORY_TAG,
                    dir => $prevous_directory_name );
    
    push @$self, $prev_tag;
}

sub get_previous_directory {
    my $self = shift;
    
    for my $tag_entry (@$self) {
        if ($tag_entry->tag() eq DSConstants::PREVIOUS_DIRECTORY_TAG) {
            return $tag_entry->dir();
        }
    }
    
    return undef;
}

sub sort {
    my $self = shift;
    my $flag = shift;
    
    if ($flag eq DSConstants::SORT_BY_TAGS) {
        @$self = sort { $a->tag() cmp $b->tag() } @$self;
    } elsif ($flag eq DSConstants::SORT_BY_DIRS) {
        @$self = sort { $a->dir() cmp $b->dir() } @$self;
    } else {
        die "Unknown sort flag: $flag\n";
    }
}

sub print_tags {
    my $self = shift;
    print DSConstants::OPERATION_LIST, "\n";
    
    for my $tag_entry (@$self) {
        print $tag_entry->tag(), "\n";
    }
}

sub print_tags_and_dirs {
    my $self = shift;
    my $max_tag_width = -1;
    
    for my $tag_entry (@$self) {
        $max_tag_width = maximum($max_tag_width,
                                 length($tag_entry->tag()));
    }
    
    my $tag_column_width = "" . $max_tag_width;
    print DSConstants::OPERATION_LIST, "\n";
    
    for my $tag_entry (@$self) {
        printf("%-" . $tag_column_width . "s  %s\n",
               $tag_entry->tag(),
               $tag_entry->dir()); 
    }
}

sub print_dirs_and_tags {
    my $self = shift;
    my $max_dir_width = -1;
    
    for my $tag_entry (@$self) {
        $max_dir_width = maximum($max_dir_width,
                                 length($tag_entry->dir()));
    }
    
    my $dir_column_width = "" . $max_dir_width;
    print DSConstants::OPERATION_LIST, "\n";
    
    for my $tag_entry (@$self) {
        printf("%-" . $dir_column_width . "s  %s\n",
               $tag_entry->dir(),
               $tag_entry->tag()); 
    }
}

sub change_tilde_prefix_to_path {
    my $dir = shift;
    
    if ($dir =~ /^\s*\~/) {
        return File::HomeDir->my_home . substr($dir, 1);
    }
    
    return $dir;
}

sub match {
    my $self = shift;
    my $tag = shift;
    my $current_best_edit_distance = 1000_000_000;
    my $best_match = undef;
    
    for my $tag_entry (@$self) {
        my $tmp_edit_distance = $tag_entry->get_edit_distance_to($tag);
        
        if ($current_best_edit_distance > $tmp_edit_distance) {
            $best_match = $tag_entry;
            
            if ($tmp_edit_distance == 0) {
                last;
            }
            
            
            $current_best_edit_distance = $tmp_edit_distance;
        }
    }
    
    if (!$best_match) {
        return undef;
    }
    
    my $best_match_copy =
        DirectoryTagEntry->new(tag => $best_match->tag(),
                               dir => $best_match->dir());
        
    $best_match_copy->dir(
        change_tilde_prefix_to_path(
            $best_match_copy->dir()));
        
    return $best_match_copy;
}

sub get_tag_entry {
    my $self = shift;
    my $tag = shift;
    
    for my $tag_entry (@$self) {
        if ($tag eq $tag_entry->tag()) {
            return $tag_entry;
        }
    }
    
    return undef;
}

1;

ds_engine.pl

#!/usr/bin/perl
use warnings;
use strict;
use Cwd;
use File::HomeDir;

use lib glob("~/.ds");

use DSConstants;
use DirectoryTagEntry;
use DirectoryTagEntryList;

sub show_tag_list {
    my $list = shift;
    my ($show_dirs, $sorted) = @_;
    
    if ($sorted) {
        $list->sort(DSConstants::SORT_BY_TAGS);
    }
    
    if ($show_dirs) {
         $list->print_tags_and_dirs();
    } else {
         $list->print_tags();
    }                               
}

sub show_tag_list_sorted_by_dirs {
    my $list = shift;
    $list->sort(DSConstants::SORT_BY_DIRS);
    $list->print_dirs_and_tags();
}

sub process_jump_to_previous {
    my $list = shift;
    my $previous_dir = $list->get_previous_directory();
    
    print DSConstants::OPERATION_SWITCH, "\n";
    
    if (defined $previous_dir) {
        print $previous_dir;
    } else {
        print getcwd;
    }
    
    print "\n";
}

sub jump_to_tagged_directory {
    my $list = shift;
    my $tag = shift;
    my $best_tag_entry = $list->match($tag);    
    
    print DSConstants::OPERATION_SWITCH, "\n";
    
    if (not defined $best_tag_entry) {
        print getcwd();
    } else {
        print $best_tag_entry->dir();
    }
    
    print "\n";
}

sub show_version_info {
    print DSConstants::OPERATION_MSG, "\n";
    print <<"END"
ds (Directory Switcher) 1.6
Copyright (C) 2022 Rodion "rodde" Efremov.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Rodion Efremov.
END
}

sub show_help_info {
    print DSConstants::OPERATION_MSG, "\n";
    print <<"END"
Usage: ds
       ds TAG
       ds -a | --add-tag | add TAG [DIR]
       ds -r | --remove-tag | remove TAG [TAG...]
       ds -l | -L | -s | -S | -d
       
-a | --add-tag | add TAG [DIR]
    Add tag called TAG and point it to DIR.
    If DIR is not specified, point to the current working directory.
           
-r | --remove-tag | remove TAG [TAG...]
    Remove all the specified tags from the user\'s tag file.
           
-l
    List all tags in the tag file.
    
-L
    List all tags and directories in the tag file.
    
-s
    List all tags in the tag file sorted by tag names.
    
-S
    List all tags and directories in the tag file sorted by tag names.
    
-d
    List all directories and tags in the tag file sorted by directories.
    
TAG
    Switches to the directory tagged with TAG. If there is not TAG in
    the tag file, the closest tag (by Levenshtein distance) is assumed.
    
[NO ARGS]
    Switch to the previous directory. Issuing this command repeatedly
    allows the user to switch back and forth between two directories.
END
}

sub process_single_arg {
    my $list = $_[0];
    my $flag = $_[1];
    
    if ($flag =~ /^-[lLsSdvh]|--help|--version$/) {
        for ($flag) {
            $_ eq DSConstants::COMMAND_LIST_TAGS             && show_tag_list($list, 0, 0);
            $_ eq DSConstants::COMMAND_LIST_TAGS_DIRS        && show_tag_list($list, 1, 0);
            $_ eq DSConstants::COMMAND_LIST_SORTED_TAGS      && show_tag_list($list, 0, 1);
            $_ eq DSConstants::COMMAND_LIST_SORTED_TAGS_DIRS && show_tag_list($list, 1, 1);
            $_ eq DSConstants::COMMAND_LIST_SORTED_DIRS      && show_tag_list_sorted_by_dirs($list);
            $_ eq DSConstants::COMMAND_VERSION_SHORT         && show_version_info;
            $_ eq DSConstants::COMMAND_VERSION_LONG          && show_version_info;
            $_ eq DSConstants::COMMAND_HELP_SHORT            && show_help_info;
            $_ eq DSConstants::COMMAND_HELP_LONG             && show_help_info;
        }
    } else {
        jump_to_tagged_directory($list, $flag);   
    }
}

sub add_tag {
    my ($list, $tag, $dir) = @_;
    
    print DSConstants::OPERATION_MSG, "\n";
    
    if ($tag =~ /\s+/) {
        print "Error: a requested tag contains space characters.\n";
        return;
    }
    
    
    my $tag_entry = $list->get_tag_entry($tag);
    
    if (defined $tag_entry) {
        if ($tag_entry->dir() ne $dir) {
            print "Updating the directory <" . $tag_entry->dir() . "> to <$dir>."; 
            $tag_entry->dir($dir);  
        } else {
            print "Redirecting the tag \"$tag\" to itself. Nothing changed.";  
        }
    } else {
        $list->add_tag_entry($tag, $dir);
        
        print "Added the tag \"$tag\" to point to\n";
        print "<$dir>.";
    }
    
    save_list($list);
    print "\n";
}

sub remove_tag {
    my ($list, $tag) = @_;
    my $remove_tag_entry = $list->remove_tag_entry($tag);
    save_list($list);
    
    print DSConstants::OPERATION_MSG, "\n";
        
    if (defined $remove_tag_entry) {
        print "Removed tag \"" . $remove_tag_entry->tag() . "\"" .
              " pointing to <" . $remove_tag_entry->dir() . ">.\n";          
    } else {
        print "$tag: no such tag.\n";
    }
}

sub update_previous {
    my $list = shift;
    my $new_dir = shift;
    
    $list->update_previous_directory($new_dir);
    save_list($list);
}

sub process_double_args {
        my ($list, $cmd, $tag) = @_;

    my $cmd_regex = "^" .
                    DSConstants::COMMAND_ADD_SHORT       . "|" .
                    DSConstants::COMMAND_ADD_LONG        . "|" .
                    DSConstants::COMMAND_ADD_WORD        . "|" .
                    DSConstants::COMMAND_REMOVE_SHORT    . "|" .
                    DSConstants::COMMAND_REMOVE_LONG     . "|" .
                    DSConstants::COMMAND_REMOVE_WORD     . "|" .
                    DSConstants::COMMAND_UPDATE_PREVIOUS . "\$";
    
    if ($cmd !~ /$cmd_regex/) {
        die "$cmd: command not recognized.";
    }
    
    for ($cmd) {
        $_ eq DSConstants::COMMAND_ADD_SHORT && add_tag($list, $tag, getcwd());
        $_ eq DSConstants::COMMAND_ADD_LONG  && add_tag($list, $tag, getcwd());
        $_ eq DSConstants::COMMAND_ADD_WORD  && add_tag($list, $tag, getcwd());
        
        $_ eq DSConstants::COMMAND_REMOVE_SHORT && remove_tag($list, $tag);
        $_ eq DSConstants::COMMAND_REMOVE_LONG  && remove_tag($list, $tag);
        $_ eq DSConstants::COMMAND_REMOVE_WORD  && remove_tag($list, $tag);
        
        my $update_dir = $tag;
        
        $_ eq DSConstants::COMMAND_UPDATE_PREVIOUS && update_previous($list, $update_dir);
    }
}

sub process_triple_args {
    my $list = shift;
    my $cmd = shift;
    my $tag = shift;
    my $dir = shift;
    
    my $cmd_regex = "^" .
                    DSConstants::COMMAND_ADD_SHORT    . "|" .
                    DSConstants::COMMAND_ADD_LONG     . "|" .
                    DSConstants::COMMAND_ADD_WORD     . "|" .
                    DSConstants::COMMAND_REMOVE_SHORT . "|" .
                    DSConstants::COMMAND_REMOVE_LONG  . "|" .
                    DSConstants::COMMAND_REMOVE_WORD  . "\$";
    
    if ($cmd !~ /$cmd_regex/) {
        print DSConstants::OPERATION_MSG . "\n";
        print "$cmd: command not recognized.";
        print DSConstants::COMMAND_ADD_SHORT, ", ";
        print DSConstants::COMMAND_ADD_LONG, " or ";
        print DSConstants::COMMAND_ADD_WORD, " expected.";
        exit DSConstants::EXIT_STATUS_BAD_COMMAND;
    }
    
    if ($cmd eq DSConstants::COMMAND_REMOVE_SHORT ||
        $cmd eq DSConstants::COMMAND_REMOVE_LONG ||
        $cmd eq DSConstants::COMMAND_REMOVE_WORD) {
        # $tag contains the first out of two tags to remove:
        print DSConstants::OPERATION_MSG, "\n";
        my $removed_tag_entry = $list->remove_tag_entry($tag);
        
        if ($removed_tag_entry) {
            print "Removed tag \"" . $tag . "\".\n";
        } else {
            print "No tag \"" . $tag . "\". Omitting.\n";
        }
        
        $removed_tag_entry = $list->remove_tag_entry($dir);
        
        if ($removed_tag_entry) {
            print "Removed tag \"" . $dir . "\".\n";
        } else {
            print "No tag \"" . $dir . "\". Omitting.\n";
        }
        
        save_list($list);   
    } else { 
        add_tag($list, $tag, $dir);          
    }
}

sub process_multiple_args {
    my $list = shift;
    my $cmd = shift;
    
    my $cmd_regex = "^(" .
                    DSConstants::COMMAND_ADD_SHORT       . "|" .
                    DSConstants::COMMAND_ADD_LONG        . "|" .
                    DSConstants::COMMAND_ADD_WORD        . "|" .
                    DSConstants::COMMAND_REMOVE_SHORT    . "|" .
                    DSConstants::COMMAND_REMOVE_LONG     . "|" .
                    DSConstants::COMMAND_REMOVE_WORD     . "|" .
                    DSConstants::COMMAND_UPDATE_PREVIOUS . "\$)";
                    
    if ($cmd !~ /$cmd_regex/) {
        die "Command \"$cmd\" not recognized.";
    }
    
    if ($cmd eq DSConstants::COMMAND_REMOVE_SHORT ||
        $cmd eq DSConstants::COMMAND_REMOVE_LONG ||
        $cmd eq DSConstants::COMMAND_REMOVE_WORD) {
        
        print DSConstants::OPERATION_MSG . "\n";
        
        my @all_arguments = @_;
        
        for my $tag_name (@all_arguments) {
            my $tag_entry = $list->remove_tag_entry($tag_name);
            
            if ($tag_entry) {
                print "Removed \"" . $tag_entry->tag() . "\".\n";
            } else {
                print "No tag \"" . $tag_name . "\ in the tags file. Omitting.\n";
            }
        }
        
        save_list($list);
        return;
    }
    
    my @all_arguments = @_;
    
    my $tag = shift;
    my @dir_components = @_;
    my $dir = join " ", @dir_components;
    
    if ($1 eq DSConstants::COMMAND_UPDATE_PREVIOUS) {
        my $path = join " ", @all_arguments;
        update_previous($list, $path);
    } else {
        add_tag($list, $tag, $dir);      
    }  
}

sub save_list {
    my $list = shift;
    $list->write_file(DSConstants::TAG_FILE_NAME);
}

my $directory_tag_list = DirectoryTagEntryList->new();
$directory_tag_list->read_file(DSConstants::TAG_FILE_NAME);

for (scalar @ARGV) {
    $_ == 0 && process_jump_to_previous($directory_tag_list);
    $_ == 1 && process_single_arg      ($directory_tag_list, @ARGV);
    $_ == 2 && process_double_args     ($directory_tag_list, @ARGV);
    $_ == 3 && process_triple_args     ($directory_tag_list, @ARGV);
    $_  > 3 && process_multiple_args   ($directory_tag_list, @ARGV);
}

ds (man page source)

.\" Manpage for ds.
.\" Contact coderodd3@gmail.com to correct errors or typos.
.TH man 8 "22 Feb 2022" "1.6" "ds man page"
.SH NAME
ds \- manage directory tags
.SH SYNOPSIS
.B ds
.br
.B ds
[-l|-L|-s|-S|-d-|-h|-v|--help|--version]
.br
.B ds
TAG
.br
.B ds
.RI [-a|--add-tag|add]
TAG
[DIR]
.br
.B ds
.RI [-r|--remove-tag|remove]
TAG
.RI [ TAG .\|.\|.]

.SH DESCRIPTION
.B ds
is a (bash) shell utility for managing directory tags providing a simple facility for tagging directories and switching between them via tags. Also, ds provides a front-end for managing the user's tag file.

.SH OPTIONS
.SS "Generic Program Information"
.TP
.B \-l
List all the tags in the order they appear in the tag file.
.TP
.BR \-L
List all the tags and their respective directories in the order they appear in the tag file.
.TP
.BR \-s
List all the tags in sorted order.
.TP
.BR \-S
List all the tags and their respective directories in the order sorted by tags.
.TP
.BR \-d
List all the directories and their respective tags in the order sorted by directories.
.TP
.BR \-h ", " \-\^\-help
Shows the help message and exits.
.TP
.BR \-v ", " \-\^\-version
Shows the version info and exits.

.SS "Tag Management Commands"
.TP
.BR \-a "," \-\^\-add-tag ", "add
Attempts to add the tag
.B TAG
and point it to the directory
.B DIR.
If
.B TAG
is not yet present in the user's tag file, it is created and pointed to
.B DIR
. Otherwise, the directory of
.B TAG
is redirected to
.B DIR
.

.TP
.BR \-r "," \-\^\-remove-tag ", "remove
Removes all the listed tags from the user's tag file.

.SH DESCRIPTION
ds maintains the tag file
.IB tags
located in the user's directory
.IB \[ti]/.ds
.
The file has two columns: the left one for the tags and the right one for the respective directories. In addition to all the tags, the tag file (usually) has a tag called
.IB zzzPREV
which holds the previously visited directory. This allows ds to "jump" back and forth between two directories simply by invoking the ds without any arguments.

A tag is a string containing no space characters, while directories with spaces are allowed as long as they are surrounded with single or double quotes.

In addition, if the directory starts with a tilde (~), it will be expanded to the name of the user's home directory. In order to keep the tilde character intact, surround the path with single or double quotes.

.SH EXAMPLES
.TP
1. To list all the directory/tag pairs sorted by directory:
.EX
.B ds -d
.EE
.TP
2. To add a tag "docs" and point it to the directory "~/Documents", type:
.EX
.B ds --add-tag docs "~/Documents" 
.EE
.TP
3. To remove tags "home", "root", "docs", type:
.EX
.B ds --remove-tag home root docs
.EE

.SH SEE ALSO
alias (1), cd (1)

.SH BUGS
No known bugs. Yet.

.SH AUTHOR
Rodion "rodde" Efremov (coderodd3@gmail.com)

installer.sh

#!/usr/bin/env bash

script_magic="alias ds='source ~/.ds/ds_script.sh'"

echo "Installing ds..."

grep "$script_magic" > /dev/null 2> /dev/null ~/.bashrc 

if [ $? != 0 ]; then
    echo "$script_magic" >> ~/.bashrc   
    echo "~/.bashrc updated!"
else
    echo "~/.bashrc is already updated."
fi

# Create files:
echo "Creating files..."
mkdir -p ~/.ds
echo "Created the ~/.ds directory."
cp ds_engine.pl *.pm ds_script.sh ~/.ds
echo "Copied the code."

tag_file=$HOME"/.ds/tags"
touch "$tag_file"

add_tag_to_file () {
    if grep -q "^$1" $tag_file; then
        echo "Tag \"$1\" is already in the tag file."
    else 
        echo "$1 $2" >> $tag_file
        echo "Added tag \"$1\" to \"$2\""
    fi
}

# Populate the default 
echo "Populating the tag file with default tags..."

add_tag_to_file "docs" "~/Documents"
add_tag_to_file "down" "~/Downloads"
add_tag_to_file "root" "/"
add_tag_to_file "home" "~"
add_tag_to_file "ds"   "~/.ds"

echo "Done populating the tag file with default tags."
echo "Done! ds will be available for use in your next shell session. :-]"
echo ""
echo ""

install-manpages.sh

#!/bin/bash
sudo cp ds /usr/local/man/man1/ds.1
gzip /usr/local/man/man1/ds.1

Outro

I apologize beforehand for posting a lot of code written in different technologies, but I really need your help for making ds more mature.

\$\endgroup\$

2 Answers 2

4
\$\begingroup\$

I'm going to comment on the shell and Perl pieces primarily. Somebody else can probably critique your man page *roff more meaningfully.

good in general

  • I'm fine with the mixing of languages here. Perl gets a lot of grief these days, but it is a wonderful language to program in. Shell has always gotten a ton of flack, but it works really well for installing things and it will always be handy for sysadmins.
  • Your output looks like it will show progress without saying too much.
  • Creating a function for your repeated code in shell.
  • Yay for including a github link. I love seeing more contributions to the community and it is easier to read code there than in the blocks in StackExchange.

shell suggestions

  • It'd be nice to rename your man page to ds.1 in your repo.
  • It is a good practice to have sh-bang lines at the top of all of your scripts. You're actually doing this for most of your runnable files already, but it is missing from ds_script.sh. Using env is also considered a good thing, so folks aren't stuck with needing to have the same exact path as used in Linux.
  • Using double square brackets in shell scripts helps avoid surprises later.
  • Run your shell script through shellcheck.
  • Check for failure in your shell scripts. cd or cp can fail. You probably don't want to keep going if that happens. bash strict mode is one way to handle this.
  • You can combine if and grep into if grep... so you don't have to check the return value explicitly. The if will look at the return value of grep. You don't use square brackets in this case. You're already doing this in one place, lower in the code.

good Perl

  • Yay for using strict mode. This is a good habit to keep going.
  • Nice indentation.
  • Good use of modules.
  • Good use of references to create data structures.
  • Good names for variables.
  • It is totally readable and maintainable code. This is impressively idiomatic code for your first Perl attempt.

Perl suggestions

  • In your for ($cmd) loop I'd write $_ eq DSConstants::COMMAND_ADD_SHORT && add_tag($list, $tag, getcwd()); as add_tag($list, $tag, getcwd()) if $_ eq DSConstants::COMMAND_ADD_SHORT;
  • You can let Perl do interpolation for you in double quoted strings. So print "Removed tag \"" . $tag . "\".\n"; becomes print "Removed tag \"$tag\".\n";
  • check out "Perl Critic". It is a module and tool that is probably more current about recent Perl practices than your humble reviewer.
\$\endgroup\$
1
  • 1
    \$\begingroup\$ I'd presume that ds_script is intended for sourcing into another shell process, rather than being run as a program. Therefore a shebang line probably isn't appropriate (though it's polite to have at least a comment to say which shell it's intended for - that can help Shellcheck and code editors select the appropriate syntax modes). \$\endgroup\$ Feb 23 at 8:04
5
\$\begingroup\$

This will focus on your Perl code.

I agree with the Perl assessment in the answer posted previously. Very well done indeed.

Command line

I see that you created your own command line parser. You could consider using a standard one for Perl, namely Getopt::Long. This is a Core module which does not require separate installation. An obvious benefit is that it is well-maintained, well-documented and has wide-spread use. You need to decide if it plays well with the rest of your non-Perl code; if not, you could browse CPAN for other "Getopt" modules which might be more suitable.

Documentation

Perl has its own documentation system (POD). You could replace your "Usage" string with POD. There are several benefits. Again, it is a standard practice in Perl, and it has widespread usage. It acts as its own unix-style manpage, invoked using the Perl builtin perldoc utility:

perldoc ds_engine.pl

The POD can be added to all your Perl files: .pl and .pm alike.

Furthermore, Getopt::Long is often paired with Pod::Usage, another Core module. This offers a flexible and standard way to display usage information.

Critic

As mentioned in the other answer, the perlcritic utility offers an automated way to analyze your code for best practices. Once installed, it is simple to run:

perlcritic ds_engine.pl

It can easily be configured to generate harsh or gentle critiques of your code. I even created a simple script to make it easy to sort and categorize the (sometimes) verbose output; the script also makes it very easy to get detailed description of the reasons for the coding recommendations.

Tests

It is great that you partitioned your code into modules with subs. If you are really ambitious and want to create robust code, you could create tests. I recommend starting with the simple Test::More Core module. Again, this is standard and has widespread usage.

If you do create tests, you could go one step further and measure how well they exercise your code with Devel::Cover.


When I run the following,

ds_engine.pl --help

instead of getting any help message, I just get an error like:

Cannot open /.ds/tags: No such file or directory

If your non-Perl code does not assure the file exists, it would be helpful to the user to print the help message regardless of the file existence.

Consider using Carp instead of die or print when errors occur to provide the user a little more context for the errors.

My preference is to use a very strict version of warnings:

use warnings FATAL => 'all';

In my experience, the warnings have always pointed to a bug in my code. The issue is that, in some common usage scenarios, it is too easy to miss the warning messages unless you are looking for them. They can be hard to spot even if your code generates a small amount of output, not to mention anything that scrolls off the screen. This option will kill your program dead so that there is no way to miss the warnings.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.