Solutions and Discussion for Perl Quiz of the Week #14

Mark Jason Dominus <[email protected]> Wed, 18 Jun 2003 23:27:59 -0400
Newsgroups gmane.comp.lang.perl.qotw.quiz-of-the-week
Organization Plover Systems
Message-ID <[email protected]>
Sample solutions and discussion
Perl Quiz of The Week #14 (20030611)

        Write a program which generates an HTML table of contents for
        a directory of files.  It should read the directory, producing
        a list of the files, and write out a file "toc.html" in that
        same directory, in the following format:

                <title>Table of Contents for [Directory Name]</title>

                <h1>Table of Contents for [Directory Name]</h1>
                <a href="file1">file1</a><br>
                <a href="file2">file2</a><br>
                ...
                <a href="lastfile">lastfile</a><br>

        The files should be listed in alphabetic order.  The program
        should accept a command-line argument that tells it what
        directory to index; if the argument is omitted, it should
        index the current directory.

----------------------------------------------------------------

I only saw one solution posted on the perl-qotw-discuss list, from
Pr. Offer Kaye; I also wrote one myself.  Offer's uses a number of
standard modules to take care of escaping HTML and URLs:

    use strict;
    use warnings;
    use Cwd;
    use URI::file;
    use HTML::Entities;

    my $toc_file = "toc.html";
    if ($#ARGV > 0) {die "Incorrect usage. Use \"$0 dir_name\" or just:
    \"$0\"\n"}
    my $dir_name = ($#ARGV == 0) ? $ARGV[0] : cwd();
    opendir(DIR, $dir_name) or die "Can't opendir $dir_name: $!\n";
    my @list = sort {lc($a) cmp lc($b)} readdir(DIR);
    chdir $dir_name or die "Couldn't chdir to $dir_name for some reason...\n";
    open(OUT,">$toc_file") or die "Couldn't open $toc_file for writing: $!\n";
    print OUT "<html><head><title>Table of Contents for ";
    print OUT encode_entities($dir_name),"</title></head><body>\n";
    print OUT "<h1>Table of Contents for ";
    print OUT encode_entities($dir_name),"</h1>\n";
    for (@list){
       next if (($_ eq ".") or ($_ eq ".."));
       my $u = encode_entities(URI::file->new($_));
       print OUT "<div><a href=\"$u\">$u</a></div>\n";
    }
    print OUT "</body></html>";

Most of this seems completely straightforeard and I don't have much to
say about it.

I wonder about using 'cwd()' instead of just '.'. I suppose '.' might
not be portable, but if you want it to be portable, it would seem to
be to be simpler to use File::Spec->curdir(), which then just returns '.'.

The output from Pr. Kaye's program is a little peculiar if the
filenames are peculiar.  For example, I created a file named
'<blink>ooky' to make sure that things didn't start blinking.  The
encode_entities call turns this into '%3Cblink%3Eooky'.  This is fine
when it appears as a URL, but as HTML text, it's wrong; you actually
see '%3Cblink%3Eooky' on the page, percent signs and all.  It should
have been turned into '&lt;blink&gt;ooky', which would have displayed
as '<blink>ooky'.

Since I expected everyone would use the modules, I thought I'd see if
it was posible to do it without the modules, while still using only
features from the Llama book.  This turns out to be possible, but just
barely.  (The modules are *not* discussed in the Llama book.)    The
main part of the program, of course, is almost the same:

        for my $f (sort readdir D) {
          my $url = escape_url($f);
          my $html = escape_html($f);
          print "<a href=\"$url\">$html</a><br>\n";
        }

The big question is how to implement 'escape_url' and 'escape_html'
with only Llama features.  'escape_html' is quite easy:

        sub escape_html {
          my $t = shift;
          $t =~ s/&/&amp;/g;
          $t =~ s/</&lt;/g;
          $t =~ s/>/&gt;/g;
          return $t;
        }

For this project, this is just fine.  'escape_url', however, is much
trickier.  Normally, I would write something like this:

        sub escape_url {
          my $url = shift;
          $url =~ s/([^0-9A-Za-z.-_])/sprintf "%%%2x", ord $1/ge;
          return $url;
        }

This uses *three* non-Llama features:

  1. The ord() function

Page 166 says "While constructing and interpreting such a byte string
is fairly straightforward using 'chr' and 'ord' (not presented
here)...".  Since they're not presented, I can't use them.

  2. sprintf "%x"

'printf' and 'sprintf' are discussed, but never the "%x" escape.

  3. s///e

s/// is of course discussed in detail, but never /e.  The book
mentions it in passing at the very very end; there's an example on
page 253.  I almost went ahead and used it.  Then I noticed that
typeglobs are also mentioned on page 253, so that allowing page 253
features would make a mockery of the 'Llama only' restriction.  So I
decided that s///e was forbidden.  (Reminder: The 'Llama only' rule
applies only to me; everyone else can of course use whatever they want
to.)

The big difficulty: how to convert "x" to hexadecimal without using
ord() and sprintf()? 

I briefly considered using something like

        s/%/%25/g;
        s/\./%2c/g;
        s/</%3c/g;
        ...
        [150 more of these]
        ...
        
and decided that it was much too gross to hold up as an example.  I
was going to come up with a plausible, instructive solution, or none
at all.  You will have to be the judge of whether I succeeded.

