gelisam / klister

an implementation of stuck macros
BSD 3-Clause "New" or "Revised" License
128 stars 11 forks source link

macro from local function #229

Open gelisam opened 5 months ago

gelisam commented 5 months ago

I still think we should add support for binding a local function of type (-> Syntax (Macro Syntax)) to an identifier, which a macro can then return as part of a syntax object.

In our previous discussions, we had two objections:

  1. the captured variables are meaningless at phases other than the one they were captured in
  2. syntax parameters cover most of the use cases

I have counter-arguments to both objections.

Variable capture

Identifiers already have a different meaning at different phases. Currently, code at level 0 can call let-syntax with a level 1 function, which captures level 1 variables, to bind it to a level 0 identifier my-macro. Then the level 0 body of let-syntax can call (my-macro) in order to run its body at level 1. Since the my-macro body now runs at level 1, it is fine that this body refers to the variables it captured at level 1.

-- level 0
#lang "prelude.kl"
(import (shift "prelude.kl" 1))

(meta
  -- level 1
  (define enable-ten 1))  -- bound in the regular environment at level 1

-- level 0
(example
  (let-syntax
    [my-macro  -- bound in the expander environment at level 0
     (lambda (stx)
       -- level 1
       (if (= enable-ten 1)  -- captures enable-ten
         (pure '10)
         (pure '5)))]
    -- level 0
    (+ 1 (my-macro))))  -- used at level 0, its body runs at level 1
-- returns 11

Similarly, I want code at level 1 to call, say, let-local-syntax with a level 1 function, which captures level 1 variables, to bind it to a level 0 identifier. This time, inner-macro-name is not that level 0 identifier, it is a level 1 identifier whose value is that level 0 identifier. Then the level 1 body of let-local-syntax can construct a syntax object which contains this level 0 identifier. When that code is spliced into level 0, the level 0 identifier is recognized as a macro, so it runs its body at level 1. Since this body now runs at level 1, it is fine that this body refers to the variables it captured at level 1.

#lang "prelude.kl"
(import (shift "prelude.kl" 1))

-- level 0
(define-macro (outer-macro)
  -- level 1
  (let [enable-ten 1]  -- bound in the regular environment at level 1, its value is 1
    (let-local-syntax
      [inner-macro-name  -- bound in the regular environment at level 1, its value is 'inner-macro
       (lambda (stx)
         -- still level 1
         (if (= enable-ten 1)  -- captures enable-ten
           (pure '10)
           (pure '5)))]
      (pure `(+ 1 (,inner-macro-name))))))

(example (outer-macro))
-- expands to
-- (+ 1 (inner-macro))  -- used at level 0, its body runs at level 1
-- returns 11

Use case

Currently, if I want a macro to cooperate with the type checker by indicating part of the type of the expression it wants to return, the definition of this macro must be divided into a main macro and an auxiliary macro:

#lang "prelude.kl"
(import "define-syntax-rule.kl")

(define-syntax-rule (aux-macro)
  -- imagine a more complex macro which does something different depending on
  -- whether A is Integer or String
  (lambda (x) x))

(define-syntax-rule (main-macro)
  (with-unknown-type [A]
    (the (-> A A)
      (aux-macro))))

(example (main-macro))

It would be nice to automate this work of splitting the definition of the macro:

(define-macro (main-macro)
  (with-unknown-type [A]
    (with-partially-known-type (-> A A)
      -- imagine a more complex macro which does something different depending on
      -- whether A is Integer or String
      (pure '(lambda (x) x)))))

With let-local-syntax, the implementation of with-partially-known-type is easy:

(define-macro (with-partially-known-type tp body)
  (let-local-syntax
    [inner-macro-name (lambda (_stx) body)]
    (pure `(the tp (,inner-macro-name)))))

And seamlessly allows local variables to remain in scope within the body of with-partially-known-type:

(define-macro (main-macro)
  (let [enable-ten 1]
    (with-unknown-type [A]
      (with-partially-known-type (-> A A)
        -- use enable-ten
        ...))))

Whereas without let-local-syntax, it is necessary for the implementer of main-macro to know that with-partially-known-type defines an intermediate macro under the hood, so that it can make enable-ten a syntax parameter instead of an ordinary local variable.