CrystalScript - a Crystal binary caching CLI

I wanted to be able to use Crystal as a scripting language, meaning I could run source files without them having to be systematically compiled.

It’s already possible by doing crystal my_script.cr, but I wondered if I could get rid of that systematic half-second compilation time every time I run the script.

So I built a CLI named crystalscript (I was not very inspired for a name). It’s basically a wrapper that will compile your script and cache the binary the first time it’s executed. After that, it will only replace the cached binary when the source changes.

The best way to use it is with shebangs, for example lets make a simple say_hello.cr script

#!/usr/bin/env crystalscript

puts "Hello"

Make it executable

chmod u+x say_hello.cr

Now it’s possible to call it like this

./say_hello.cr

I also made it so there are different optimization mode for the binaries.

For example, when I’m creating a new script, it will change often and I want the compilation to be as fast as possible. In this case I can use this shebang.

#!/usr/bin/env -S crystalscript --mode dev

When I’m done I can change the mode to normal (default) or release to have a faster and lighter binary.

Those modes are just presets using the crystal compiler args like -O, --release, --no-debug, etc. More info on the compilation modes for CrystalScript.

On a side note, I found a similar project called scriptisto which works with many languages, but I still wanted to try to build one myself for Crystal.

7 Likes

Looks like a great helper!
Thanks for the reference to scriptisto. While not specialized for Crystal directly, it might be a good general purpose tool.

I’m actually a bit surprised to see a sqlite database, though. It seems to only store some metadata about the cache. Why did you chose that instead of writing directly to the file system? Would be fewer dependencies…

One of the main reasons to use SQLite is I needed a way to know which cached binary belongs to which script (absolute path); otherwise, a single script path could accumulate multiple stale binaries every time it’s updated.

I considered hashing the absolute path and concatenating it to the binary’s name, but I felt it would be easier to use SQLite, as there is also other metadata I want to use for cache invalidation.

I want to be able to replace cached binaries when any of the following changes:

  • The script content
  • The compilation parameters
  • Crystal version

At some point it will also be used to optionally clean

  • binaries that have not been called for a certain amount of time
  • orphaned binaries, whose source script doesn’t exist anymore

I’m wondering if the path of the original source file should even be relevant for caching. Two source files with the same contents should (usually) produce the same executable. And it seems quite common to share the same script across multiple projects. So it would be nice to reuse an existing executable, even if it was build from a different source path. :thinking:

Yes, actually that’s the case. The binary’s filename is a hash of the script’s content, so if two scripts in different locations have identical content, there will be only one cached binary but two entries in the database. I only made the table unique on the source absolute path because one path shouldn’t have multiple cached binaries at a time.

In such case, when we modify one of the two identical scripts:

  1. We retrieve the initial content hash by using the path of the script being executed; otherwise, at that point I wouldn’t be able to tell what content hash was used for this script before it was updated.
  2. Compile the new version, which creates a new binary file.
  3. Update the database entry with the new content hash.
  4. Check in the database if any other entry still references the old content hash and, based on that, potentially clean the old binary.
1 Like