在 OCaml 中使用 ANSI 转义码 —— \027、ANSITerminal、Fmt + Logs
OCaml 字符串字面量支持 `"\027"`(十进制 —— 规范的 OCaml ESC 形式,在 OCaml 1.0 起的所有版本中可用)、`"\x1b"`(十六进制,OCaml 4.06+)以及 `"\o033"`(八进制,OCaml 4.06+)。**OCaml 的妙处**:`"\NNN"` 是**十进制**,不像 C 那样是八进制 —— Nim 用户踩到的同一个陷阱。C 中 `"\033"` 是字节 27(八进制);OCaml 中 `"\033"` 是字节 33(十进制 —— 字符 `!`)。从 C 移植时规范修复是 `"\027"`(十进制)或 —— 在 OCaml 4.06+ —— `"\x1b"`(十六进制)或 `"\o033"`(八进制,当你想保留字面看起来一样时)。没有 `\e` 命名转义。`print_string "\027[1;31merror:\027[0m permission denied\n"` 通过运行时的 `stdout` 通道写原始字节,在 macOS、Linux、BSD、Windows 10+ Conhost 1709+ 上原生解析。 选 **`ANSITerminal`**(opam 包,OCaml 规范 ANSI 库)—— `ANSITerminal.print_string [red; Bold] "error: "` 读作一个样式列表后跟文本,自动发出 reset,在 Windows 上通过原生 console-mode 切换处理能力检测。**结构化 / 格式化输出**选 **`fmt`**(Dune 默认格式化器)—— `Fmt.pr "@{<red>error@}: %s@." msg` 把语义标记标签解析为 SGR 序列并尊重 `Fmt.set_style_renderer Format.std_formatter `Ansi_tty`。将 `fmt` 与 **`logs`** 配对做结构化日志,每个来源 / 级别带彩色前缀(`Logs.info (fun m -> m "started on %d" port)` 在 stdout 是 TTY 时产生彩色 `[INFO]` 前缀)。完整 TUI 运行时选 **`notty`** —— pqwy 的高质量声明式 TUI 库,带图像组合(`I.string A.(fg red) "error"` 返回 `Notty.image`,用 `<|>` / `<->` 组合,经 `Notty_unix.Term` 渲染)。 能力门控:`Unix.isatty Unix.stdout`(`unix` 库,随多数 OCaml 安装一起)在 stdout 重定向时返回 `false`。搭配 `Sys.getenv_opt "NO_COLOR"` 处理通用退出。**utop REPL 注意**(本页主要 SERP 差异化):utop 内的 `print_string "\027[31mfoo\027[0m"` **确实**渲染红色(utop 将 stdout 管道到底层 lambdaterm 解析 ANSI)。然而当 REPL 显示顶层绑定的**值**(`let s = "\027[31mfoo\027[0m";;`),utop 把 OCaml 字符串字面量原样回显给你 —— 转义字节作为 `\027[...]` 字面显示,而非颜色。修复方法:显式打印字符串(`let () = print_string s`)而非让 utop 回显值,或安装 `utop-full` 并使用 `UTop.set_phrase_terminator` / `UTop.set_show_box` 定制 REPL 渲染。
推荐库
- ANSITerminal
规范的 OCaml ANSI 库 —— `opam install ansiterminal`。`ANSITerminal.print_string [red; Bold] "error: "` 读作样式列表 + 文本,自动发出 reset,支持 `[Foreground Red; Background Yellow; Bold; Underlined; Inverse]` 加上 24 位 `[Foreground (RGB (255, 128, 0))]`。模块级 `ANSITerminal.set_autoreset true` 确保每次 print 都 reset。在 Windows 上:原生 console-mode 切换 —— 不需要手动 VT 启用。几乎每个 OCaml CLI 工具最终都会引入的一个库。
- fmt
Dune 默认格式化器 —— 现代 OCaml 中 `Fmt.pr` / `Fmt.epr` 替代直接 `Format.printf` 用于面向人的输出。语义标记标签(`@{<red>error@}`、`@{<bold>fatal@}`)在 `Fmt.set_style_renderer Format.std_formatter `Ansi_tty` 时解析为 SGR 序列。切到 `\`None` 得到纯文本输出(CI / 管道)。需要格式化字符串驱动的彩色输出而非命令式样式列表调用时选它。
- logs
结构化日志 —— 与 `fmt` 配对实现按源 / 级别的彩色前缀。`Logs.info (fun m -> m "started on %d" port)` 产生 `[INFO] started on 8080`,stdout 是 TTY 时 `[INFO]` 渲染为绿色。源(`Logs.Src.create "db" ~doc:"database"`)让你通过 `LOGS_REPORTER` / `LOGS_LEVEL` env 按模块过滤。规范的 OCaml 结构化日志选择;在整个现代 OCaml 生态(cohttp、dune、mirage)中使用。
- notty
pqwy 的高质量声明式 TUI 库 —— OCaml 对 vty / blessed / brick 的回答。`I.string A.(fg red) "error"` 返回 `Notty.image` 值,用 `<|>`(水平)和 `<->`(垂直)组合。经 `Notty_unix.Term.create ()` 渲染做 unix 风格 stdout 驱动循环,或经 `Notty_lwt.Term.create ()` 做 Lwt 集成的异步友好事件循环。需要重绘布局而非仅一行彩色日志时选它。
常用写法
(* 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 ()