local tl = require("tl")
local L = {}
L.DEFAULT = {
["nil-index"] = true,
["enum-exhaustive"] = true,
["shadow-local"] = true,
["no-global"] = true,
["no-any"] = false,
["explicit-number"] = false,
}
local SKIP_KEYS = { if_parent = true, type = true, newtype = true, decltuple = true, expected = true }
local function is_node(t)
return type(t) == "table" and type(t.kind) == "string"
end
local function walk(root, visit)
local seen = {}
local function go(n)
if type(n) ~= "table" or seen[n] then return end
seen[n] = true
if is_node(n) then visit(n) end
for k, v in pairs(n) do
if not SKIP_KEYS[k] and type(v) == "table" then go(v) end
end
end
go(root)
end
local function unquote(tk)
if type(tk) ~= "string" then return nil end
local q = tk:sub(1, 1)
if (q == '"' or q == "'") and tk:sub(-1) == q then
return tk:sub(2, -2)
end
return nil
end
local function subject_key(n)
if not is_node(n) then return nil end
if n.kind == "variable" or n.kind == "identifier" then return n.tk end
if n.kind == "op" and n.op and n.op.op == "." then
local a, b = subject_key(n.e1), subject_key(n.e2)
if a and b then return a .. "." .. b end
end
if n.kind == "paren" then return subject_key(n.e1) end
return nil
end
local function collect_allows(src)
local allows = {}
local y = 0
for line in (src .. "\n"):gmatch("(.-)\n") do
y = y + 1
local names = line:match("%-%-%s*htl:%s*allow%(([%w%-, ]+)%)")
if names then
allows[y] = allows[y] or {}
for name in names:gmatch("[%w%-]+") do allows[y][name] = true end
end
end
return allows
end
local CHAIN_OPS = { ["."] = true, [":"] = true, ["@funcall"] = true, ["@index"] = true }
local CHAIN_WHAT = { ["."] = "field access", [":"] = "method call", ["@funcall"] = "call", ["@index"] = "index" }
local function lint_nil_index(ast, report)
walk(ast, function(n)
if n.kind == "op" and n.op and CHAIN_OPS[n.op.op] then
local base = n.e1
if is_node(base) and base.kind == "op" and base.op and base.op.op == "@index" then
report("nil-index", n.y, n.x,
CHAIN_WHAT[n.op.op] .. " directly on an index result: the value may be nil at runtime; bind it to a local and nil-check first")
end
end
end)
end
local function collect_enums(ast)
local enums = {}
walk(ast, function(n)
if (n.kind == "local_type" or n.kind == "global_type") and is_node(n.value)
and n.value.newtype and n.value.newtype.def and n.value.newtype.def.typename == "enum" then
local name = n.var and n.var.tk or "?"
enums[name] = n.value.newtype.def.enumset or {}
end
end)
return enums
end
local function literal_tests(exp)
if not is_node(exp) or exp.kind ~= "op" then return nil end
local op = exp.op and exp.op.op
if op == "or" then
local s1, l1, n1 = literal_tests(exp.e1)
local s2, l2 = literal_tests(exp.e2)
if s1 and s2 and s1 == s2 then
for _, v in ipairs(l2) do l1[#l1 + 1] = v end
return s1, l1, n1
end
return nil
end
if op == "==" then
local lit = unquote(exp.e2 and exp.e2.tk)
local subj, node = subject_key(exp.e1), exp.e1
if lit == nil then
lit = unquote(exp.e1 and exp.e1.tk)
subj, node = subject_key(exp.e2), exp.e2
end
if lit and subj then return subj, { lit }, node end
end
return nil
end
local function lint_enum_exhaustive(ast, report, extra)
extra = extra or {}
local enums = collect_enums(ast)
for name, set in pairs(extra.enums or {}) do
if enums[name] == nil then enums[name] = set end
end
walk(ast, function(n)
if n.kind ~= "if" or not n.if_blocks then return end
if #n.if_blocks < 2 then return end
local subject, seen_lits, subject_node = nil, {}, nil
for _, blk in ipairs(n.if_blocks) do
if not blk.exp then return end local s, lits, node = literal_tests(blk.exp)
if not s then return end
if subject and s ~= subject then return end
subject = s
subject_node = subject_node or node
for _, v in ipairs(lits) do seen_lits[v] = true end
end
if not subject then return end
local best_name, best_set
if extra.subject_enum and subject_node then
local set, tname = extra.subject_enum(subject_node.y, subject_node.x, subject)
if set == false then return end if set then best_name, best_set = tname, set end
end
if not best_set then
if next(enums) == nil then return end
local best_size
for name, set in pairs(enums) do
local all, size = true, 0
for _ in pairs(set) do size = size + 1 end
for v in pairs(seen_lits) do
if not set[v] then all = false break end
end
if all and (best_size == nil or size < best_size) then
best_name, best_set, best_size = name, set, size
end
end
end
if not best_name then return end
local missing = {}
for v in pairs(best_set) do
if not seen_lits[v] then missing[#missing + 1] = v end
end
if #missing > 0 then
table.sort(missing)
report("enum-exhaustive", n.y, n.x,
"if-chain on '" .. subject .. "' does not cover enum " .. best_name .. " value(s): "
.. table.concat(missing, ", ") .. "; add a branch or an else")
end
end)
end
local function lint_shadow(ast, report)
local scopes = {}
local function push() scopes[#scopes + 1] = {} end
local function pop() scopes[#scopes] = nil end
local function declare(name, y, x)
if type(name) ~= "string" or name == "self" or name == "..." or name:sub(1, 1) == "_" then
return
end
for i = #scopes - 1, 1, -1 do
local outer = scopes[i][name]
if outer then
report("shadow-local", y, x,
"local '" .. name .. "' shadows an outer local declared at line " .. outer)
break
end
end
scopes[#scopes][name] = y
end
local function declare_args(args)
if type(args) ~= "table" then return end
for _, a in ipairs(args) do
if is_node(a) then declare(a.tk, a.y, a.x) end
end
end
local visit
local function visit_children(n)
for k, v in pairs(n) do
if not SKIP_KEYS[k] and type(v) == "table" then visit(v) end
end
end
local seen = {}
visit = function(n)
if type(n) ~= "table" or seen[n] then return end
seen[n] = true
if not is_node(n) then
visit_children(n)
return
end
local k = n.kind
if k == "statements" then
push()
for _, s in ipairs(n) do visit(s) end
pop()
elseif k == "local_declaration" then
visit(n.exps)
for _, v in ipairs(n.vars or {}) do
if is_node(v) then declare(v.tk, v.y, v.x) end
end
elseif k == "local_function" then
if is_node(n.name) then declare(n.name.tk, n.name.y, n.name.x) end
push()
declare_args(n.args)
visit(n.body)
pop()
elseif k == "function" or k == "record_function" or k == "global_function" or k == "macroexp" or k == "local_macroexp" then
push()
declare_args(n.args)
visit(n.body)
pop()
elseif k == "forin" then
visit(n.exps)
push()
for _, v in ipairs(n.vars or {}) do
if is_node(v) then declare(v.tk, v.y, v.x) end
end
visit(n.body)
pop()
elseif k == "fornum" then
visit(n.from); visit(n.to); visit(n.step)
push()
if is_node(n.var) then declare(n.var.tk, n.var.y, n.var.x) end
visit(n.body)
pop()
else
visit_children(n)
end
end
push()
visit(ast)
pop()
end
local GLOBAL_KINDS = { global_declaration = true, global_function = true, global_type = true }
local function lint_no_global(ast, report)
walk(ast, function(n)
if GLOBAL_KINDS[n.kind] then
report("no-global", n.y, n.x, "global declaration; prefer a local and return it from the module")
end
end)
end
local function lint_no_any(ast, report)
local seen = {}
local function go(t)
if type(t) ~= "table" or seen[t] then return end
seen[t] = true
if t.typename == "any" and t.y then
report("no-any", t.y, t.x, "explicit 'any' weakens type checking; use a concrete type or a record")
end
for k, v in pairs(t) do
if k ~= "if_parent" and k ~= "type" and k ~= "expected" and type(v) == "table" then go(v) end
end
end
go(ast)
end
local function numeric_literal(exp)
if not is_node(exp) then return nil end
if exp.kind == "integer" then return "integer" end
if exp.kind == "number" then return "number" end
if exp.kind == "op" and exp.op and exp.op.op == "-" and exp.e2 == nil then
return numeric_literal(exp.e1)
end
return nil
end
local function lint_explicit_number(ast, report)
walk(ast, function(n)
if n.kind ~= "local_declaration" or not n.vars then return end
local decl = n.decltuple
local annotated = (decl and decl.tuple and #decl.tuple) or 0
for i, v in ipairs(n.vars) do
if is_node(v) and i > annotated then
local exp = n.exps and n.exps[i]
local inferred = numeric_literal(exp)
if inferred then
local other = inferred == "integer" and "number" or "integer"
report("explicit-number", v.y, v.x,
"'" .. tostring(v.tk) .. "' is inferred as " .. inferred .. " from its literal; write `local "
.. tostring(v.tk) .. ": " .. inferred .. " = ...` (or `: " .. other .. "`) to fix the numeric type explicitly")
end
end
end
end)
end
local RULES = {
{ "nil-index", lint_nil_index },
{ "enum-exhaustive", lint_enum_exhaustive },
{ "shadow-local", lint_shadow },
{ "no-global", lint_no_global },
{ "no-any", lint_no_any },
{ "explicit-number", lint_explicit_number },
}
function L.rule_names()
local out = {}
for _, r in ipairs(RULES) do out[#out + 1] = r[1] end
return out
end
function L.config(spec)
local cfg = {}
for k, v in pairs(L.DEFAULT) do cfg[k] = v end
for item in (spec or ""):gmatch("[^,%s]+") do
local sign, name = item:match("^([%+%-]?)([%w%-]+)$")
if not name or cfg[name] == nil then
return nil, "unknown lint rule: " .. tostring(item)
end
cfg[name] = (sign ~= "-")
end
return cfg
end
function L.run(src, filename, cfg, extra)
cfg = cfg or L.DEFAULT
local ast, errs = tl.parse(src, filename, "tl")
if not ast or #errs > 0 then
return nil, errs[1] and errs[1].msg or "parse failed"
end
local allows = collect_allows(src)
local out = {}
local function report(rule, y, x, msg)
if allows[y] and allows[y][rule] then return end
out[#out + 1] = { rule = rule, y = y or 0, x = x or 0, msg = msg .. " [htl " .. rule .. "]" }
end
for _, r in ipairs(RULES) do
if cfg[r[1]] then r[2](ast, report, extra) end
end
table.sort(out, function(a, b)
if a.y ~= b.y then return a.y < b.y end
return a.x < b.x
end)
return out
end
return L