The Lisp Shell Followup

Sat Jan 28, 2012

So, I may have to backtrack on what I was saying earlier. Specifically, I called clisp a toy shell, and I called the machine I'm currently typing this on a toy machine. I did this because, having just installed it and spent a grand total of five minutes poking around, I assumed

It turns out that most of those don't apply. I did actually lose tab-completion when working with files, but that's it. Pretty much every program that I want to run typically1 works just as well from clisp as it does in bash, scripts run exactly the same as under a standard shell when you use run-shell-command, cd is actually a function defined in clisps' cl-user, and when I need to run a regular bash for whatever reason eshell can pickup the slack.

There's also a few non-obvious things I gain to offset losing filename tab completion.

First off, I get to define helper functions at my command line. One situation I've already found this useful in is copying files off my previous computer. It's a fairly specific situation, because I didn't want to sync a complete directory, but rather surgically copy over some 12 or 13 irregularly named files. That would have taken 12 or 13 separate scp calls. In regular shell, I'd have to do something like write a script for it. Having an actual language available let me pull out my first trick

> (defun cp-file (file-name)
    (run-shell-command (format nil "scp inaimathi@other-machine:.emacs.d/~a .emacs.d/")))

CP-FILE

> (cp-file "example.el")

This isn't specific to clisp, obviously. I assume that any language shell you use could pull the same trick. Still, having the ability to define helpers on the fly is something I occasionally wish I had.

Another thing that I imagine would work in any language shell, is an easier way of defining shell scripts. I wrote a little set of ui utilities a while ago, one of which is pack, a translator for various archive formats so that I can write pack foo rather than tar -xyzomgwtfbbq foo.tar.gz foo

#!/usr/bin/ruby

require 'optparse'
require 'pp'
require 'fileutils'

archive_types = {
  "tar" => ["tar", "-cvf"],
  "tar.gz" => ["tar", "-zcvf"],
  "tgz" => ["tar", "-zcvf"],
  "tar.bz2" => ["tar", "-jcvf"],
  "zip" => ["zip"]
}

########## parsing inputs
options = { :type => "tar", :excluded => [".git", ".gitignore", "*~"] }
optparse = OptionParser.new do|opts|
  opts.on("-e", "--exclude a,b,c", Array,
          "Specify things to ignore. Defaults to [#{options[:excluded].join ", "}]") do |e|
    options[:excluded] = e
  end
  opts.on("-t", "--type FILE-TYPE",
          "Specify archive type to make. Defaults to '#{options[:type]}'. Supported types: #{archive_types.keys.join ", "}") do |t|
    options[:type] = t
  end
end
optparse.parse!
##########

ARGV.each do |target|
  if not archive_types[options[:type]]
    puts "Supported types are #{archive_types.keys.join ", "}"
    exit
  elsif options[:type] == "zip"
    exclude = options[:excluded].map{|d| ["-x", d]}.flatten
  else
    exclude = options[:excluded].map{|d| ["--exclude", d]}.flatten
  end
  fname = target.sub(/\\/$/, "")
  args = archive_types[options[:type]] +
    [fname + "." + options[:type], fname] +
    exclude
  system(*args)
end

So that was necessary in bash, and because shell scripts can't easily share data, the companion script, unpack, had to define almost the exact same set of file-extension-to-command/option mappings2. If I'm using clisp, I could instead write

(defun pack (file-name &key (type tar) (exclude '(".git" ".gitignore" "*~")))
  (pack-file (make-instance type :file-name file-name :excluded exclude)))

(defmethod pack-file ((f tar.gz))
  (run-shell-command (format nil "tar -zcvf ~@[~{--exclude ~a~^~}~]~a"
                             (excluded f) (file-name f))))

and be done with it. This is a similar, but more extreme version of the previous point. Instead of writing shell-scripts, I can now write functions, macros or methods. These are smaller conceptual units and deal with inputs more easily, letting me focus on expressing what I want the script to do. In fact looking at language shells this way makes it obvious that things like optparse are just hacks to get around the way that scripts accept arguments.

The last cool thing is to do with the package management. I could be wrong about this, but I don't think the Lisp notion of in-package exists elsewhere. So I can define a package like

(defpackage :apt-get (:use :cl))

(defun install (&rest packages)
  (su-cmd "apt-get install ~{~(~a~)~^ ~}" packages))

(defun update ()
  (su-cmd "apt-get update"))

(defun search (search-string)
  (cmd "apt-cache search '~a'" search-string))

where the cmds are defined as something like

(defmacro cmd (command &rest args)
  `(run-shell-command
    (if args `(format nil ,command ,@args) `command)))

(defmacro su-cmd (command &rest args)
  `(run-shell-command
    (format nil "su -c \\"~a\\""
            (if args `(format nil ,command ,@args) `command))))

The issue I'd have with defining these in, for example a Python shell, is that I'd then have a choice. I could either import the file and put up with typing out the name of the module at every invocation, or I could import install, update, search from and then hope that I don't have to define conflicting functions3. In a Lisp shell, I can define it and load it and then do (in-package :apt-get) when I need to do a series of commands relating to installing new modules.

Now all of these, clisp-exclusive or not, are small syntactic fixes that work around basic shell annoyances. To the point that you're probably asking yourself what the big deal is. It's basically the same reason that macros are awesome; they get rid of inconsistencies at the most basic levels of your code, and the increased simplicity you get that way has noticeable impacts further up the abstraction ladder. The sorts of things that look like minor annoyances can add up to some pretty hairy code, and cutting it off at the root often saves you more trouble than you'd think.

I'll admit that tab completion on file names is a pretty big thing to lose4, but the things I outline above are mighty tempting productivity boosts to my shell. To the point that I'm fairly seriously debating switching over on my main machine. Between Emacs, StumpWM/Xmonad and Conkeror, it's not really as if someone else can productively use my laptop anyway. Adding an esoteric shell really doesn't seem like it would be a big negative at this point.

  1. Including fairly complex CLI stuff like wicd-curses, mplayer and rsync --progress.
  2. Except for expansion rather than compression.
  3. Or import another module that defines new ones with the same names.
  4. And I'm going to put a bit of research into not losing it.

Creative Commons License

all articles at langnostic are licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License

Reprint, rehost and distribute freely (even for profit), but attribute the work and allow your readers the same freedoms. Here's a license widget you can use.

The menu background image is Jewel Wash, taken from Dan Zen's flickr stream and released under a CC-BY license