Improving Factor's error messages

March 13, 2010

For all its amazing features, Factor has awful error reporting. This is probably because reporting good error messages is not as sexy as performance tuning or writing new libraries, but nonetheless, error reporting is a huge, important part of a development environment's user experience. Just look at all the excitement over Clang giving better error messages than GCC. Factor's still a ways away from correcting spelling errors, but I did spend some time improving its error reporting in two particularly deficient areas: its reporting of syntax errors, and its handling of stack management errors. The nerdy details are below; the gist is: if you've tried Factor before and given up on it because of its opaque error messages, now's a great time to give it another shot. Or if you've stuck with Factor despite its Gen-X apathy toward invalid code, enjoy the monkey that just got pulled off your back.

Syntax errors

Factor's lexer keeps track of where in the source the next token should be read. When a syntax error occurs, the lexer adds this pointer to the reported error message:

5:     swa pappend ;
          ^
No word named ``swa'' found in current vocabulary search path

This works well when syntax errors are detected and raised immediately after being read from the lexer. However, Factor has extensible syntax, and many poorly written syntax handlers would undiscerningly read in their entire form, not verifying for correctness until a subsequent pass. You used to get a lot of this:

12:     ) ;
           ^
“intt” is not a C type

leaving you to open the source code in your editor and search for "intt" to find where the problem actually was. Going through and fixing these syntax handlers to parse and verify their input as it comes from the lexer lets the error messages give much better immediate feedback:

10:         intt bar,
                ^
“intt” is not a C type

There are some syntax errors that can't be detected and reported so immediately. Though Factor doesn't suffer much syntax that requires backtracking, it poorly handles missing closing tokens. With its free-love approach to syntax, you can't really know for sure that your "}" or ";" isn't just around the corner until you hit the end of the file you're parsing. For example, trying to parse this:

{ 1 2 3 }
{ 4 5 6
{ 7 8 9 }

gives a pretty worthless error message:

4: 
   ^
Expected } but got end of input

The missing brace might be obvious in this case, but in a giant haystack of differently sized and punctuated definitions, finding where the needle needs to go could take a while. Now, when an syntax error occurs, it displays the location of the innermost active syntax word in addition to the location the error was raised:

2: { 4 5 6
4: 
   ^
Expected ; but got end of input

That makes it immediately clear which literal is incomplete. The added context benefits other syntax error messages too:

4: : foo ( x y -- z )
5:     swa pappend ;
          ^
No word named ``swa'' found in current vocabulary search path
 8: FUNCTION: int foo (
10:         intt bar,
                ^
“intt” is not a C type

Stack logic errors

Factor enforces much stricter stack discipline than many of its RPN kin: functions must take a declared number of inputs, yield a declared number of outputs, and when using "if" statements and loops, must keep the stack balanced among their branches. The compiler will shun any function definition that rejects these tenets:

( scratchpad ) : derp ( -- ) 2 ;
:errors - show 1 compiler errors
( scratchpad ) :errors
Asset: derp

Stack effect declaration is wrong
inferred (( -- x ))
declared (( -- ))


( scratchpad ) : derp ( ? -- x ) [ 2 ] [ ] if ;
:errors - show 1 compiler errors
( scratchpad ) :errors
Asset: derp

Unbalanced branches
[ 2 ] (( x -- x ))
[ ] (( x -- ))

These error messages are fairly self-explanatory. The problem comes when using higher-order functions. The compiler inlines most common control flow functions (such as map and reduce) so that it can optimize them down to simple loops. As it verifies a function's stack usage, the compiler descends into the definitions of these inlined functions. When it encounters a stack imbalance, it often ends up reporting the error from the perspective of whatever deeply nested inlined function it happened to have been walking through, leaving no breadcrumb trail back to your own code that caused it:

( scratchpad ) : derp ( x -- ) [ 2drop ] each ;
:errors - show 1 compiler errors
( scratchpad ) :errors
Asset: derp

The recursive function (each-integer) digs arbitrarily deep into the stack


( scratchpad ) : derp ( x -- ) [ drop ] [ drop ] if* ;
:errors - show 1 compiler errors
( scratchpad ) :errors
Asset: derp

Unbalanced branches
[ drop call ] ( x -- )
[ nip call ] ( x x -- )

The compiler could just as well have done away with the pretense of context and reported "ERROR". In a longer function definition, finding the one jenga tile knocking down the stack pretty much required single-stepping the code.

C++ suffers a similar problem with template expansion: too little verification of input parameters is done up front, and error reporting is postponed until the last minute when something goes undeniably wrong. Just like the Boost library introduced a Concept Check library to enforce up-front constraints on C++ template parameters, the solution to Factor's stack checking error problem is to have higher-order functions declare the expected stack effects of their input functions, and have the compiler enforce these effects. The resulting error messages are much clearer and more useful:

( scratchpad ) : derp ( x -- ) [ 2drop ] each ;
:errors - show 1 compiler errors
( scratchpad ) :errors
Asset: derp

The input quotations to each don't match their expected effects
Input     Expected           Got
[ 2drop ] (( ... x -- ... )) (( x x -- ))


( scratchpad ) : derp ( x -- ) [ drop ] [ drop ] if* ;
:errors - show 1 compiler errors
( scratchpad ) :errors
Asset: derp

The input quotations to if* don't match their expected effects
Input    Expected           Got
[ drop ] (( ..a ? -- ..b )) (( x -- ))
[ drop ] (( ..a -- ..b ))   (( x -- ))
:errors - show 1 compiler errors

Inlined higher-order functions are allowed a bit more leeway in how much stack they use and leave behind, as long as they leave the stack balanced across any loops or branches they set up. (This allows for things like "reduce" being implemented in terms of "each", by taking its accumulator as an extra input and leaving the updated value as an extra output.) The "...", "..a", and "..b" in those "Expected" columns are "row variables" that represent a number of values taken as inputs or outputs that has to be consistent among the stack effects using the same variable name. Read the Factor help article about them for the details.