`~describe` vs. `#:role` in `syntax-parse`

How should we choose between ~describe descriptions and #:role for error information with syntax-parse?

Consider this macro, inspired by a discussion on Discord:

#lang racket

(require (for-syntax syntax/parse))

(define-syntax hash-refs
  (syntax-parser
    [(_ (~var hsh expr #:role "hash")
        (~describe "key with optional default"
                   (~seq (~var key expr #:role "key")
                         (~optional (~seq #:or (~describe "default expression"
                                                          default:expr)))))
        ...)
     #`(let ([h hsh])
         (values (hash-ref h key (~? default)) ...))]))

The incorrect use (hash-refs #hash() 'foo #:or) gives a helpful error message:

hash-refs: expected more terms starting with default expression
  parsing context: 
   while parsing key with optional default

If we replace the (~describe "default expression" default:expr) form with (~var default expr #:role "default"), the error message is less useful and does not mention the role:

hash-refs: expected more terms starting with expression
  parsing context: 
   while parsing key with optional default

For completeness, I also tried (~describe #:role "default" "Expression" default:expr), which produced (again without mentioning the role):

hash-refs: expected more terms starting with Expression
  parsing context: 
   while parsing key with optional default

Maybe this is just a bug, and these error messages should mention the role?

I think ~describe makes the most sense when it's divorced from the context of a particular macro, especially since using it is equivalent to moving the pattern it's wrapping into a syntax class and giving that syntax class a #:description.

The #:role option makes more sense (to me) for disambiguation between two pieces of input syntax that are supposed to have the same shape, but which are used for different purposes. It seems similar to the distinction between types and parameter names to me.

With these ideas in mind, I rewrote your macro above as follows:

#lang racket

(require syntax/parse/define)


(begin-for-syntax
  (define-splicing-syntax-class hash-refs-clause
    #:attributes (key default)
    #:description "key with optional default"
    (pattern (~seq key (~optional (~seq #:or (~describe "default expression" default:expr))))
      #:declare key expr #:role "key")))

(define-syntax-parse-rule (hash-refs hsh clause:hash-refs-clause ...)
  #:declare hsh expr #:role "hash"
  (let ([h hsh])
    (values (hash-ref h clause.key (~? clause.default)) ...)))

As best as I can tell, this produces the same error messages. Note also that I decided to use #:declare with #:role instead of using ~var, since in my opinion the use of ~var makes it more difficult to understand the structure of the macro's input.

But I'm not sure what to do about that (~describe "default expression" default:expr) bit. That's an interesting case.

Separately though, we can use syntax classes to get a slight improvement on the error message. In the error message for the incorrect use (hash-refs #hash() 'foo #:or), the context is given as while parsing key with optional default. But once we've seen #:or, that default doesn't seem so optional to me anymore. If we restructure the syntax classes like so:

(begin-for-syntax
  (define-splicing-syntax-class hash-refs-clause
    #:attributes (key default)
    #:description #false
    (pattern key
      #:declare key expr #:role "key"
      #:attr default #false)
    (pattern :key-with-default))

  (define-splicing-syntax-class key-with-default
    #:attributes (key default)
    #:description "key with default"
    (pattern (~seq key #:or (~describe "default expression" default:expr))
      #:declare key expr #:role "key")))

(define-syntax-parse-rule (hash-refs hsh clause:hash-refs-clause ...)
  #:declare hsh expr #:role "hash"
  (let ([h hsh])
    (values (hash-ref h clause.key (~? clause.default)) ...)))

...then the error message for (hash-refs #hash() 'foo #:or) will correctly notify us that the default expression is not optional in that context, since we've supplied #:or:

hash-refs: expected more terms starting with default expression
  parsing context: 
   while parsing key with default in: (hash-refs #hash() (quote foo) #:or)
1 Like

The "expected more terms starting with" error is special, since there is no term to "run" the syntax class on. The "starting with" part is only included if the description is a literal string, and it ignores the role. It would probably be possible to include the role if the role is also a literal string, though.

1 Like

Using a syntax class also by default has ~delimit-cut behavior, and it changes the visibility of attributes.

I like that!

Amusingly, my first implementation of this macro looked more like this version, including using a splicing syntax class with two patterns.

However, for the erroneous use (hash-refs test-hash 'foo #:and), your version here and mine at the top of this thread produce the same, reasonably nice error message:

hash-refs: expected expression for key or expected the literal #:or
  parsing context: 
   while parsing different things... in: #:and

In contrast, this early version I wrote:

(define-syntax hash-refs
  (syntax-parser
    [(_ (~describe "hash expression" hsh:expr)
        (~describe "key with optional default"
                   (~or* (~describe "required key expression" key:expr)
                         (~describe "key with default"
                                    (~seq (~describe "key expression" key:expr)
                                          #:or
                                          (~describe "default expression" default:expr)))))
        ...)
     #`(let ([h hsh])
         (values (hash-ref h key (~? default)) ...))]))

produces this worse error message, which just says "expression" instead of "required key expression":

hash-refs: expected expression or expected the literal #:or
  parsing context: 
   while parsing different things... in: #:and

I was surprised to find that your implementation didn't produce this message, because the structure of the patterns seems basically the same, other than inlining the syntax classes. Maybe there's some combination of #:opaque, #:role, or ~delimit-cut/~commit/~! that would produce the same result, but I haven't found it yet.

Overall, I feel like I know how to use these features well enough to get reasonably good error messages (especially because syntax-parse does so much right automatically), but I don't feel like I have very strong intuitions about what changes to make when there's something I want to fine-tune: I work a lot out through trial-and-error experimentation.

Maybe I want some kind of debugger or inspector for syntax-parse error messages?

It hadn't even occurred to me that the role and description can be expressions, not just literal strings! That makes a lot of sense. And yes, including the role when possible does seem like a nice enhancement.

There's no nice front end, but the syntax/parse/debug module has some tools for debugging/inspecting how syntax-parse behaves. In particular, debug-syntax-parse! will tell syntax-parse to print out the internal data structures gathered during parsing that it uses to synthesize error messages. You should probably look at the comments in (submod syntax/parse/private/residual progress) to understand what the data structures mean.

The translation to error "prose" is in syntax/parse/private/runtime-report; it's complicated, but there are some comments about when error reporting discards specific information. I think the main thing you can rely on is that if all of the parse failures have a common prefix, you can rely on syntax-parse to report that common prefix. After the failures fork, you're at the mercy of heuristics. Your early version had two occurrences of key:expr in subpatterns with different descriptions. If there's a failure, it's going to fail in two different ways. In contrast, @notjack's first version has only one subpattern for the key, followed by an optional suffix.