Skip to main content
ansicode
Lua

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

Direct io.write — \27 (decimal, all Lua) or \x1b (5.2+)
-- 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")
Pattern-based colours with ansicolors.lua
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 — LuaPosix isatty + NO_COLOR + FORCE_COLOR
-- 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')
In-place progress with CR + EL + cursor hide
-- 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")

Related sequences

Other languages