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