Zulip Chat Archive
Stream: Program verification
Topic: Understanding do-notation syntax
Clément Blaudeau (Oct 13 2025 at 07:35):
I'm trying to understand the do-notation syntax (as we're using it as a target of our translation of Rust code to lean). I'm puzzled by the fact that chaining monadic let-bindings on the right-hand side is not allowed :
def this_fails (_: Nat) : Option Nat := do
let x : Nat ←
let y : Nat ← pure 0 -- fails here
pure (y + 1)
pure x
while putting a match of if-then-else works fine :
def works_match (_: Nat) : Option Nat := do
let x : Nat ←
match () with
| _ =>
let y : Nat ← pure 0
pure (y+1)
pure x
def works_ite (_: Nat) : Option Nat := do
let x : Nat ←
if h: true then
let y : Nat ← pure 0
pure (y+1)
else
by apply False.elim; apply h; rfl
pure x
Here are the examples in the playground. It feels like a bug in the syntax, but I wanted to ask if I was not missing something obvious before opening an actual issue on github.
Sebastian Graf (Oct 13 2025 at 08:05):
Short answer: The parser only accepts a single do element/statement in the RHS of let x <- rhs; rest, so it's a feature :)
Long answer: In order to relax this restriction, it would be necessary to handle let mut reassignments in rhs. (The case let x <- y := y + 1 currently is conveniently disallowed, probably for the same reason.) Whenever there's a reassignment in a nested construct, do elaboration currently introduces a join point to pass the updated mut vars. But it only does so for select control flow constructs such as if and match, where it's also important to not duplicate the continuation into the branches. Changing this would be a major rewrite of the do elaborator, I think.
One could rewrite rhs to box up the mut vars along with the result, let (x, newY) <- let y := y + 1; pure ((), newY). That would be the "StateT rather than StateCpsT encoding of the mut vars and it has the downside of boxing up a tuple rather than doing a multi-ar function call to what will end up pretty likely as a non-escaping function closure with the current StateCpsT strategy. Also it would mean more complexity, and it is incompatible with the current translation of early return, which can just be pure (because returns always leave the outermost do and the elaborator uses said CPS strategy.)
So: I see no chance that this "bugfix" will happen with the current do elaborator. But I'm working on rewriting do elaboration this Q, and will consider this as a feature request.
Clément Blaudeau (Oct 13 2025 at 08:40):
Thank you for the answer (short and long) and for the work on this feature!
To understand why wrapping my rhs let-binding into a dummy match () with | _ => works: is it correct to say that the match provides a join point, while having just a rhs let-binding does not ?
So: I see no chance that this "bugfix" will happen with the current
doelaborator. But I'm working on rewritingdoelaboration this Q, and will consider this as a feature request.
Do you know what is the overall timeline on this ? We (at Cryspen) are very interested in this feature (and corresponding mvcgen support) and might try to contribute to its developpement.
Sebastian Graf (Oct 13 2025 at 12:14):
is it correct to say that the
matchprovides a join point, while having just a rhs let-binding does not ?
Yes, I think that't the gist of it.
Do you know what is the overall timeline on this ?
I should be finished with the rewrite by the end of the quarter. Could you live with a macro until then? I imagine that would be a viable workaround
Sebastian Graf (Oct 13 2025 at 12:41):
That is, something like
macro "let " x:ident " : " ty:term " ←* " rhs:doSeq : doElem =>
`(doElem| let $x : $ty ← match () with | () => $rhs)
#check do
let x : Nat ←*
let y : Nat ←* pure 0 -- no longer fails here
pure (y + 1)
pure x
You could of course override the existing syntax as well.
Clément Blaudeau (Oct 13 2025 at 13:08):
Thank you for the macro! To override the existing syntax, I would need to produce a Parser.term.doLetArrow directly (without quotations) I suppose ? (just replacing ←* by ← in the macro is not enough)
Sebastian Graf (Oct 13 2025 at 14:10):
@Markus Himmel also reminded me that you could simply use a nested do block, which will be denested and force creation of a join point as well. Same expansion:
macro "let " x:ident " : " ty:term " ←* " rhs:doSeq : doElem =>
`(doElem| let $x : $ty ← do $rhs)
#check do
let x : Nat ←*
let y : Nat ←* pure 0 -- no longer fails here
pure (y + 1)
pure x
Here is a macro that does what you want. Note that it emits join points when it doesn't need to, as in the second #check:
import Lean
open Lean Parser
def doIdDecl := leading_parser
atomic (ident >> Term.optType >> ppSpace >> Term.leftArrow) >>
Term.doSeq
def doPatDecl := leading_parser
atomic (termParser >> ppSpace >> Term.leftArrow) >>
Term.doSeq >> optional (checkColGt >> " | " >> Term.doSeq)
@[doElem_parser low] def doLetArrowSeq := leading_parser
withPosition ("let " >> optional "mut " >> (doIdDecl <|> doPatDecl))
macro_rules
| `(doElem| let $[mut%$mutTk?]? $x:ident $[: $ty?]? ← $seq:doSeq) =>
`(doElem| let $[mut%$mutTk?]? $x:ident $[: $ty?]? ← do $seq)
macro_rules
| `(doElem| let $[mut%$mutTk?]? $p:term ← $doSeq:doSeq $[| $seq:doSeq]?) =>
`(doElem| let $[mut%$mutTk?]? $p:term ← do $doSeq:doSeq $[| $seq:doSeq]?)
#check do
let x : Nat ←
let y : Nat ← pure 0 -- no longer fails here
pure (y + 1)
pure x
#check do
let x : Nat ← pure (); pure 3
let y : Nat ← pure (); pure 4
let z : Nat ← pure (); pure 5
return x + y + z
Sebastian Graf (Oct 13 2025 at 15:56):
Beware that the interpretation of semicolons is ambiguous with the macro above, which perhaps is one reason why the default syntax is more restricted. Concretely, do let x : Nat ← pure 13; return x + 3 will not type-check because pure 13; return x + 3 is parsed as one doSeq. This leaves me unconvinced that the above macro is actually a feature that we will support in the builtin syntax.
Clément Blaudeau (Oct 22 2025 at 10:26):
Thank you for the macro and the explanation !
Clément Blaudeau (Nov 13 2025 at 15:09):
Could another fix be to allow some delimiters in the do-statement syntax ? This would serve as a join point just like control flow ?
Sebastian Graf (Nov 13 2025 at 15:35):
Sorry, I didn't understand that. What kind of delimiters do you have in mind? To delimit what?
Clément Blaudeau (Nov 19 2025 at 13:47):
I was thinking some thing like :
let x <- ( --delimiter, starts a new do-statement
let y <- ... ;
y + 1 )
-- continue with top-level
let z <- ...
delimiters could serve as joint point ?
Sebastian Graf (Nov 19 2025 at 20:06):
Why can't you use do as a delimiter?
let x <- do
let y <- ...
y + 1
-- continue with top-level
let z <- ...
Last updated: Dec 20 2025 at 21:32 UTC