Guile - Literal string interpolation

May 01, 2022
Tags:

Literal string interpolation is an interesting construct that can help formatting strings in a more natural way. If you don't know what they are, have a look at PEP 498.

SRFI-109 seems to tackle this feature. But it's overkill for such a simple feature.

Thus in this post, I will introduce a very simple syntax in Guile.

Goal

Transform this:

(let ((x 1))
  (fstring "x^2=@{(* x x)}"))

into this:

(let ((x 1))
  (format #f "x^2=~a" (* x x)))

Implementation

The first step is to define what delimit an interpolation in a string. We won't implement escaping of characters here for simplicity.

I've choose to represent my interpolation as @{expression}. One can easily change this by modifying the regex variable.

(define-module (fstring)
  #:use-module (ice-9 regex)
  #:export-syntax (fstring))

(define regex "@\\{([^@]+)\\}")

We then need a way to transform our string into a new string by replacing the interpolation with a format argument. This is the job of fmt->str. Note that this procedure is taking and returning a syntax object.

(define (fmt->str id)
  (datum->syntax id
    (regexp-substitute/global #f regex
                              (syntax->datum id)
                              'pre (lambda _ "~a") 'post)))

Then we need to transform a string with interpolations into a list of arguments. This is the job of fmt->args. Again, this procedure is working with syntax objects.

(define (fmt->args id)
  (datum->syntax x
    (reverse
     (fold-matches regex (syntax->datum id) '()
                   (lambda (match matches)
                     (cons
                      (call-with-input-string
                          (match:substring match 1)
                        read)
                      matches))))))

Finally, we need to glue all of this together in a syntax transformer.

(define-syntax fstring
  (lambda (x)
    (define (fmt->str id) ...)
    (define (fmt->args id) ...)
    (syntax-case x ()
      ((_ fmt)
       (with-syntax ((str (fmt->str #'fmt))
                     ((args ...) (fmt->args #'fmt)))
         #'(format #f str args ...))))))

And voilà. The advantage of this technique is that the transformation is done at expansion time. Which also means that we can rely on the compiler to tell us if there's a missing variable such as in the following example. Try it!

(let ((x 1))
  (fstring "x=@{y}"))

There's another way that I've found using local evaluation in (ice-9 local-eval). However, we loose the advantage of compile checking of the arguments.

There's also some limitations here. What if we want to use a more complex formatting argument such as ~{ ~a~%~} instead of just ~a. This would require some re-working in fmt->str and is left as an exercice.

That's it for today. A small post for a simple feature. Just to show you how easy it's to create new syntaxes for you project in Guile.

Full code

(define-module (fstring)
  #:use-module (ice-9 regex)
  #:export-syntax (fstring))

(define regex "@\\{([^@]+)\\}")

(define-syntax fstring
  (lambda (x)

    (define (fmt->str id)
      (datum->syntax id
        (regexp-substitute/global #f regex
                                  (syntax->datum id)
                                  'pre (lambda _ "~a") 'post)))

    (define (fmt->args id)
      (datum->syntax x
        (reverse
         (fold-matches regex (syntax->datum id) '()
                       (lambda (match matches)
                         (cons
                          (call-with-input-string
                              (match:substring match 1)
                            read)
                          matches))))))

    (syntax-case x ()
      ((_ fmt)
       (with-syntax ((str (fmt->str #'fmt))
                     ((args ...) (fmt->args #'fmt)))
         #'(format #f str args ...))))))