jeopardy/fmt.lua
2026-05-06 19:39:08 -07:00

356 lines
12 KiB
Lua

--[[
fmt.lua - Go's fmt.Printf() style formatting for Lua
Supported verbs:
%v - default format (tostring)
%T - type of the value
%t - boolean (true/false)
%d - integer (decimal)
%b - integer (binary)
%o - integer (octal)
%x - integer (hex, lowercase)
%X - integer (hex, uppercase)
%e - float (scientific, lowercase)
%E - float (scientific, uppercase)
%f - float (decimal point)
%g - float (%e for large, %f otherwise)
%G - float (%E for large, %f otherwise)
%s - string
%q - quoted string (Lua-escaped)
%c - character (from integer codepoint)
%% - literal percent sign
Width & precision:
%8d - right-aligned, width 8
%-8d - left-aligned, width 8
%08d - zero-padded, width 8
%8.2f - width 8, 2 decimal places
%.5s - truncate string to 5 chars
%*d - width from next argument
%.*f - precision from next argument
--]]
local fmt = {}
-- ── helpers ──────────────────────────────────────────────────────────────────
local function to_int(v)
local n = math.tointeger and math.tointeger(v) or (type(v) == "number" and math.floor(v) or nil)
if n == nil then
error(string.format("fmt: expected integer, got %s (%s)", type(v), tostring(v)), 3)
end
return n
end
local function to_num(v)
if type(v) ~= "number" then
error(string.format("fmt: expected number, got %s (%s)", type(v), tostring(v)), 3)
end
return v
end
-- Convert integer to binary string
local function to_binary(n)
n = to_int(n)
if n == 0 then return "0" end
local neg = n < 0
if neg then n = -n end
local bits = {}
while n > 0 do
table.insert(bits, 1, n % 2)
n = math.floor(n / 2)
end
local s = table.concat(bits)
return neg and "-" .. s or s
end
-- Apply width/alignment padding
local function apply_width(s, width, left_align, zero_pad, is_numeric)
if not width or width == 0 or #s >= width then return s end
local pad_char = (zero_pad and is_numeric) and "0" or " "
local pad = string.rep(pad_char, width - #s)
if zero_pad and is_numeric then
-- keep sign before zeros: "-007"
if s:sub(1,1) == "-" then
return "-" .. pad .. s:sub(2)
end
return pad .. s
end
return left_align and (s .. pad) or (pad .. s)
end
-- Apply precision to a string (truncation)
local function apply_str_precision(s, prec)
if prec and #s > prec then
return s:sub(1, prec)
end
return s
end
-- Quoted / escaped string (like Go %q)
local function quote_string(s)
s = s:gsub('\\', '\\\\')
s = s:gsub('"', '\\"')
s = s:gsub('\n', '\\n')
s = s:gsub('\r', '\\r')
s = s:gsub('\t', '\\t')
s = s:gsub('%z', '\\0')
return '"' .. s .. '"'
end
-- ── core formatter ────────────────────────────────────────────────────────────
--[[
fmt.Sprintf(format, ...) → string
Returns the formatted string without printing it.
--]]
function fmt.Sprintf(format, ...)
local args = { ... }
local ai = 1 -- argument index
local out = {} -- output buffer
local i = 1
local len = #format
while i <= len do
local c = format:sub(i, i)
if c ~= "%" then
out[#out+1] = c
i = i + 1
else
i = i + 1
if i > len then
error("fmt: trailing '%' in format string", 2)
end
-- %% literal
if format:sub(i, i) == "%" then
out[#out+1] = "%"
i = i + 1
else
-- ── parse flags ───────────────────────────────────────────
local left_align = false
local zero_pad = false
local plus_sign = false
local space_sign = false
local hash_flag = false
while i <= len do
local f = format:sub(i, i)
if f == "-" then left_align = true
elseif f == "0" then zero_pad = true
elseif f == "+" then plus_sign = true
elseif f == " " then space_sign = true
elseif f == "#" then hash_flag = true
else break
end
i = i + 1
end
-- ── parse width ───────────────────────────────────────────
local width = nil
if i <= len and format:sub(i, i) == "*" then
width = to_int(args[ai]); ai = ai + 1
i = i + 1
else
local w = format:match("^%d+", i)
if w then width = tonumber(w); i = i + #w end
end
-- ── parse precision ───────────────────────────────────────
local prec = nil
if i <= len and format:sub(i, i) == "." then
i = i + 1
if i <= len and format:sub(i, i) == "*" then
prec = to_int(args[ai]); ai = ai + 1
i = i + 1
else
local p = format:match("^%d*", i)
prec = tonumber(p) or 0
i = i + #p
end
end
-- ── parse verb ────────────────────────────────────────────
if i > len then
error("fmt: missing verb in format string", 2)
end
local verb = format:sub(i, i)
i = i + 1
local arg = args[ai]; ai = ai + 1
local s = ""
local is_numeric = false
-- %v default
if verb == "v" then
s = tostring(arg)
-- %T type
elseif verb == "T" then
s = type(arg)
-- %t boolean
elseif verb == "t" then
s = arg and "true" or "false"
-- %d decimal integer
elseif verb == "d" then
is_numeric = true
local n = to_int(arg)
s = tostring(math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if n < 0 then s = "-" .. s
elseif plus_sign then s = "+" .. s
elseif space_sign then s = " " .. s
end
-- %b binary
elseif verb == "b" then
is_numeric = true
s = to_binary(arg)
if hash_flag then s = "0b" .. s end
-- %o octal
elseif verb == "o" then
is_numeric = true
local n = to_int(arg)
local neg = n < 0
s = string.format("%o", math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if hash_flag and s:sub(1,1) ~= "0" then s = "0" .. s end
if neg then s = "-" .. s end
-- %x hex lowercase
elseif verb == "x" then
is_numeric = true
local n = to_int(arg)
local neg = n < 0
s = string.format("%x", math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if hash_flag then s = "0x" .. s end
if neg then s = "-" .. s end
-- %X hex uppercase
elseif verb == "X" then
is_numeric = true
local n = to_int(arg)
local neg = n < 0
s = string.format("%X", math.abs(n))
if prec then s = string.rep("0", math.max(0, prec - #s)) .. s end
if hash_flag then s = "0X" .. s end
if neg then s = "-" .. s end
-- %e scientific lowercase
elseif verb == "e" then
is_numeric = true
local p = prec ~= nil and prec or 6
s = string.format("%." .. p .. "e", to_num(arg))
if plus_sign and arg >= 0 then s = "+" .. s end
-- %E scientific uppercase
elseif verb == "E" then
is_numeric = true
local p = prec ~= nil and prec or 6
s = string.format("%." .. p .. "E", to_num(arg))
if plus_sign and arg >= 0 then s = "+" .. s end
-- %f decimal float
elseif verb == "f" then
is_numeric = true
local p = prec ~= nil and prec or 6
s = string.format("%." .. p .. "f", to_num(arg))
if plus_sign and arg >= 0 then s = "+" .. s end
if space_sign and arg >= 0 then s = " " .. s end
-- %g / %G shortest float representation
elseif verb == "g" or verb == "G" then
is_numeric = true
local p = prec ~= nil and prec or -1
local n = to_num(arg)
local abs_n = math.abs(n)
if p == -1 then
-- mimic Go: use %e if exponent < -4 or >= precision(default 6)
if abs_n ~= 0 and (abs_n < 1e-4 or abs_n >= 1e6) then
s = string.format(verb == "G" and "%.6E" or "%.6e", n)
else
s = string.format("%.6g", n)
if verb == "G" then s = s:upper() end
end
else
s = string.format("%." .. p .. (verb == "G" and "G" or "g"), n)
end
if plus_sign and n >= 0 then s = "+" .. s end
-- %s string
elseif verb == "s" then
s = tostring(arg)
s = apply_str_precision(s, prec)
-- %q quoted string
elseif verb == "q" then
s = quote_string(tostring(arg))
-- %c character
elseif verb == "c" then
local n = to_int(arg)
-- utf8.char available in Lua 5.3+
if utf8 then
s = utf8.char(n)
else
s = string.char(n) -- ASCII only fallback
end
else
error(string.format("fmt: unknown verb %%%s", verb), 2)
end
-- Apply width padding
s = apply_width(s, width, left_align, zero_pad, is_numeric)
out[#out+1] = s
end
end
end
return table.concat(out)
end
--[[
fmt.Printf(format, ...)
Prints to stdout (no trailing newline, just like Go).
--]]
function fmt.Printf(format, ...)
io.write(fmt.Sprintf(format, ...))
end
--[[
fmt.Println(...)
Prints args separated by spaces with a trailing newline.
--]]
function fmt.Println(...)
local parts = {}
for i = 1, select("#", ...) do
parts[i] = tostring(select(i, ...))
end
print(table.concat(parts, " "))
end
--[[
fmt.Fprintf(file, format, ...)
Writes formatted output to a file handle.
--]]
function fmt.Fprintf(file, format, ...)
file:write(fmt.Sprintf(format, ...))
end
--[[
fmt.Errorf(format, ...) → string
Returns a formatted error string (for use with error()).
--]]
function fmt.Errorf(format, ...)
return fmt.Sprintf(format, ...)
end
return fmt