WEB Advent 2008 / Don’t Commit That Error

It’s easy to get caught up in testing patterns, frameworks, and other “correct” ways to to handle testing, but we often overlook some of the simple things PHP can do to make sure we aren’t making basic mistakes. This article covers one of those overlooked tools: php -l. Running the PHP executable from the command line with the -l tells PHP to check the syntax of the provided PHP file, or “lint” the file.

Let’s take a look at what this does. Create a file called hello.php with this as its content:

<?php

echo "Hello World!"

You might have noticed, there’s a syntax error. That’s so we can demonstrate what php -l does when it encounters a syntax error.

Now, from the command line, run the following. Your output should be similar.

prompt> php -l hello.php

Parse error: syntax error, unexpected $end, expecting ',' or ';' in hello.php on line 4
Errors parsing hello.php

That tells us that PHP expected a comma, or a semicolon, but it got to the end of the file instead. Add a semicolon after the echo "Hello World!" line and run it again. This time it should look like this:

prompt> php -l hello.php
No syntax errors detected in hello.php

PHP’s lint mode is useful for making sure files you’re getting ready to commit are at least syntactically correct. It’s not a replacement for real unit tests, but it’s a nice gut check for code that doesn’t have tests written yet. Committing a simple syntax error is embarrassing, and this helps you double check yourself.

Running this command manually ends up taking way too much time. Fortunately, most VCSs have the ability to add scripts that run before you make a commit. These scripts are called pre-commit hooks. A pre-commit hook is just a script that can be run from the shell. It can keep a commit from being allowed depending upon what exit code it uses when it stops. A normal exit code, or zero, tells the VCS to allow the commit; anything else tells it to not allow the commit.

Git is my VCS of choice, so that’s what I’ll use for this article. There’s a quick crash course available if you’re not familiar with Git and want to follow along. Adding a pre-commit hook script to a Git repository is straight-forward. Create a .git/hooks/pre-commit script with the following code in it.

#!/usr/bin/php
<?php

$output = array();
$return = 0;
exec('git rev-parse --verify HEAD 2> /dev/null', $output, $return);
$against = $return == 0 ? 'HEAD' : '4b825dc642cb6eb9a060e54bf8d69288fbee4904';

exec("git diff-index --cached --name-only {$against}", $output);

$filename_pattern = '/\.php$/';
$exit_status = 0;

foreach ($output as $file) {
    if (!preg_match($filename_pattern, $file)) {
        // don't check files that aren't PHP
        continue;
    }

    $lint_output = array();
    exec("php -l " . escapeshellarg($file), $lint_output, $return);
    if ($return == 0) {
        continue;
    }
    echo implode("\n", $lint_output), "\n";
    $exit_status = 1;
}

exit($exit_status);

There are some pieces of this code that might look like voodoo, but let’s walk through them. The first line is called a Shebang. It tells your system to execute the file using the executable specified. This is how your system knows that the file it’s executing is PHP code.

Some systems install PHP in different locations. Another common place is /usr/local/bin/php. Adjust as necessary if your PHP executable lives somewhere else. You can type whereis php from most Unix-like operating systems to find it.

The <?php is self-explanatory. The next two lines set up temporary variables. exec() takes its second and third arguments by reference, so we define them here to the defaults we want, then pass them in.

On the next line, we call exec(). It executes the git rev-parse command which determines if you’re getting ready to make the first commit to your repository or if there are other commits. The 2> /dev/null line at the end of the command keeps any errors from being output. What we really care about here is the $return variable we created earlier. It’s still 0 if our repository has commits in it.

After $return is populated, we use it to determine what we’re comparing our code against. If it is 0, we can use HEAD to determine what files have changed; otherwise, we use 4b825dc642cb6eb9a060e54bf8d69288fbee4904 to compare against an empty commit.

Next, we call git diff-index with a few parameters. First, we add --cached to tell Git we only want files that are going to be committed. Then, we add --name-only to tell Git to only output the name of the files that are being committed.

Notice this time we only pass in one extra argument to exec(), the $output variable. We don’t care what the return value is; we only want the output from the command. $output is an array populated with each line as its own entry. git diff-index outputs one file per line, so it works perfectly to create an array of files that have changed.

Now we’re almost ready to start checking the files. There are a few variables that need to be initialized. $filename_pattern is a regular expression to determine if the file we have needs to be linted. You can modify this if you use something other than .php for your file extensions.

Note: You can do this check much more elegantly using FilterIterators. I’m using regular expressions, because I imagine more people are familiar with them. Check out Elizabeth Smith’s article from last year’s PHP Advent explaining how to use FilterIterators instead if you’re interested.

Next, we set the $exit_status to 0. Remember that our pre-commit script needs to exit with a zero to say it was successful. This sets the default state.

We’ve got a list of files that are about to be committed in $output. Now the script starts looping over that list. Each file is checked against our regular expression to see if it is a PHP file to test.

Files that make it past that first check are run through php -l. Notice we’re grabbing the output and the return value again. PHP will exit with a zero if the file has no syntax errors. If that’s what we get, we just continue along. If not, we display the output by imploding the $lint_output array and changing the $exit_status to 1 which signifies that there was an error.

The final line exits using the $exit_status value. Now, let’s try this. Put a copy of the above file in your .git/hooks/pre-commit and make sure it is executable (chmod +x .git/hooks/pre-commit). Create the hello.php from earlier with the syntax error in it and try to commit that to your repository.

prompt> git add hello.php 
prompt> git commit -m "testing pre-commit"

Parse error: syntax error, unexpected $end, expecting ',' or ';' in hello.php on line 4
Errors parsing hello.php

Now you’re set. Every time you run git commit, Git runs your pre-commit script to verify that every PHP file you commit is at least syntactically correct. You could extend this to run unit tests for your files, or any number of tasks before you commit.

All this Git talk might have you worried if you’re running Subversion. You can do the same thing in Subversion. Matthew Turland put together a blog post explaining how he does it.

<shameless plug>If this Git stuff is new to you and you’re interested in learning more, in addition to the excellent community documentation available online, you can check out my book, Pragmatic Version Control using Git, for an introduction to Git. It’s scheduled to ship later this month.</shameless plug>

Other posts