Found while scoping an HTML DSL for super-scrabble's browser view with Pyfun 0.7.0.
web.pyfun builds its page by f-string concatenation with hand-placed
escaping. The obvious replacement is a Node ADT and a builder over it, so a
page reads as its structure. User builders take you most of the way — a
Html { } over yield/yield! compiles and runs today — but two F# CE
items that a markup DSL leans on are absent, and one of them is absent from
the built-in seq { } too. The docs table is also behind the compiler.
What works
type Node = El string (List string) (List Node) | Text string
module Html =
let yield_ n = [n]
let yieldFrom ns = ns
let combine a b = List.concat a b
let delay f = f ()
let zero = []
let page rows =
el "body" (Html {
yield el "h1" [text "Super Scrabble"]
yield! rows |> List.map (fun r -> el "tr" [text r])
yield el "p" [text "done"]
})
<body><h1>Super Scrabble</h1><tr>a</tr><tr>b</tr><p>done</p></body>
Emitted, it is the expected chain:
def page(rows):
return el("body", Html_combine(Html_yield_(el("h1", [text("Super Scrabble")])), Html_delay(lambda _: Html_combine(Html_yieldFrom(_pf_map(lambda r: el("tr", [text(r)]), rows)), Html_delay(lambda _: Html_yield_(el("p", [text("done")])))))))
The protocol as src/desugar.rs implements it: bind, return_,
returnFrom, yield_, yieldFrom, combine, delay, zero. Everything
below is about what that list lacks.
1. No for
A board is 21 rows of 21 cells. In F# that is
Html {
for r in rows do
yield tr r
}
and For is in the protocol DESIGN.md §8.1 lists as the model being
followed ("Bind, Return, ReturnFrom, Zero, Combine, Delay,
For, While"). It is not in CeItem (src/parser/ast.rs:651) and the
item parser does not know it:
error: expected `let!`, `let`, `do!`, `return`, or `yield`, found identifier `for`
--> 3:5
|
3 | for x in xs do
| ^^^
That message is from seq { }, so the built-in has the same gap: the only
spelling of "yield one per element" is yield! (List.map f xs), which
allocates the whole list to feed a lazy generator, and yield! (Seq.map …)
otherwise. Neither is what Python's for … in …: yield says, and seq's
promise is idiomatic generators.
Pyfun has no for loop elsewhere — a fun is recursive and folds do the
looping — so for would be a contextual keyword inside CE braces only, the
same way async/seq/result/option are keywords only before {.
Desugaring for user builders is one row: for x in e do … →
B.for_ e (fun x -> …) (F#'s For, keyword-safe like return_). For
seq the native lowering is the Python for statement.
2. No custom operations
Every markup CE in F# (Fun.Blazor, Falco.Markup's CE form, Feliz's
prop style) spells attributes as custom operations:
Html {
class' "board"
yield td "x"
}
Pyfun rejects the line — class_ "board" is neither a keyword item nor an
expression that fits one, so it hits the same "expected let! …" error.
That is the right answer for the protocol as designed; custom operations
are the part of F#'s CE machinery that needs a per-builder table
([<CustomOperation>]), and a bare-call item would have to resolve against
the builder module by name. Whether that belongs in Pyfun is a design
decision I would not make on one program; without it a markup DSL is
list-shaped (div [attrs] [kids]), which is what F#'s own community settled
on for Feliz, and that works fine today. Recording the gap so the decision
is made rather than stumbled on.
3. A first-item near-miss is diagnosed as a record
The tell that these are missing is the message, and in first position it is
the wrong message:
error: expected `=`, found identifier `r`
--> 11:9
|
11 | for r in rows do
| ^
error: expected `=`, found string literal
--> 12:12
|
12 | class_ "board"
| ^^^^^^
Html { for … and Html { class_ … do not satisfy starts_ce_item
(src/parser/mod.rs:1637), so the one-token lookahead that separates
Maybe { let! … } from Some { x = 1 } takes the record path, and the
error is about a record literal that was never intended. In second position
the same line gets the honest "expected let! … found identifier" message
(src/parser/mod.rs:1994). The lookahead is a deliberate design (DESIGN.md
§8.1); the fix is only in the diagnostic — when an uppercase name is
followed by { ident <not =>, say that the identifier is not a CE item
rather than that = is missing.
4. The docs table stops at yield
docs/src/internals/03-desugaring.md:50-59 quotes the protocol table from
src/desugar.rs with six rows: let!, do!, let, return, return!,
yield. The source and DESIGN.md §8.1 have eight — yield! →
B.combine (B.yieldFrom e) (B.delay …) and (empty) → B.zero. The two
that are missing are exactly the two a list-shaped DSL needs (yield! for
the rows, zero for an empty element), and lesson 20 mentions neither, so
a reader working from the docs would conclude the DSL above is impossible.
The docs quote is a copy, not an include, so it drifted when #8 on the
roadmap added the rows.
Suggested order
4 is a doc fix. 3 is a diagnostic. 1 is the feature — for in seq { }
is the strongest case, since it is the one place the native lowering
already has the Python form waiting. 2 is a design question to record and
decide, not a request to build.
Found while scoping an HTML DSL for super-scrabble's browser view with Pyfun 0.7.0.
web.pyfunbuilds its page by f-string concatenation with hand-placedescaping. The obvious replacement is a
NodeADT and a builder over it, so apage reads as its structure. User builders take you most of the way — a
Html { }overyield/yield!compiles and runs today — but two F# CEitems that a markup DSL leans on are absent, and one of them is absent from
the built-in
seq { }too. The docs table is also behind the compiler.What works
<body><h1>Super Scrabble</h1><tr>a</tr><tr>b</tr><p>done</p></body>Emitted, it is the expected chain:
The protocol as
src/desugar.rsimplements it:bind,return_,returnFrom,yield_,yieldFrom,combine,delay,zero. Everythingbelow is about what that list lacks.
1. No
forA board is 21 rows of 21 cells. In F# that is
and
Foris in the protocolDESIGN.md§8.1 lists as the model beingfollowed ("
Bind,Return,ReturnFrom,Zero,Combine,Delay,For,While"). It is not inCeItem(src/parser/ast.rs:651) and theitem parser does not know it:
That message is from
seq { }, so the built-in has the same gap: the onlyspelling of "yield one per element" is
yield! (List.map f xs), whichallocates the whole list to feed a lazy generator, and
yield! (Seq.map …)otherwise. Neither is what Python's
for … in …: yieldsays, andseq'spromise is idiomatic generators.
Pyfun has no
forloop elsewhere — afunis recursive and folds do thelooping — so
forwould be a contextual keyword inside CE braces only, thesame way
async/seq/result/optionare keywords only before{.Desugaring for user builders is one row:
for x in e do …→B.for_ e (fun x -> …)(F#'sFor, keyword-safe likereturn_). Forseqthe native lowering is the Pythonforstatement.2. No custom operations
Every markup CE in F# (Fun.Blazor, Falco.Markup's CE form, Feliz's
propstyle) spells attributes as custom operations:Pyfun rejects the line —
class_ "board"is neither a keyword item nor anexpression that fits one, so it hits the same "expected
let!…" error.That is the right answer for the protocol as designed; custom operations
are the part of F#'s CE machinery that needs a per-builder table
(
[<CustomOperation>]), and a bare-call item would have to resolve againstthe builder module by name. Whether that belongs in Pyfun is a design
decision I would not make on one program; without it a markup DSL is
list-shaped (
div [attrs] [kids]), which is what F#'s own community settledon for Feliz, and that works fine today. Recording the gap so the decision
is made rather than stumbled on.
3. A first-item near-miss is diagnosed as a record
The tell that these are missing is the message, and in first position it is
the wrong message:
Html { for …andHtml { class_ …do not satisfystarts_ce_item(
src/parser/mod.rs:1637), so the one-token lookahead that separatesMaybe { let! … }fromSome { x = 1 }takes the record path, and theerror is about a record literal that was never intended. In second position
the same line gets the honest "expected
let!… found identifier" message(
src/parser/mod.rs:1994). The lookahead is a deliberate design (DESIGN.md§8.1); the fix is only in the diagnostic — when an uppercase name is
followed by
{ ident <not =>, say that the identifier is not a CE itemrather than that
=is missing.4. The docs table stops at
yielddocs/src/internals/03-desugaring.md:50-59quotes the protocol table fromsrc/desugar.rswith six rows:let!,do!,let,return,return!,yield. The source andDESIGN.md§8.1 have eight —yield!→B.combine (B.yieldFrom e) (B.delay …)and(empty)→B.zero. The twothat are missing are exactly the two a list-shaped DSL needs (
yield!forthe rows,
zerofor an empty element), and lesson 20 mentions neither, soa reader working from the docs would conclude the DSL above is impossible.
The docs quote is a copy, not an include, so it drifted when #8 on the
roadmap added the rows.
Suggested order
4 is a doc fix. 3 is a diagnostic. 1 is the feature —
forinseq { }is the strongest case, since it is the one place the native lowering
already has the Python form waiting. 2 is a design question to record and
decide, not a request to build.