123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250open!CoreopenAngstromopenAngstrom.Let_syntax(* NOTE: This was largely AI-generated, with oversight and review from Bryce. *)(* This parser implements a subset of ECMA-48 (also known as ANSI X3.64 or ISO/IEC 6429).
Key sections referenced:
- Section 5.4: Control sequences (CSI)
- Section 8.3.143: ST (String Terminator)
- Section 8.3.89: OSC (Operating System Command)
For OSC 8 hyperlinks specifically, see:
https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
Design note: Each parser returns a list of elements to handle malformed sequences
efficiently. When CSI or OSC parsing fails, we return [Escape (Some '[' or ']')]
without consuming anything beyond that byte, allowing the rest to be parsed normally.
This avoids backtracking and expensive peeking of the entire input. *)letesc_char='\027'letbel_char='\007'(* === Character predicates for CSI sequences (ECMA-48 section 5.4) === *)(* Parameter bytes: 0x30-0x3F (ECMA-48 section 5.4.2a). Includes digits and semicolons
used by SGR and recognized by [Ansi_text], plus a few other characters we ignore. *)letis_csi_param_bytec=letcode=Char.to_intcin0x30<=code&&code<=0x3F;;(* Intermediate bytes: 0x20-0x2F (ECMA-48 section 5.4.2b). Rarely used in practice, and
ignored by [Ansi_text]. *)letis_csi_intermediate_bytec=letcode=Char.to_intcin0x20<=code&&code<=0x2F;;(* Final byte: 0x40-0x7E per ECMA-48 section 5.4.2c *)letis_csi_final_bytec=letcode=Char.to_intcin0x40<=code&&code<=0x7E;;letesc=charesc_charlettext_of_trailingstr=ifString.is_emptystrthen[]else[`Text(Text.of_stringstr)];;(* ECMA-48 section 5.4: CSI is [{ ESC [ }] (0x1B 0x5B)
Format: CSI P...P I...I F
where P = parameter bytes, I = intermediate bytes, F = final byte
Returns a list to handle malformed sequences: valid CSI returns [element],
malformed returns [Escape (Some '[')] without consuming params/intermediate. *)letcsi=let%bind()=char'['*>return()inlet%bindprivate_prefix=(* DEC private mode sequences use '?' as a prefix to the parameters (e.g., ?25h). *)match%bindpeek_charwith|Some'?'->advance1>>|fun()->Some'?'|_->returnNoneinlet%bindparams=take_whileis_csi_param_byteandintermediate=take_whileis_csi_intermediate_byteinmatch%bindpeek_charwith|Somecwhenis_csi_final_bytec->(* Valid CSI: consume final byte and parse *)any_char>>|funterminal->[(Ansi.of_csi?private_prefixparams~terminal:>Text_with_ansi.element)]|_->(* Malformed CSI: no valid final byte. Return Fe '[' followed by
prefix, params, and intermediate as text, so nothing is lost. *)letparams=matchprivate_prefixwith|Somec->sprintf"%c%s"cparams|None->paramsinreturn(`Unknown(Unknown_esc.Fe'[')::text_of_trailing(params^intermediate));;(*= OSC 8 is the hyperlink extension (not part of ECMA-48): Format:
[{ OSC 8 ; params ; uri ST}]
See: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
Returns a list to handle malformed sequences. *)letosc_hyperlink_of_payloadpayload=matchString.chop_prefixpayload~prefix:"8;"with|None->None|Somerest->(* Find the next semicolon to separate params from URI *)(matchString.lsplit2rest~on:';'with|None->None|Some(_params,uri)->(* Empty URI means end of hyperlink *)ifString.is_emptyurithenSomeHyperlink.EndelseSome(Hyperlink.Starturi));;(* ECMA-48 section 8.3.89: OSC is [{ ESC ] }] (0x1B 0x5D)
Format: OSC <payload> ST
where ST (String Terminator) is [{ ESC \ }] (0x1B 0x5C) or BEL (0x07)
Note: While ECMA-48 only defines ESC \ as the String Terminator, BEL is widely
accepted as an alternative terminator for OSC sequences in real-world terminals
(xterm, iTerm2, etc.). This is for backwards compatibility with older terminals. *)letis_osc_terminatorc=Char.equalcesc_char||Char.equalcbel_charletosc=let%bindpayload=char']'*>take_tillis_osc_terminatorinmatch%bindpeek_charwith|SomecwhenChar.equalcbel_char->(* BEL terminator: consume it and interpret the OSC *)let%map()=advance1in(matchosc_hyperlink_of_payloadpayloadwith|Somehyperlink->[`Hyperlinkhyperlink]|None->[(Ansi.of_osc_payloadpayload:>Text_with_ansi.element)])|Some_->(* Must be ESC - check if it's ESC \ *)let%bindavailableinifavailable>=2then(match%bindpeek_string2with|"\027\\"->(* Valid ST: consume both characters and interpret the OSC *)let%map()=advance2in(matchosc_hyperlink_of_payloadpayloadwith|Somehyperlink->[`Hyperlinkhyperlink]|None->[(Ansi.of_osc_payloadpayload:>Text_with_ansi.element)])|_->(* ESC followed by something other than backslash - unterminated OSC. Return
Fe ']' followed by payload as text. Don't consume the ESC. *)return(`Unknown(Unknown_esc.Fe']')::text_of_trailingpayload))else(* Not enough input for ST - unterminated OSC at end of input *)return(`Unknown(Unknown_esc.Fe']')::text_of_trailingpayload)|None->(* End of input - unterminated OSC *)return(`Unknown(Unknown_esc.Fe']')::text_of_trailingpayload);;(* Fe escape sequences: ESC + byte in 0x40-0x5F per ECMA-48 section 5.3. Note:
'[' (0x5B) and ']' (0x5D) are in this range but CSI and OSC are handled separately. *)letis_fe_bytec=letcode=Char.to_intcin0x40<=code&&code<=0x5F;;(* Fp escape sequences: ESC + byte in 0x60-0x7E per ECMA-48 section 5.3. *)letis_fp_bytec=letcode=Char.to_intcin0x60<=code&&code<=0x7E;;(* Intermediate bytes for nF escape sequences: 0x20-0x2F per ECMA-48 section 5.4.2b *)letis_nf_intermediate_bytec=letcode=Char.to_intcin0x20<=code&&code<=0x2F;;(* Final byte for nF escape sequences: 0x30-0x7E per ECMA-48 section 5.4.2c *)letis_nf_final_bytec=letcode=Char.to_intcin0x30<=code&&code<=0x7E;;(* Handles ESC followed by any byte that isn't '[' (CSI) or ']' (OSC).
- Fe sequences (0x40-0x5F, excluding '[' and ']')
- Fp sequences (0x60-0x7E): private use, includes common sequences like ESC c (reset)
- nF sequences (0x20-0x2F intermediate bytes + 0x30-0x7E final byte)
- Incomplete: ESC at end of input, ESC+ESC, or ESC + invalid byte *)letother_escape=match%bindpeek_charwith|Some'\027'|None->return[`UnknownUnknown_esc.Incomplete]|Someescaped->ifis_fe_byteescapedthen((* '[' and ']' already handled by caller *)let%map()=advance1in[`Unknown(Unknown_esc.Feescaped)])elseifis_fp_byteescapedthen(let%map()=advance1in[`Unknown(Unknown_esc.Fpescaped)])elseifis_nf_intermediate_byteescapedthen((* consume intermediate bytes + final byte *)let%bindintermediates=take_while1is_nf_intermediate_byteinmatch%bindpeek_charwith|Somefinalwhenis_nf_final_bytefinal->let%map()=advance1in[`Unknown(Unknown_esc.Nf(intermediates^String.make1final))]|_->(* Truncated nF: emit Incomplete, leave intermediates as text *)return(`UnknownUnknown_esc.Incomplete::text_of_trailingintermediates))else(* Invalid byte - emit Incomplete, leave byte for text parsing *)return[`UnknownUnknown_esc.Incomplete];;(*= Main escape sequence dispatcher. After ESC, the next byte determines sequence type:
- '[' (0x5B) -> CSI (Control Sequence Introducer)
- ']' (0x5D) -> OSC (Operating System Command)
- other -> Other escape sequence *)letescape_sequence=match%bindesc*>peek_charwith|Some'['->csi(* CSI: ESC [ *)|Some']'->osc(* OSC: ESC ] *)|Some_->other_escape|None->(* ESC at end of input *)return[`UnknownUnknown_esc.Incomplete];;letplain_text=let%maps=take_while1(func->not(Char.equalcesc_char))in[`Text(Text.of_strings)];;let(=>)peek_forcase=match%bindpeek_string(String.lengthpeek_for)with|peekedwhenString.equalpeekedpeek_for->returncase|_->Angstrom.fail"This message should not reach the top-level. Seeing it is a bug!";;letparser=let%mapout=fix(funparser->match%bindpeek_charwith|None->returnAppendable_list.empty|Some_->let%mapcurr=match%bindchoice["\027"=>`Escape;return`Plain_text]with|`Escape->escape_sequence|`Plain_text->plain_textandrest=parserinAppendable_list.append(Appendable_list.of_listcurr)rest)inAppendable_list.to_listout;;letparsestr=matchparse_string~consume:Allparserstrwith|Okresult->result|Error_->(* Should be unreachable, but fallback just in case *)[`Text(Text.of_stringstr)];;