ANSI escape codes in OCaml — \027, ANSITerminal, Fmt + Logs
OCaml string literals support `"\027"` (decimal — the canonical OCaml ESC form, works on every OCaml back to 1.0), `"\x1b"` (hex, OCaml 4.06+), and `"\o033"` (octal, OCaml 4.06+). **The OCaml twist**: `"\NNN"` is **decimal**, not octal like C — same trap Nim users hit. `"\033"` in C is byte 27 (octal); `"\033"` in OCaml is byte 33 (decimal — the `!` character). When porting from C the canonical fix is `"\027"` (decimal) or — on OCaml 4.06+ — `"\x1b"` (hex) or `"\o033"` (octal, when you want to keep the literal-looking-the-same). There is no `\e` named escape. `print_string "\027[1;31merror:\027[0m permission denied\n"` writes raw bytes through the runtime's `stdout` channel, parsed natively on macOS, Linux, BSDs, and Windows 10+ Conhost 1709+. Reach for **`ANSITerminal`** (opam package, the canonical OCaml ANSI library) — `ANSITerminal.print_string [red; Bold] "error: "` reads as a list of styles followed by the text, auto-emits the reset, and handles capability detection on Windows via native console-mode toggling. For **structured / formatted output** reach for **`fmt`** (the Dune-default formatter) — `Fmt.pr "@{<red>error@}: %s@." msg` parses semantic markup tags into SGR sequences and respects `Fmt.set_style_renderer Format.std_formatter `Ansi_tty`. Pair `fmt` with **`logs`** for structured logging with per-source-level coloured prefixes (`Logs.info (fun m -> m "started on %d" port)` produces a coloured `[INFO]` prefix when stdout is a TTY). For a full TUI runtime reach for **`notty`** — pqwy's high-quality declarative TUI library with image composition (`I.string A.(fg red) "error"` returns an `Notty.image` you compose with `<|>` / `<->` and render via `Notty_unix.Term`). Capability gating: `Unix.isatty Unix.stdout` (the `unix` library, ships with most OCaml installs) returns `false` when stdout is redirected. Pair with `Sys.getenv_opt "NO_COLOR"` for the universal opt-out. **utop REPL caveat** (page's primary SERP differentiator): `print_string "\027[31mfoo\027[0m"` inside utop DOES render red (utop pipes stdout to its underlying lambdaterm which parses ANSI). However, when the REPL displays the **value** of a top-level binding (`let s = "\027[31mfoo\027[0m";;`), utop renders the OCaml string literal back to you — escape bytes appear verbatim as `\027[...]`, not as a colour. The fix: print the string explicitly (`let () = print_string s`) rather than letting utop echo the value, or install `utop-full` and use `UTop.set_phrase_terminator` / `UTop.set_show_box` to customise REPL rendering.
Recommended libraries
- ANSITerminal
Canonical OCaml ANSI library — `opam install ansiterminal`. `ANSITerminal.print_string [red; Bold] "error: "` reads as a list of styles + text, auto-emits the reset, supports `[Foreground Red; Background Yellow; Bold; Underlined; Inverse]` plus 24-bit `[Foreground (RGB (255, 128, 0))]`. Module-level `ANSITerminal.set_autoreset true` ensures every print resets. On Windows: native console-mode toggling — no manual VT-enable needed. The one library nearly every OCaml CLI tool eventually pulls in.
- fmt
Dune-default formatter — what `Fmt.pr` / `Fmt.epr` are in modern OCaml, replacing direct `Format.printf` for human-facing output. Semantic markup tags (`@{<red>error@}`, `@{<bold>fatal@}`) parse into SGR sequences when `Fmt.set_style_renderer Format.std_formatter `Ansi_tty`. Switch to `\`None` for plain output (CI / piped). The right call when you want format-string-driven coloured output rather than imperative style-list calls.
- logs
Structured logging — pairs with `fmt` for coloured per-source-level prefixes. `Logs.info (fun m -> m "started on %d" port)` produces `[INFO] started on 8080` with `[INFO]` rendered in green when stdout is a TTY. Sources (`Logs.Src.create "db" ~doc:"database"`) let you filter per-module via `LOGS_REPORTER` / `LOGS_LEVEL` env vars. The canonical OCaml structured-logging choice; used throughout the modern OCaml ecosystem (cohttp, dune, mirage).
- notty
pqwy's high-quality declarative TUI library — the OCaml answer to vty / blessed / brick. `I.string A.(fg red) "error"` returns a `Notty.image` value that composes with `<|>` (horizontal) and `<->` (vertical). Render via `Notty_unix.Term.create ()` for a unix-style stdout-driven loop, or `Notty_lwt.Term.create ()` for an async-friendly Lwt-integrated event loop. Reach for it when you need a redrawing layout, not just a coloured log line.
Idiomatic patterns
(* OCaml accepts three ESC forms in "..." literals.
All compile to the same single byte (0x1b = 27).
"\027" — \NNN is DECIMAL (OCaml 1.0+, canonical)
"\x1b" — hex (OCaml 4.06+)
"\o033" — octal (OCaml 4.06+)
THE OCAML TWIST: \NNN is DECIMAL — not octal like C.
In C, "\033" is byte 27 (octal 33 = decimal 27 = ESC).
In OCaml, "\033" is byte 33 (decimal — the '!' character).
Porting from C? "\033" silently mis-compiles. The fix is
"\027" (decimal) or — on OCaml 4.06+ — "\x1b" (hex) or
"\o033" (octal, when you want the literal looking the same).
There is no \e named escape.
On OCaml < 4.06 you have ONLY \NNN decimal — must use "\027". *)
let () =
print_string "\027[1;31merror:\027[0m permission denied\n";
print_string "\027[33mwarn:\027[0m deprecated flag\n";
print_string "\x1b[32mok:\x1b[0m 142 tests passed\n"; (* 4.06+ *)
print_string "\o033[36minfo:\o033[0m skipping cache\n"; (* 4.06+ *)
(* Truecolor — 38;2;R;G;B *)
print_string "\027[38;2;255;128;0morange truecolor\027[0m\n";
(* 256-palette — 38;5;n *)
print_string "\027[38;5;196mbright red\027[0m\n"
(* Reusable helper — no opam dependency: *)
let esc sgr text = "\027[" ^ sgr ^ "m" ^ text ^ "\027[0m"
let () =
print_endline (esc "1;31" "FATAL" ^ " server crashed");
print_endline (esc "32" "ok:" ^ " 142 tests passed");
print_endline (esc "38;2;255;128;0" "truecolor orange")(* opam install ansiterminal
dune file:
(executable (name main) (libraries ansiterminal)) *)
open ANSITerminal
let () =
set_autoreset true; (* every print auto-resets — recommended *)
(* Style list + text — the canonical ANSITerminal call: *)
print_string [red; Bold] "error: ";
print_endline "permission denied";
print_string [green] "ok: ";
print_endline "142 tests passed";
print_string [Background Yellow; black; Bold] " CAUTION ";
print_endline " brakes wet";
(* 24-bit truecolor — Foreground (RGB (r, g, b)): *)
print_string [Foreground (RGB (255, 128, 0))] "hot orange";
print_endline "";
print_string
[ Foreground (RGB (0, 110, 130))
; Background (RGB (240, 240, 240))
; Bold ]
" deep teal on light grey ";
print_endline "";
(* Cursor + screen helpers — same module: *)
erase Eol; (* erase to end of line *)
set_cursor 1 1; (* move to (col, row) *)
save_cursor ();
restore_cursor ();
(* Capability check — ANSITerminal handles Windows via
native console mode toggling, but you can also check
manually: *)
if Unix.isatty Unix.stdout
then prerr_endline "stdout is a TTY"
else prerr_endline "stdout redirected — no colour"(* opam install fmt logs
dune file:
(executable
(name main)
(libraries fmt fmt.tty logs logs.fmt logs.cli)) *)
(* ─── Fmt — semantic markup tags compile to SGR ─── *)
let () =
(* Enable ANSI rendering on stdout / stderr formatters.
Switch to `None for CI / piped output. *)
Fmt.set_style_renderer Fmt.stdout `Ansi_tty;
Fmt.set_style_renderer Fmt.stderr `Ansi_tty;
Fmt.pr "@{<red>error:@} %s@." "permission denied";
Fmt.pr "@{<yellow>warn:@} %s@." "deprecated flag";
Fmt.pr "@{<green>ok:@} 142 tests passed@.";
Fmt.pr "@{<bold>@{<magenta>FATAL@}@}: server crashed@.";
(* Compose markup with format directives: *)
Fmt.pr "@{<cyan>request@} %s %s in @{<bold>%dms@}@."
"GET" "/users/42" 142;
(* Custom styled printer — reusable: *)
let pp_status ppf = function
| `Ok -> Fmt.pf ppf "@{<green>OK@}"
| `Fail -> Fmt.pf ppf "@{<red>FAIL@}"
| `Skip -> Fmt.pf ppf "@{<yellow>SKIP@}"
in
Fmt.pr "[%a] migration applied@." pp_status `Ok
(* ─── Logs — structured logging with coloured levels ─── *)
let () =
Logs.set_reporter (Logs_fmt.reporter ());
Logs.set_level (Some Logs.Info);
Logs.info (fun m -> m "started on port %d" 8080);
Logs.warn (fun m -> m "slow query: %dms" 1240);
Logs.err (fun m -> m "auth failed for user %d" 42);
(* Per-module log source — filter via LOGS_LEVEL=db:debug *)
let src = Logs.Src.create "db" ~doc:"database layer" in
let module Db = (val Logs.src_log src : Logs.LOG) in
Db.info (fun m -> m "connected to %s" "postgres://...")(* Capability gating in OCaml:
Unix.isatty Unix.stdout — stdlib (via unix lib)
Sys.getenv_opt "NO_COLOR" — universal opt-out
Sys.getenv_opt "FORCE_COLOR" — universal opt-in
utop REPL CAVEAT (page's primary SERP differentiator):
print_string "\027[31mfoo\027[0m" → renders RED in utop
(utop pipes stdout to lambdaterm which parses ANSI)
let s = "\027[31mfoo\027[0m";; → utop echoes the
OCaml string LITERAL: escape bytes show as \027[...] —
NOT as a colour. utop renders the VALUE, not the meaning.
Fix: print explicitly, don't let utop echo the value:
let () = print_string s
Or use UTop.set_phrase_terminator / UTop.set_show_box to
customise REPL rendering (utop-full required). *)
let ansi_capable () =
match Sys.getenv_opt "NO_COLOR", Sys.getenv_opt "FORCE_COLOR" with
| Some _, _ -> false
| _, Some _ -> true
| _ -> Unix.isatty Unix.stdout
let styled text sgr =
if ansi_capable ()
then Printf.sprintf "\027[%sm%s\027[0m" sgr text
else text
let () =
print_endline (styled "OK" "32");
print_endline (styled "FAIL" "1;31")
(* ─── Notty — declarative TUI image composition ─── *)
(* opam install notty
dune: (executable (name tui) (libraries notty notty.unix)) *)
let tui_demo () =
let open Notty in
let open Notty.Infix in
let title = I.string A.(fg yellow ++ st bold) "notty demo" in
let ok = I.string A.(fg green) "✓ ok " <|>
I.string A.empty "142 tests passed" in
let fail = I.string A.(fg red) "✗ FAIL" <|>
I.string A.empty " 3 tests failed" in
let info = I.string A.(fg cyan) "ℹ info" <|>
I.string A.empty " skipping cache" in
let body = ok <-> fail <-> info in
let img = title <-> I.void 1 1 <-> body in
let term = Notty_unix.Term.create () in
Notty_unix.Term.image term img;
ignore (Notty_unix.Term.event term);
Notty_unix.Term.release term
let () = tui_demo ()