ANSI escape codes in Lua
Lua has no `\e` escape literal, but `\27` (decimal) works in every interpreter back to Lua 5.1, and `\x1b` (hex) works since Lua 5.2. `io.write` and `print` are byte-clean on PUC-Rio Lua (5.1 / 5.2 / 5.3 / 5.4), LuaJIT, and the embedded interpreters that ship with Neovim, OpenResty, Redis, and Wireshark. **Beware**: long-bracket strings `[[...]]` do **not** expand escape sequences — only regular `"..."` strings do, so always use double-quoted string literals when emitting ANSI bytes. For ergonomic styling: **ansicolors.lua** (kikito's classic) parses pattern strings like `colors('%{red bold}error:%{reset} permission denied')` into SGR sequences — a single 1-file dependency, installable via LuaRocks or vendor-copy. **term.lua** (hoelzro's port of kikito's terminal-control helpers) covers the non-SGR side: `term.clear()`, `term.cleareol()`, `term.cursor.goto(x, y)`, `term.cursor.hide()`, alt-screen + cursor save/restore. For full-screen TUIs link **lcurses** (Lua binding for ncurses). For capability gating — `isatty()`, `TIOCGWINSZ`, raw-mode termios — use **LuaPosix**.
Recommended libraries
- ansicolors.lua
Pattern-based colour helper — `colors('%{red bold}error:%{reset} permission denied')`. Pattern tokens `%{<style> <color> on_<bgcolor>}` expand to SGR escape sequences. Single 1-file dependency, install via LuaRocks (`luarocks install ansicolors`) or vendor the file directly. Used by `busted`, `inspect`, and many LÖVE games for in-engine console output.
- term.lua (hoelzro)
Terminal-control helpers — `term.clear()`, `term.cleareol()`, `term.cursor.goto(x, y)`, `term.cursor.hide()` / `.show()`, `.save()` / `.restore()`, alt-screen helpers. Pairs with ansicolors.lua: this handles cursor + screen, ansicolors handles SGR. ~150 LOC, no native dependencies.
- lcurses
Lua binding for ncurses — windows, panels, menus, forms, colour pairs, mouse, raw input. Use for full-screen TUIs (file managers, dashboards, text-mode games). Installs via LuaRocks (`luarocks install lcurses`); needs ncurses headers at build time.
- LuaPosix
POSIX bindings — `posix.unistd.isatty(1)` for stdout capability detection, `posix.termio.tcgetattr` / `tcsetattr` for raw-mode switching, `posix.sys.ioctl` with `TIOCGWINSZ` for terminal-size queries. Required when you want capability-gated ANSI in scripts that may run piped, in CI, or on dumb terminals.
Idiomatic patterns
-- Lua has no \e literal. Use \27 (decimal, works in every Lua) or
-- \x1b (hex, requires Lua 5.2+). Note: long-bracket strings [[...]] do
-- NOT expand escapes — always use regular "..." strings for ANSI.
io.write("\27[1;31merror:\27[0m permission denied\n")
-- Lua 5.2+:
io.write("\x1b[1;32mok:\x1b[0m all 142 tests passed\n")local colors = require 'ansicolors'
print(colors('%{red bold}error:%{reset} permission denied'))
print(colors('%{green}ok:%{reset} all 142 tests passed'))
print(colors('%{white on_blue bold} highlighted %{reset} back to normal'))
print(colors('%{yellow}deprecated:%{reset} use the new API'))-- Capability gate: NO_COLOR > FORCE_COLOR > !isatty(stdout). Wrap the
-- LuaPosix require in pcall so the script still runs on hosts without it
-- (Windows without WSL, locked-down embedded interpreters).
local ok, unistd = pcall(require, 'posix.unistd')
local is_tty = ok and unistd.isatty(1) == 1 -- 1 = STDOUT_FILENO
local function should_color()
if os.getenv('NO_COLOR') ~= nil then return false end
local force = os.getenv('FORCE_COLOR')
if force and force ~= '0' then return true end
return is_tty
end
local USE_COLOR = should_color()
local function paint(sgr, text)
if USE_COLOR then
return ('\27[%sm%s\27[0m'):format(sgr, text)
end
return text
end
print(paint('1;31', 'error:'), 'permission denied')
print(paint('1;32', 'ok:'), 'all 142 tests passed')-- Hide cursor for the duration of the progress loop; restore it on
-- normal exit AND on Ctrl-C. Lua's poor man's defer is an __gc finalizer
-- on a sentinel table — runs when the script exits.
io.write("\27[?25l") -- hide cursor
local _restore = setmetatable({}, {__gc = function()
io.write("\27[?25h") -- restore cursor
end})
for i = 1, 100 do
-- \r returns to col 1; \27[K erases to end of line
io.write(("\r\27[K%d%% complete"):format(i))
io.flush()
os.execute('sleep 0.02') -- portable; LuaSocket has socket.sleep()
end
io.write("\n")