123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566(* A streaming pretty-printer. The imperative builder API (string, space, box, …)
emits a flat token stream straight into a layout engine; nothing is ever
materialized as a document tree. The engine gives genuine hard breaks, column
awareness and break coalescing we control — unlike a thin layer over OCaml's
[Format] — with bounded memory (the engine buffers only the ~[width] of
lookahead a break/group decision needs). *)typebreak_strength=Cut|Space|Newline|Blank_lineletstrength=functionCut->0|Space->1|Newline->2|Blank_line->3(* ===================================================================== *)(* Token stream + layout engine + imperative builder *)(* ===================================================================== *)moduleDoc=structtypegkind=|GBox(* fill: each soft break wraps independently as needed *)|GHov(* same fill semantics, kept distinct for parity with Format *)|GHv(* all-or-nothing: whole group flat, or every soft break wraps *)|GV(* every soft break wraps *)|GH(* soft breaks never wrap (hard/blank still break) *)(* --- token stream (the builder's output, the engine's input) --- *)(* The document is streamed as a flat token sequence. A group / nest /
if-broken opens with its own token and closes with a matching [TEnd]. A
[TBreak] carries one [break_strength]: Cut/Space are soft (flatten in a
fitting group); Newline/Blank_line always break. *)typetoken=|TTextofint*string(* display width, payload *)|TBreakofbreak_strength|TBeginofgkind|TNestofint|TIfBroken(* Content emitted only when the enclosing group is laid out broken (a
trailing comma after the last element of a list that wraps). It never
counts toward the fit decision. *)|TEnd(* --- layout engine --- *)typemode=Flat|Brkm|Fill(* A live layout frame — the streaming analogue of the old tree renderer's
per-worklist-item [(indent, mode)] pair. *)typeeframe={findent:int;fmode:mode}(* The outcome of a fit scan. Nullary (so returning it never allocates): the
scan parks its resumable state in [scan_state], not a boxed payload. *)typesres=Fits|Nofit|Susp(* A front decision whose fit scan ran out of buffered input and suspended,
resumed on the next [feed] so each buffered token is scanned once. One
record, mutated in place — no per-suspend allocation. [active] flags a live
suspension; [ghv] distinguishes a [GHv] open (uses [base]) from a [Fill]
break (uses [str]); the rest are the parked [scan_go] loop variables. *)typescan_state={mutableactive:bool;mutableghv:bool;mutablebase:int;mutablestr:break_strength;mutableavail:int;mutablesstack:modelist;mutablefr:eframelist;mutableib:int;mutablei:int;}(* A stateful token consumer with bounded lookahead. A decision that needs to
look ahead ([GHv] open, [Fill] break) waits until enough tokens are buffered
in [queue] to resolve it; because [scan] short-circuits once the available
column is exhausted, the FIFO stays bounded by ~[width]. Everything else is
laid out immediately. Returns [(feed, finish)]: [feed] pushes one token,
[finish] signals end of input (and drains the tail). *)letmake_engine~width~add_string~add_char~add_substring=letcol=ref0inletemitted=reffalsein(* Cap how far breaks indent, so deeply nested code does not march off to the
right margin (the analogue of Format's [max_indent]). *)letmax_indent=max0(width-10)in(* A break's indentation is always [<= max_indent] (see [break_line]), so one
string of that many spaces covers every indent: emit a slice of it. *)letspaces=String.makemax_indent' 'in(* A pending line break (indent + blank?) and/or a pending flat separator,
deferred until the next text and coalesced — across group boundaries — by
max strength; a line break supersedes a flat separator. *)letpend_line=refNoneinletpend_flat=refNoneinletframes=ref[{findent=0;fmode=Brkm}]inletcur()=List.hd!framesin(* The pending-token FIFO: a growable ring buffer, so [scan] can index the
lookahead without allocating (a [Queue]+[Seq] scan allocated a node per
token scanned, on every re-scan). [qn] tokens live at [qhd .. qhd+qn) mod
capacity. *)(* Capacity is always a power of two, so index wrap-around is [land mask]
(cheaper than [mod]); [qmask] is [capacity - 1]. *)letqbuf=ref(Array.make32TEnd)inletqmask=ref31inletqhd=ref0inletqn=ref0inletqgeti=!qbuf.((!qhd+i)land!qmask)inletqpushx=if!qn=Array.length!qbufthen(letold=!qbufandomask=!qmaskandohd=!qhdinletncap=2*Array.lengtholdinletnb=Array.makencapTEndinfori=0to!qn-1donb.(i)<-old.((ohd+i)landomask)done;qbuf:=nb;qmask:=ncap-1;qhd:=0);!qbuf.((!qhd+!qn)land!qmask)<-x;incrqninletqpop()=letx=!qbuf.(!qhd)inqhd:=(!qhd+1)land!qmask;decrqn;xin(* >0 while dropping the content of an [if_broken] whose enclosing group is
not broken (the trailing comma of a flat list). *)letskip_depth=ref0in(* The suspended-scan state (see [scan_state]). *)letsc={active=false;ghv=false;base=0;str=Space;avail=0;sstack=[];fr=[];ib=0;i=0;}inletscan_at_end=reffalseinletflush()=(match!pend_linewith|Some(ind,blank)->(* Suppress a leading break before any output, like [if started]. *)if!emittedthen(add_char'\n';ifblankthenadd_char'\n';add_substringspaces0ind;col:=ind)|None->(match!pend_flatwith|Someswhenstrengths>=strengthSpace->add_char' ';incrcol|_->()));pend_line:=None;pend_flat:=Noneinletbreak_lineindblank=letind=minindmax_indentin(match!pend_linewith|Some(_,b0)->pend_line:=Some(ind,b0||blank)|None->pend_line:=Some(ind,blank));pend_flat:=Noneinletflat_seps=if!pend_line=Nonethenpend_flat:=Some(match!pend_flatwith|Somes0->ifstrengths>=strengths0thenselses0|None->s)inleteff_col()=match!pend_linewith|Some(ind,_)->ind|None->!col+if!pend_flat<>Nonethen1else0in(* Does the content fit in [avail] columns up to the next line-ending break?
Trailing context beyond the immediate group is included (matching the old
tree renderer's [fits], not a local "does this group alone fit" check).
[sstack] is the mode stack of groups entered during the scan (innermost
first), all [Flat]; beneath them [fr] is the enclosing frame stack, read
directly (no copy). The current mode is the head of [sstack], else the
innermost [fr]; a break there ends the line iff that mode is breaking
([Brkm]/[Fill]). [i] indexes the lookahead in the ring buffer. Returns
[Ok] once decided ([false] when [avail] is exhausted, [true] at the first
line-ending break); [Error] with the state to resume from when the buffer
runs out before deciding (unless [!scan_at_end]). *)letrecscan_goavailsstackfribi=ifavail<0thenNofitelseifi>=!qnthenif!scan_at_endthenFitselse((* out of buffered input: park the loop state for the next [feed] *)sc.avail<-avail;sc.sstack<-sstack;sc.fr<-fr;sc.ib<-ib;sc.i<-i;Susp)elselettok=qgetiinifib>0then(* dropping if_broken content: measure nothing *)letib=matchtokwith|TBegin_|TNest_|TIfBroken->ib+1|TEnd->ib-1|_->ibinscan_goavailsstackfrib(i+1)elsematchtokwith|TText(w,_)->scan_go(avail-w)sstackfrib(i+1)|TBegin_->scan_goavail(Flat::sstack)frib(i+1)|TNest_->(* the current mode (head of [sstack], else innermost [fr]) *)letm=matchsstackwith|m::_->m|[]->(matchfrwithf::_->f.fmode|[]->Brkm)inscan_goavail(m::sstack)frib(i+1)|TIfBroken->scan_goavailsstackfr1(i+1)|TEnd->(matchsstackwith|_::tl->scan_goavailtlfrib(i+1)|[]->(matchfrwith|_::(_::_asftl)->scan_goavail[]ftlib(i+1)|_->Fits(* popped past outermost frame = [] *)))|TBreakb->(letm=matchsstackwith|m::_->m|[]->(matchfrwithf::_->f.fmode|[]->Brkm)inmatchmwith|Brkm|Fill->Fits|Flat->(matchbwith|Newline|Blank_line->Nofit|Space->scan_go(avail-1)sstackfrib(i+1)|Cut->scan_goavailsstackfrib(i+1)))in(* Commit a resolved decision ([fit]: does it fit flat?) and pop its front
token. *)letresolve_decisionfit=sc.active<-false;ignore(qpop());ifsc.ghvthenframes:={findent=sc.base;fmode=(iffitthenFlatelseBrkm)}::!frameselseiffitthenflat_sepsc.strelsebreak_line(cur()).findentfalsein(* Lay out a token that needs no lookahead, updating the print state exactly
as the old tree renderer did for the corresponding [doc] node. *)letprocesstok=matchtokwith|TText(w,s)->flush();add_strings;emitted:=true;col:=!col+w|TNestn->letc=cur()inframes:={findent=c.findent+n;fmode=c.fmode}::!frames|TBegink->(* A box's break-indentation is measured from the column where the box
opens (Format semantics), not from the inherited nesting — they
differ when a box starts mid-line (e.g. a WAT s-expression after its
[(]). Rebase the group's indent to the open column. *)letbase=eff_col()inletm=matchkwith|GV->Brkm|GH->Flat|GBox|GHov->Fill|GHv->assertfalse(* resolved with lookahead in [advance] *)inframes:={findent=base;fmode=m}::!frames|TIfBroken->(* only reached when the enclosing group is broken; the flat case is
skipped in [advance] *)letc=cur()inframes:={findent=c.findent;fmode=c.fmode}::!frames|TEnd->(match!frameswith_::(_::_astl)->frames:=tl|_->())|TBreakstr->(letc=cur()inmatch(str,c.fmode)with|Newline,_->break_linec.findentfalse|Blank_line,_->break_linec.findenttrue|(Cut|Space),Flat->flat_sepstr|(Cut|Space),Brkm->break_linec.findentfalse|(Cut|Space),Fill->assertfalse(* resolved in [advance] *))in(* Drain the queue front while each front token is resolvable. A decision
([GHv] open, [Fill] break) scans the lookahead from index 1 (past the
front token); if it cannot yet decide it is left [pending] and resumed on
the next [feed]. *)letadvance~at_end()=scan_at_end:=at_end;letgo_on=reftrueinwhile!go_ondoif!skip_depth>0thenif!qn=0thengo_on:=falseelsematchqpop()with|TBegin_|TNest_|TIfBroken->incrskip_depth|TEnd->decrskip_depth|_->()elseif!qn=0thengo_on:=falseelseifsc.activethen(* resume the suspended front decision from its parked state *)matchscan_gosc.availsc.sstacksc.frsc.ibsc.iwith|Susp->go_on:=false|Fits->resolve_decisiontrue|Nofit->resolve_decisionfalseelsematchqget0with|TBeginGHv->(letbase=eff_col()in(* [sc.ghv]/[sc.base] are read by [resolve_decision] on resolve —
set them before the scan so the immediate case sees them too. *)sc.ghv<-true;sc.base<-base;matchscan_go(width-base)[Flat]!frames01with|Susp->sc.active<-true;go_on:=false|Fits->resolve_decisiontrue|Nofit->resolve_decisionfalse)|TBreak((Cut|Space)asstr)when(cur()).fmode=Fill->((* If kept flat this separator itself occupies a column, so what
follows starts one column further right; account for it. *)letsep=matchstrwithSpace->1|_->0in(* [sc.ghv]/[sc.str] are read by [resolve_decision] on resolve. *)sc.ghv<-false;sc.str<-str;matchscan_go(width-eff_col()-sep)[]!frames01with|Susp->sc.active<-true;go_on:=false|Fits->resolve_decisiontrue|Nofit->resolve_decisionfalse)|TIfBrokenwhen(cur()).fmode<>Brkm->ignore(qpop());skip_depth:=1|tok->ignore(qpop());processtokdoneinletfeedtok=qpushtok;advance~at_end:false()inletfinish_stream()=advance~at_end:true()in(feed,finish_stream)(* --- imperative builder (streams tokens into an engine) --- *)typestate={feed:token->unit;finish_stream:unit->unit;mutablepending_break:break_strengthoption;(* The most recent break not yet emitted, held so a following break can
coalesce with it (by max strength) and so [force_eol]/[skip_space] can
drop it — the streaming analogue of the old frame-head lookback. *)mutablehas_emitted:bool;(* content since last forced end-of-line *)mutablepending_eol:(unit->unit)option;mutableholding_eol:bool;}letcreate~feed~finish_stream={feed;finish_stream;pending_break=None;has_emitted=false;pending_eol=None;holding_eol=false;}(* Emit a non-break token, first flushing any pending break so a break always
precedes the following text/group and follows the preceding group's content
(the token order the old tree fold produced). *)letemitsttok=(matchst.pending_breakwith|Somes->st.pending_break<-None;st.feed(TBreaks)|None->());st.feedtok(* Record a break, coalescing with a pending one into the stronger of the two —
the analogue of the old [append]'s break-after-break merge and of the Format
engine's [register_break]. *)letpush_breaksts=st.pending_break<-Some(matchst.pending_breakwith|Somes0->ifstrengths>=strengths0thenselses0|None->s)(* Drop a pending break, so a deferred end-of-line comment hugs the preceding
token instead of being pushed past a break. *)letdrop_trailing_breakst=matchst.pending_breakwith|Somes->st.pending_break<-None;Somes|None->Noneletforce_eolst=matchst.pending_eolwith|None->()|Someemit_comment->st.pending_eol<-None;letdropped=drop_trailing_breakstinemit_comment();letnext_brk=matchdroppedwithSomeBlank_line->Blank_line|_->Newlineinpush_breakstnext_brk;st.has_emitted<-falseletdefer_eolstemit_comment=force_eolst;st.pending_eol<-Someemit_commentletwith_held_eolstf=letprev=st.holding_eolinst.holding_eol<-true;f();st.holding_eol<-prevlethas_pending_eolst=st.pending_eol<>Nonelettextstlens=ifnotst.holding_eolthenforce_eolst;st.has_emitted<-true;emitst(TText(len,s))letstringsts=textst(String.lengths)sletstring_asstlens=textstlensletspacest=ifst.has_emittedthenpush_breakstSpaceletcutst=push_breakstCutletnewlinest=push_breakstNewlineletblank_linest=push_breakstBlank_lineletindentstnf=emitst(TNestn);f();emitstTEndletif_brokenstf=emitstTIfBroken;f();emitstTEndletscopedstkind~skip_space~indentf=ifnotst.holding_eolthenforce_eolst;(ifskip_spacethenmatchst.pending_breakwith|Some(Cut|Space)->st.pending_break<-None|_->());emitst(TBeginkind);(* The group's own indent is a nest wrapping its whole body, so every break
in the body indents from [base + indent] (see the old [group_wrap]). *)ifindent<>0thenemitst(TNestindent);f();ifindent<>0thenemitstTEnd;emitstTEndletboxst~skip_space~indentf=scopedstGBox~skip_space~indentflethvboxst~skip_space~indentf=scopedstGHv~skip_space~indentflethovboxst~skip_space~indentf=scopedstGHov~skip_space~indentfletvboxst~skip_space~indentf=scopedstGV~skip_space~indentflethboxst~skip_spacef=scopedstGH~skip_space~indent:0f(* Flush a trailing deferred end-of-line comment, then drain the engine. A
trailing break is left pending and never fed, so it is dropped (no trailing
whitespace). *)letfinalizest=force_eolst;st.finish_stream()end(* ===================================================================== *)(* Public API *)(* ===================================================================== *)typet=Doc.stateletindent=Doc.indentletstring=Doc.stringletstring_as=Doc.string_asletspacet()=Doc.spacetletcutt()=Doc.cuttletnewlinet()=Doc.newlinetletblank_linet()=Doc.blank_linetletdefer_eol=Doc.defer_eolletwith_held_eol=Doc.with_held_eollethas_pending_eol=Doc.has_pending_eolletif_broken=Doc.if_brokenletboxt?(skip_space=false)?(indent=0)f=Doc.boxt~skip_space~indentflethvboxt?(skip_space=false)?(indent=0)f=Doc.hvboxt~skip_space~indentflethboxt?(skip_space=false)f=Doc.hboxt~skip_spaceflethovboxt?(skip_space=false)?(indent=0)f=Doc.hovboxt~skip_space~indentfletvboxt?(skip_space=false)?(indent=0)f=Doc.vboxt~skip_space~indentfletrun_channel?(width=78)ocf=(* Lay out straight into the channel — no intermediate string, no Format
buffering. The hot output path. *)letfeed,finish_stream=Doc.make_engine~width~add_string:(funs->output_stringocs)~add_char:(func->output_charocc)~add_substring:(funsposlen->output_substringocsposlen)inletc=Doc.create~feed~finish_streaminfc;Doc.finalizecletrun_string?(width=78)f=(* Lay out straight into a buffer and return its contents. *)letb=Buffer.create256inletfeed,finish_stream=Doc.make_engine~width~add_string:(funs->Buffer.add_stringbs)~add_char:(func->Buffer.add_charbc)~add_substring:(funsposlen->Buffer.add_substringbsposlen)inletc=Doc.create~feed~finish_streaminfc;Doc.finalizec;Buffer.contentsbletrun_err?(width=78)f=(* Lay out to stderr, then a trailing newline and a flush — the replacement
for the [Format.eprintf "%a@."] debug idiom, which got both for free. *)run_channel~widthstderrf;output_charstderr'\n';flushstderrletrun_discardf=(* A printer that produces no output: tokens are dropped, nothing is laid out.
For the dry pass that only needs the side effects of running the printer —
recording which source locations get looked up, via [Trivia]'s [collect] —
so it avoids building and laying out the whole document just to discard it. *)letc=Doc.create~feed:(fun_->())~finish_stream:(fun()->())infc;Doc.finalizec