Skip to content

A markup CE needs for (in seq too) and a decision on custom operations; the docs table stops at yield #103

Description

@simontreanor

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions