Wednesday, May 6, 2009

A Groovy DSL for working with the filesystem.

I've recently come back to the idea of using Java as a shell scripting tool. There are a couple of reasons for this; firstly Java is familiar with all my developers, secondly Java's startup time has seriously improved with the 6u1x series of the Hotspot VM, finally Java has a brilliant dynamic language which slips over core Java in the form of Groovy.

However there are still some problems; the most immediately obvious is the fact the shell scripting often involves a lot file manipulation and calling of external processes, tasks which involve a lot of navigation around the file system. 

Unfortunately Java has never had a chdir() function, so that means you have to work with everything as an absolute file path, often resulting in some nasty string based code.

Fortunately Groovy can overcome this with it's ability to create Domain Specific Languages. We can use these features to map to standard file system concepts.

Lets take the following bash script. This script creates a directory in the home folder, downloads and unzips a contrived application called myapp into the directory and then creates a shortcut link on the desktop:

# create directory in home
mkdir ~/myapp
# change to directory
cd ~/myapp
# download myapp.zip with wget
wget http://www.myapp.com/download/myapp.zip
# extract myapp.zip using unp
unp myapp.zip
# delete myapp.zip
rm myapp.zip
# change Desktop in home
cd ~/Desktop
# create shortcut and append an entry to the executable to myapp
> myapp.desktop
echo "Exec=${HOME}/myapp/myappExecutable" >> myapp.desktop

While we couldn't map this exactly in Groovy we could at least do something similar, this is what I came up with:

//create the my app directory in home
myAppPath = (~Path + "myapp")
//download and extract
myAppPath.exec "wget -q http://www.myapp.com/download/myapp.zip" .exec "unp myapp.zip"
//delete myapp.zip
myAppPath - "myapp.zip"
//create a pointer to myApp executable
myAppPath/"myAppExecutable"
//create shortcut in Desktop folder
~Path/"Desktop"/"myapp.desktop" >> "EXEC=${myAppPath}"

How would we accomplish this in Groovy.

First we define a class called Path with the ability to ability to build up a path via an append method and a toString method to create a string representation of the path:

public class Path {

Stack pathStack;

Path {
pathStack = new Stack();
}

public append(String element) {
pathStack.push "element"
return this
}

public String toString() {
//code to build up the path from the stack
.....
}

}

Now we use Groovy's operator overloading feature to be able to add write pathInstance / "aPathelement", to do this we simply implement the method div on the Path class:

public div(String element) {
return append element;
}

Next we want to be able to quickly get an instance of Path which points to the home directory quickly. I wanted to map this to Bash's '~' home folder expansion. Fortunately we can implement this by overloading the bitwise operator on a static method on the path object:

public static Path bitwiseNegate() {
return new Path()/System.getProperty("user.home")
}

This allows us to write ~Path to create a Path instance to point to the home folder.

Next we want to be able to easily add a new folder to the existing Path instance and then point the Path instance to the new directory by overloading the + operator by implementing the add method:

public Path plus(String element) {
this / element
f = new File(toString())
if !(f.exists()) {
f.mkdirs()
}
return this;
}

Now we need to implement an exec method to execute processes in the directory specified by the Path instance:

public exec(String command) {
//test that the path is a directory
...
command.execute(new File(this.toString(),null)
return this
}

We also need to be able to delete the file by overloading the '-' operator by creating a minus method:

public Path minus(String element) {
this / element
f = new File(toString())
f.delete();
return this;
}

Finally we want to be able to easily write to a file, we can map to to the unix ">>" shell command which is normally used to route a processes std.out to an existing file (kinda what we are trying to do). We can do this by overloading the right shift operator by implementing the rightShift method:

public Path rightShift(String valueToAppend) {
f = new File(toString())
if (!f.exists()) {
f.createNewFile()
}
f.append valueToAppend
return this
}

And there we have enough to implement the script example. It is however worth noting that the Path class is far from complete. For example another common task would be to list the contents of a directory and filter via a regular expression, so we can add a method each which takes a regex and a closure to handle each file as a Path instance:

public each(Pattern p, Closure c) {
new File(toString()).each {
if (p.matcher(it).matches()) {
//..clone current path
Path newPath = ...
newPath / it
c.call(newPath)
}
}
}

now you can write:

~Path.each /foo/ {
//do stuff with it
}

However Groovy allows you to call a method as a string literal for example to call x.toString() you could use x.'toString'(), furthermore we can intercept method calls to methods that don't exist, and enhance this a little and rewrite the former as:

~Path.'/foo/' {
//do stuff with it
}

To do this we implement the invokeMethod to handle the non existent method:

public invokeMethod(String name, args) {
if (name.startsWith("/") && name.endsWith("/") {
//strip out regular expession and create Pattern object and assign to 'createPattern' variable
each(createPattern, args[0]);
} else { //blow up
}
}

So there we have it. Of course this class is not complete, we can add additional methods to do additional operations such as iterating through the elements in the Path instance. Furthermore we might be able to further enhance the class by utilising Groovy's Meta Object Protocol.

A full Listing of the Path class can be found here.

2 comments:

Francois MAROT said...

Well done ! I love the power of Groovy and love what you did with it. Very good way to handle the filesystem while still having the power of the Java/groovy duo.

kellyrob said...

Awesome example of using Groovy as a 'bash' alternative. I've gone down this path as well, but by leveraging the built in AntBuilder instead of your(very nifty) DSL route.
Another real bonus for me is that the Groovy script lends itself much more readily to testing - something that very often never happens with bash scripts.