After I started using a password manager to generate my passwords, sometimes I want to do the same in the shell — not necessarily for passwords, but just a random alphanumeric string of a specified length can be useful sometimes.
The command would take just one argument as the length for the output string.
I wrote the first version in Racket, then rewrote it in Common Lisp because Racket's startup speed makes it quite unsuitable for shell commands. I eventually rewrote it in Picolisp because it's available in Termux; it's quite a fascinating language.
The Racket version
Define all the characters that could be used — it's just hardcoded alphanumeric characters because this is my only use case. Here if the environment variable CHARSET
is set it would use that instead.
(define charset (or (getenv "CHARSET")
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"))
The main logic is just making a list of specified length, then choosing a random item (with select-random-item
from the charset for each list item, converting the list into a string in the end.
(define (select-random-item seq)
(sequence-ref seq (random (sequence-length seq))))
(define (random-string [len 16])
(list->string
(map (λ (x) (select-random-item charset))
(make-list len #f)))
}
The last section is the entry point; when there are arguments passed into the script, take the 0th argument and pass it to random-string
(if (empty? (vector->list (current-command-line-arguments)))
(displayln (random-string))
(displayln (random-string (string->number (vector-ref (current-command-line-arguments) 0)))
}
Common Lisp rewrite
The logic is almost exactly the same as the Racket version, though here I didn't bother with looking for the CHARSET
environment variable.
(defvar *charset* "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
One thing that surprised me initially is the existence of "random states". Learning about little things like this is exactly why I like writing simple scripts like this.
(defun select-random-item (seq)
(elt seq (random (length seq)
;; create a newly randomly seeded random state
(make-random-state t))))
Looking at this now, I probably should've written the Racket version with sequence-map
operating directly on a string.
(defun random-string (&optional (len 16))
(map 'string
(lambda (x) (select-random-item *charset*))
(make-string len)))
Here I depend on CLISP by using *args*
(format t (if *args*
(random-string (parse-integer (first *args*)))
(random-string)))
Writing the Picolisp version
I first saw Picolisp because it is currently (as of 2018-10) the only Lisp family language packaged in Termux.
In Picolisp, command line arguments can't be accessed directly in the program. The interpreter itself processes arguments as files to load or functions to invoke. This becomes a problem when the whole point of the script is to be a shell command.
There are two ways around this: one is to simply wrap the whole thing into a shell script, and handle arguments there. That seemed unstatisfying to me (I don't know why now that I've realized how hacky this next solution is), so I went with a hack to bind the first command line argument to a variable in the script.
pil
reads command line arguments starting with a "-" as a function to run; or, more specifically, as a form to evaluate after wrapping it in parentheses. For example, -bye
runs (bye)
immediately, exiting the program; -argv dummy len
runs (argv dummy len)
which binds the first command line argument to dummy
and second to len
but those arguments are still going to be loaded as files.
If pil
sees an argument that's just a "-", it'll stop loading subsequent arguments as files. This would've been the solution: pil -"argv dummy dummy len" random-string - 60
would bind dummy
to "random-string", then to "-", then len
to "60", after which it'll load my script where len
is available. There's just one problem: I have to write this command in the shebang.
The shebang tells the kernel to pass this file to the specified program, like #!/bin/bash
It can also pass an argument to the program, for instance, #!/usr/bin/pulseaudio -nF
in some PulseAudio config files. The problem is, it can only pass one argument: everything after the space in the shebang is passed to the program as $1 in shell notation. This means I can't pass another "-" argument to pil
In the end, my shebang looks like #!/usr/bin/pil -argv dummy len
bind the first argument (path to script) to dummy
second argument to len
len
isn't actually going to be loaded because (bye)
has been called before its loading starts.
#!/usr/bin/pil -argv dummy len
# a bit of a hack around Picolisp's loading mechanism
# random-string [length]
Here I have to seed the PRNG with current time before running select-random-item
because I couldn't find a way to get time more accurate than seconds. If I seed it inside select-random-item
it'd receive the same (fresh) seed and thus return the same character throughout the second.
(seed (+ (date) (time)))
(setq *charset* (chop "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"))
(de select-random-item (seq)
(car (nth seq
(rand 1 (length seq)))))
Picolisp doesn't have defaults for optional arguments, so I have to set it myself when the input is nil.
Another interesting thing about Picolisp is that it actually uses a list of form ((arg1 arg2 ...) body)
as functions. Personally I think this is quite elegant, and would like to see more non-functions that are applicable like this in other Lisps as well. Allowing lists to be applicable like functions shouldn't break anything… I think.
(de random-string (len)
(if (not len) (setq len 16))
(if (str? len) (setq len (format len)))
(mapcar '(() (select-random-item *charset*))
(range 1 len)))
(prinl (random-string len))
(bye)