It turns out that I could build ord(), because it is the same as
unpack("C"), which the book discusses in some detail.

        sub Ord {
          my $chr = shift;
          unpack "C", $chr;
        }

And once I have a number, I can use ordinary arithmetic to convert it
to hex:

              my @hex = (0 .. 9, 'a' .. 'f');
              my $h0 = $ord % 16;
              my $h1 = ($ord - $h0) / 16;
              $result .= "%$hex[$h1]$hex[$h0]";

So here's 'escape_url':

        sub escape_url {
          my @chars = split //, $_[0];
          my $result = "";
          for (@chars) {
            my $ord = Ord($_);
            if ($good_char{$_}) {
              $result .= $_;
            } else {
              my $h0 = $ord % 16;
              my $h1 = ($ord - $h0) / 16;
              $result .= "%$hex[$h1]$hex[$h0]";
            }
          }
          return $result;
        }

%good_char just describes the characters that don't need to be
escaped.  The program works fine if you leave it empty, and you can do
that if you like.  I defined it this way, which produces nicer output:

        my %good_char;
        {
          my @good_char = ('-', '.', '_', 0 .. 9, 'A' .. 'Z', 'a' .. 'z');
          for (@good_char) { $good_char{$_} = 1 }
        }

Here's my program in full:

        #!/usr/bin/perl -w

        use strict 'vars';

        sub Ord {
          my $chr = shift;
          unpack "C", $chr;
        }

        my %good_char;
        {
          my @good_char = ('-', '.', '_', 0 .. 9, 'A' .. 'Z', 'a' .. 'z');
          for (@good_char) { $good_char{$_} = 1 }
        }
        my @hex = (0 .. 9, 'a' .. 'f');

        my $dir = shift || '.';
        opendir D, $dir
          or die "Couldn't open directory $dir: $!; aborting";

        my $title = "Table of contents for $dir";

        print "<html><head><title>$title</title></head>

        <body><h1>$title</h1>
        ";

        for my $f (sort readdir D) {
          my $url = escape_url($f);
          my $html = escape_html($f);
          print "<a href=\"$url\">$html</a><br>\n";
        }

        print "</body></html>\n\n";

        ################################################################


        sub escape_url {
          my @chars = split //, $_[0];
          my $result = "";
          for (@chars) {
            my $ord = Ord($_);
            if ($good_char{$_}) {
              $result .= $_;
            } else {
              my $h0 = $ord % 16;
              my $h1 = ($ord - $h0) / 16;
              $result .= "%$hex[$h1]$hex[$h0]";
            }
          }
          return $result;
        }

        sub escape_html {
          my $t = shift;
          $t =~ s/&/&amp;/g;
          $t =~ s/</&lt;/g;
          $t =~ s/>/&gt;/g;
          return $t;
        }


----------------------------------------------------------------

1. Alert readers will notice that my program forgot to escape the
   directory name in the title of the document.  I didn't realize this
   until I saw Pr. Kaye's solution.   Whoops!

2. I violated my own spec, which said "[the program] should write out
   a file "toc.html" in that same directory...".  When time came to
   write the program, I decided the spec was dumb, and opted to have
   it write the output to STDOUT instead.  If you think the specified
   behavior is better, just add

        open STDOUT, ">", "$dir/toc.html" or die ...;

   near the top of the program.   I still think the specified behavior
   is dumb.  The program is more flexible without it.    What was I
   thinking, anyway?

3. Pr. Kaye says:

        Note that my solution will not work for ALL cases- just the
        more common.  Plus, the HTML file created is very simplistic.
        A better ('better' as in more complete/robust) solution would
        perhaps be to determine the proper encoding (either based on
        the filenames or through a command-line switch) and create a
        (valid) XHTML file, properly formatted for that encoding. Or
        perhaps use UTF-8 regardless.  

   This is a huge issue that I never considered at all.  It reminded
   me of the time I asked for some simple calendrical computation and
   the -discuss list was awash with people asking about the French
   revolutionary calendar.  But Pr. Kaye raises a good point.
   Pr. Kaye lives in Israel, and may very well encounter files whose
   names contain Hebrew characters.  I have no idea of the corect way
   to deal with this.  I would not be surprised to learn that my
   program fails miserably when presented with filenames containing
   Hebrew characters.  (I would also not be surprised to learn that it
   works perfectly as long as the output file contains a line
   specifying that the character encoding is UTF-16.  I really can't
   exaggerate my ignorance here.)

   Pr. Kaye continues:   

        Another point is that I haven't got a way to test this on
        different file systems, so I'm not sure it will work
        everywhere- although I've done my best to try to make the
        solution robust in that sense. But I'm pretty sure that the
        script will not currently work across file systems.

4. Robert Spier asks:

        At this point, I'm stumped.  What does Pr. stand for?

   I liked refering to people as "Mr." in previous reports.  But then
   I got worried, because this assumes that all these people are men.
   I do not want to assume that, and really, I don't know.  So I
   decided to invent a new formal title for programmers that would
   evade the issue.  'Pr.' stands for 'Programmer'.  

   Lawyers get to impress people by putting 'Esq.' after their names.
   Now programmers can impress people by using a special title too.

Thanks again to everyone who particpated, including Pr. Kaye and also
those people who worked the problem in private and said nothing.  I
will post another quiz tonight.