local tl = require("tl")
local lint = require("htl.lint")
local fmt_mod = require("htl.fmt")
local H = {}
do
local tl_search = tl.search_module
tl.search_module = function(module_name, search_all)
local found, fd, tried = tl_search(module_name, false) if found or not search_all then
return found, fd, tried
end
return tl_search(module_name, true) end
end
H.lint_cfg = {}
H.tl_cfg = {}
local function on_off(on, off)
local t = {}
for _, name in ipairs(on or {}) do t[name] = true end
for _, name in ipairs(off or {}) do t[name] = false end
return t
end
function H.set_lints(lua_on, lua_off, tl_on, tl_off)
H.lint_cfg = on_off(lua_on, lua_off)
H.tl_cfg = on_off(tl_on, tl_off)
end
H.deps = {}
function H.set_deps(names)
local deps = {}
for _, name in ipairs(names or {}) do deps[name] = true end
H.deps = deps
end
function H.lint_rules()
return lint.rule_names()
end
function H.format(filename, indent)
local fd, err = io.open(filename, "rb")
if not fd then return nil, "could not open " .. filename .. ": " .. tostring(err) end
local src = fd:read("a")
fd:close()
return fmt_mod.format(src, filename, { indent = indent })
end
H.GEN_TARGET = "5.4"
local function new_env()
local env = assert(tl.new_env({
defaults = {
feat_lax = "off",
gen_compat = "off",
gen_target = H.GEN_TARGET,
},
}), "htl: tl.new_env failed")
env.report_types = true
return env
end
local function source_lines(cache, file)
local cached = cache[file]
if cached ~= nil then return cached end
local fd = io.open(file, "rb")
if not fd then
cache[file] = false
return false
end
local src = fd:read("a")
fd:close()
local lines = {}
for l in (src .. "\n"):gmatch("(.-)\n") do lines[#lines + 1] = l end
cache[file] = lines
return lines
end
local function has_marker(lines, y, marker)
if not lines or not y then return false end
local pat = "%-%-%-@" .. marker .. "%f[%W]"
local l = lines[y]
if l and l:match(pat) then return true end
local above = lines[y - 1]
return (above and above:match("^%s*%-%-%-@") and above:match(pat)) and true or false
end
local function indent_of(line)
return #(line:match("^(%s*)") or "")
end
local function field_lines(lines, y)
local at, order = {}, {}
local decl = lines[y]
if not decl then return at, order end
local base = indent_of(decl)
for i = y + 1, #lines do
local l = lines[i]
if l:match("^%s*end%f[%W]") and indent_of(l) <= base then break end
local name, written = l:match("^%s*([%w_]+)%s*:%s*(.-)%s*$")
if name then
at[name] = i
local ty = written:gsub("%s*%-%-.*$", "")
order[#order + 1] = { name = name, type = ty }
end
end
return at, order
end
local function struct_spec(cache, t)
local lines = source_lines(cache, t.file)
if not lines then return nil end
if not has_marker(lines, t.y, "struct") then return nil end
local at, order = field_lines(lines, t.y)
local required, declared = {}, {}
local any = false
for name in pairs(t.fields or {}) do
declared[name] = true
if not has_marker(lines, at[name], "optional") then
required[name] = true
any = true
end
end
if not any then return nil end
local fields, seen = {}, {}
for _, f in ipairs(order) do
if required[f.name] and not seen[f.name] then
seen[f.name] = true
fields[#fields + 1] = f
end
end
return { name = t.str or "record", required = required, declared = declared, fields = fields }
end
local function struct_resolver(result, filename)
local ok, report = pcall(tl.get_types, result)
if not ok or type(report) ~= "table" then return nil end
local by_pos = report.by_pos and report.by_pos[filename]
if not by_pos then return nil end
local specs, sources = {}, {}
local function deref(id, depth)
local t = report.types[id]
if t and t.ref and depth < 8 then return deref(t.ref, depth + 1) end
return t
end
return function(y, x)
local id = by_pos[y] and by_pos[y][x]
if not id then return nil end
local t = deref(id, 0)
if not t or not t.fields or not t.file or not t.y then return nil end
if specs[id] == nil then specs[id] = struct_spec(sources, t) or false end
return specs[id] or nil
end
end
local function marker_args(line, marker)
if not line then return false, nil end
local at = line:find("%-%-%-@" .. marker .. "%f[%W]")
if not at then return false, nil end
local args = line:sub(at):match("^%-%-%-@" .. marker .. "%s*(%b())")
return true, args and args:sub(2, -2) or nil
end
local function marker_on(lines, y, marker)
if not lines or not y then return false, nil end
local found, args = marker_args(lines[y], marker)
if found then return true, args end
local above = lines[y - 1]
if above and above:match("^%s*%-%-%-@") then return marker_args(above, marker) end
return false, nil
end
local function clean_path(p)
local s = tostring(p or ""):gsub("\\", "/")
s = s:gsub("^%./", "")
return s:lower()
end
local function same_file(a, b)
if not a or not b then return false end
local x, y = clean_path(a), clean_path(b)
if x == y then return true end
return x:sub(-#y - 1) == "/" .. y or y:sub(-#x - 1) == "/" .. x
end
local function qualified_name(lines, y, name)
local base = indent_of(lines[y] or "")
if base == 0 then return name end
local parts = { name }
for i = y - 1, 1, -1 do
local l = lines[i]
if l and l:match("%S") and indent_of(l) < base then
local outer = l:match("^%s*local%s+record%s+([%w_]+)") or l:match("^%s*record%s+([%w_]+)")
if not outer then break end
table.insert(parts, 1, outer)
base = indent_of(l)
if base == 0 then break end
end
end
return table.concat(parts, ".")
end
local function sealed_spec(cache, t, filename)
local lines = source_lines(cache, t.file)
if not lines then return nil end
local found, args = marker_on(lines, t.y, "sealed")
if not found then return nil end
local fns
if args then
fns = {}
for name in args:gmatch("[^,%s]+") do fns[#fns + 1] = name end
if #fns == 0 then fns = nil end
end
return {
name = qualified_name(lines, t.y, t.str or "record"),
file = tostring(t.file):match("([^/\\]+)$") or tostring(t.file),
here = same_file(t.file, filename),
fns = fns,
}
end
local function sealed_resolver(result, filename)
local ok, report = pcall(tl.get_types, result)
if not ok or type(report) ~= "table" then return nil end
local by_pos = report.by_pos and report.by_pos[filename]
if not by_pos then return nil end
local specs, sources = {}, {}
local function deref(id, depth)
local t = report.types[id]
if t and t.ref and depth < 8 then return deref(t.ref, depth + 1) end
return t
end
return function(y, x)
local id = by_pos[y] and by_pos[y][x]
if not id then return nil end
local t = deref(id, 0)
if not t or not t.fields or not t.file or not t.y then return nil end
if specs[id] == nil then specs[id] = sealed_spec(sources, t, filename) or false end
return specs[id] or nil
end
end
local function nilable_at_decl(cache, t)
local lines = source_lines(cache, t.file)
if not lines then return false end
return (marker_on(lines, t.y, "nilable")) and true or false
end
local function nilable_resolver(result, filename)
local ok, report = pcall(tl.get_types, result)
if not ok or type(report) ~= "table" then return nil end
local by_pos = report.by_pos and report.by_pos[filename]
if not by_pos then return nil end
local marked, sources = {}, {}
local function deref(id, depth)
local t = report.types[id]
if t and t.ref and depth < 8 then return deref(t.ref, depth + 1) end
return t
end
return function(y, x)
local id = by_pos[y] and by_pos[y][x]
if not id then return false end
local t = deref(id, 0)
if not t or t.fields or not t.file or not t.y then return false end
if marked[id] == nil then marked[id] = nilable_at_decl(sources, t) end
return marked[id]
end
end
local function extensible_declared(cache, t)
local lines = source_lines(cache, t.file)
if not lines then return nil end
if not marker_on(lines, t.y, "extensible") then return nil end
local declared = {}
for name in pairs(t.fields or {}) do declared[name] = true end
return declared
end
local function literal_key(item)
local key = item.key
if type(key) ~= "table" then return nil end
if key.conststr then return key.conststr end
if key.kind == "string" and type(key.tk) == "string" then return key.tk:sub(2, -2) end
return nil
end
local function extensible_keys(filename, result)
local out = {}
if not result or not result.ast then return out end
local ok, report = pcall(tl.get_types, result)
if not ok or type(report) ~= "table" then return out end
local by_pos = report.by_pos and report.by_pos[filename]
if not by_pos then return out end
local declareds, sources = {}, {}
local function deref(id, depth)
local t = report.types[id]
if t and t.ref and depth < 8 then return deref(t.ref, depth + 1) end
return t
end
local function declared_at(y, x)
local id = by_pos[y] and by_pos[y][x]
if not id then return nil end
local t = deref(id, 0)
if not t or not t.fields or not t.file or not t.y then return nil end
if declareds[id] == nil then declareds[id] = extensible_declared(sources, t) or false end
return declareds[id] or nil
end
local seen = {}
local function go(n)
if type(n) ~= "table" or seen[n] then return end
seen[n] = true
if n.kind == "literal_table" and n.y and n.x then
local declared = declared_at(n.y, n.x)
if declared then
for _, item in ipairs(n) do
local name = type(item) == "table" and item.y and item.x and literal_key(item)
if name and not declared[name] then
out[item.y .. ":" .. item.x] = name
end
end
end
end
for k, v in pairs(n) do
if k ~= "if_parent" and k ~= "type" and k ~= "newtype" and k ~= "decltuple" and k ~= "expected"
and type(v) == "table" then go(v) end
end
end
go(result.ast)
return out
end
local function union_resolver(result, filename)
local ok, report = pcall(tl.get_types, result)
if not ok or type(report) ~= "table" then return nil end
local by_pos = report.by_pos and report.by_pos[filename]
if not by_pos then return nil end
local function deref(id, depth)
local t = report.types[id]
if t and t.ref and depth < 8 then return deref(t.ref, depth + 1) end
return t
end
return function(y, x)
local id = by_pos[y] and by_pos[y][x]
if not id then return nil end
local t = deref(id, 0)
if not t then return nil end
if type(t.types) ~= "table" or #t.types < 2 then return false end
local names = {}
for _, mid in ipairs(t.types) do
local m = deref(mid, 0)
if not m or not m.str then return false end
names[m.str:match("([^.]+)$") or m.str] = true
end
return names
end
end
local function enum_boundary_resolvers(result, filename)
local ok, report = pcall(tl.get_types, result)
if not ok or type(report) ~= "table" then return nil, nil end
local by_pos = report.by_pos and report.by_pos[filename]
if not by_pos then return nil, nil end
local function deref(id, depth)
if not id then return nil end
local t = report.types[id]
if t and t.ref and depth < 8 then return deref(t.ref, depth + 1) end
return t
end
local function at(y, x)
return deref(by_pos[y] and by_pos[y][x], 0)
end
local function enum_of(t)
if not t or type(t.enums) ~= "table" then return nil end
local values = {}
for _, v in ipairs(t.enums) do values[#values + 1] = v end
table.sort(values)
return { name = t.str or "enum", values = values }
end
local cast_at = function(y, x, from_y, from_x)
local target = enum_of(at(y, x))
if not target then return nil end
local from = at(from_y, from_x)
return target, from and from.str or nil
end
local enum_table_at = function(y, x)
local t = at(y, x)
if not t then return nil end
local key, value = enum_of(deref(t.keys, 0)), enum_of(deref(t.values, 0))
if not (key or value) then return nil end
return { name = t.str or "table", key = key, value = value }
end
return cast_at, enum_table_at
end
local function subject_enum_resolver(result, filename)
local ok, report = pcall(tl.get_types, result)
if not ok or type(report) ~= "table" then return nil end
local function deref(id, depth)
local t = report.types[id]
if t and t.ref and depth < 8 then return deref(t.ref, depth + 1) end
return t
end
return function(y, x, key)
local syms = tl.symbols_in_scope(report, y, x, filename)
local parts = {}
for p in key:gmatch("[^.]+") do parts[#parts + 1] = p end
local id = syms[parts[1]]
if not id then return nil end
local t = deref(id, 0)
for i = 2, #parts do
if not t or not t.fields then return nil end
local fid = t.fields[parts[i]]
if not fid then return nil end
t = deref(fid, 0)
end
if not t then return nil end
if t.enums then
local set = {}
for _, v in ipairs(t.enums) do set[v] = true end
return set, t.str or "enum"
end
return false
end
end
H.env = new_env()
local function fmt(filename, e)
return string.format("%s:%d:%d: %s", e.filename or filename, e.y or 0, e.x or 0, e.msg or "?")
end
local function warnings_of(filename, result, src)
local out = {}
local allows = {} local function allowed(file, y, rule)
if not y or y == 0 then return false end
local a = allows[file]
if a == nil then
local text = (file == filename) and src or nil
if not text then
local fd = io.open(file, "rb")
if fd then text = fd:read("a"); fd:close() end
end
a = text and lint.collect_allows(text) or false
allows[file] = a
end
return a and a[y] and a[y][rule] == true
end
for _, w in ipairs(result.warnings or {}) do
local rule = w.tag and ("tl:" .. w.tag)
local file = w.filename or filename
if not rule then
out[#out + 1] = fmt(filename, w)
elseif H.tl_cfg[rule] ~= false and not allowed(file, w.y, rule) then
out[#out + 1] = fmt(filename, w) .. " [htl " .. rule .. "]"
end
end
return out
end
local function norm_path(p)
p = tostring(p):gsub("^%./", "")
return p:lower()
end
local function explain_self_require(filename, e)
local msg = e.msg or ""
local name = msg:match("no type information for required module: '([^']+)'")
or msg:match("module not found: '([^']+)'")
or msg:match("circular require: '([^']+)'")
if not name then return msg end
local found, fd = tl.search_module(name, true)
if fd then fd:close() end
if found and norm_path(found) == norm_path(filename) then
return msg .. string.format(
" (module '%s' resolved to '%s', the requiring file itself: the filesystem is case-insensitive " ..
"and the module name collides with this file's name; rename one of them)", name, found)
end
return msg
end
local function callee_name(n)
if type(n) ~= "table" 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 == "." or n.op.op == ":") then
local a, b = callee_name(n.e1), callee_name(n.e2)
if a and b then return a .. n.op.op .. b end
end
return nil
end
local function explain_arity(ast, e, msg)
local given, expects = msg:match("^wrong number of arguments %(given (%d+), expects (%d+)%)")
if not given or not ast then return msg end
given, expects = tonumber(given), tonumber(expects)
if given <= expects then return msg end
local hit
local seen = {}
local function go(n)
if hit or type(n) ~= "table" or seen[n] then return end
seen[n] = true
if n.kind == "op" and n.op and (n.op.op == "@funcall" or n.op.op == "@methcall")
and n.y == e.y and n.x == e.x and type(n.e2) == "table" then
local last = n.e2[#n.e2]
if type(last) == "table" and last.kind == "op" and last.op
and (last.op.op == "@funcall" or last.op.op == "@methcall") then
hit = last
return
end
end
for k, v in pairs(n) do
if k ~= "y" and k ~= "x" and type(v) == "table" then go(v) end
end
end
go(ast)
if not hit then return msg end
local name = callee_name(hit.e1)
local call = name and (name .. "(...)") or "the last argument"
local extra = given - expects
return msg .. string.format(
": %s is a call in last position, so all of its return values expand into arguments here (%d extra); " ..
"bind them first (`local a, b = %s`) or wrap it in parentheses `(%s)` to keep only the first",
call, extra, call, call)
end
local function header_sig(src, y)
local lines, i = {}, 0
for line in (src .. "\n"):gmatch("([^\n]*)\n") do
i = i + 1
if i >= y then lines[#lines + 1] = line end
if i >= y + 12 then break end
end
local text = table.concat(lines, "\n")
local p = text:find("(", 1, true)
if not p then return nil end
local depth, q = 0, p
while q <= #text do
local ch = text:sub(q, q)
if ch == "(" then
depth = depth + 1
elseif ch == ")" then
depth = depth - 1
if depth == 0 then break end
end
q = q + 1
end
if depth ~= 0 then return nil end
local sig = text:sub(p, q)
local rets = text:sub(q + 1):match("^[ \t]*(:[^\n]*)")
if rets then
rets = rets:gsub("%s*%-%-.*$", ""):gsub("%s+return%s.*$", ""):gsub("%s+end%s*$", "")
sig = sig .. rets
end
return (sig:gsub("%s+", " "):gsub("%( ", "("):gsub(" %)", ")"))
end
local function record_decl(ast, rec)
for _, s in ipairs(ast) do
if type(s) == "table" and (s.kind == "local_type" or s.kind == "global_type")
and s.var and s.var.tk == rec and s.value and s.value.newtype then
return s
end
end
return nil
end
local function forward_ref_fix(src, decl, line)
local lines, i = {}, 0
for l in (src .. "\n"):gmatch("([^\n]*)\n") do
i = i + 1
lines[i] = l
end
local yend = decl.yend
if not yend then
local head_indent = (lines[decl.y] or ""):match("^(%s*)")
for j = decl.y + 1, #lines do
if lines[j]:match("^" .. head_indent .. "end%s*$") then
yend = j
break
end
end
end
if not yend or yend <= decl.y then return nil end
local indent
for j = yend - 1, decl.y + 1, -1 do
local l = lines[j]
if l and l:match("%S") then
indent = l:match("^(%s*)")
break
end
end
if not indent then
indent = (lines[decl.y] or ""):match("^(%s*)") .. " "
end
return {
applicability = "safe",
edits = { { line = yend, col = 1, end_line = yend, end_col = 1, text = indent .. line .. "\n" } },
}
end
local function explain_forward_ref(ast, src, e, msg)
local key, rec = msg:match("^invalid key '([%w_]+)' in record '([%w_]+)'")
if not key or not ast or not src then return msg end
for _, s in ipairs(ast) do
if type(s) == "table" and s.kind == "record_function" and s.fn_owner and s.name
and s.fn_owner.tk == rec and s.name.tk == key and s.y and s.y > (e.y or 0) then
local sig = header_sig(src, s.y) or "(...)"
if s.is_method then
sig = sig:gsub("^%(%s*%)", "(self: " .. rec .. ")", 1):gsub("^%(", "(self: " .. rec .. ", ", 1)
end
local decl_line = key .. ": function" .. sig
local explained = msg .. string.format(
": `%s.%s` is defined at line %d, after this use, and Teal adds a record's fields in " ..
"source order. Declare it up front inside `record %s`: `%s` -- or move the " ..
"definition above line %d",
rec, key, s.y, rec, decl_line, e.y or 0)
local decl = record_decl(ast, rec)
local fix = decl and forward_ref_fix(src, decl, decl_line) or nil
return explained, fix
end
end
return msg
end
local function require_sites(ast)
local out, seen = {}, {}
local function go(n)
if type(n) ~= "table" or seen[n] then return end
seen[n] = true
if type(n.kind) == "string" and n.kind == "op" and n.op and n.op.op == "@funcall"
and type(n.e1) == "table" and n.e1.kind == "variable" and n.e1.tk == "require"
and type(n.e2) == "table" and type(n.e2[1]) == "table" and n.e2[1].kind == "string" then
local tk = n.e2[1].tk or ""
local name = tk:sub(2, -2)
local found, fd = tl.search_module(name, true)
if fd then fd:close() end
out[#out + 1] = { name = name, y = n.y, x = n.x, path = found }
end
for k, v in pairs(n) do
if k ~= "if_parent" and k ~= "type" and k ~= "newtype" and k ~= "decltuple" and k ~= "expected"
and type(v) == "table" then go(v) end
end
end
go(ast)
return out
end
local function self_require_errors(filename, ast)
local out = {}
for _, r in ipairs(require_sites(ast)) do
if r.path and norm_path(r.path) == norm_path(filename) then
out[#out + 1] = {
y = r.y, x = r.x,
msg = string.format(
"require(\"%s\") resolves to '%s', the requiring file itself: the filesystem is " ..
"case-insensitive and the module name collides with this file's name; rename one of them",
r.name, r.path),
}
end
end
return out
end
local WHERE_FIELD_MSG =
"syntax error: 'where' opens a union predicate when it is the first line of a record " ..
"or interface body; write [\"where\"]: <type>, or put another field first"
local function explain_where_field(filename, src, syntax_errors)
local msgs, dropped = {}, {}
if not src then return msgs, dropped end
local lines = {}
for line in (src .. "\n"):gmatch("([^\n]*)\n") do lines[#lines + 1] = line end
for i, e in ipairs(syntax_errors) do
local line = e.msg == "syntax error" and e.y and lines[e.y]
local indent, gap = nil, nil
if line then indent, gap = line:match("^(%s*)where(%s*):") end
if indent and e.x == #indent + 5 + #gap + 1 then
local rewritten = {}
for j, l in ipairs(lines) do rewritten[j] = l end
rewritten[e.y] = indent .. "[\"where\"]" .. line:sub(#indent + 6)
local _, errs = tl.parse(table.concat(rewritten, "\n"), filename, "tl")
local still = {}
for _, r in ipairs(errs or {}) do still[r.y or 0] = true end
if not still[e.y] then
msgs[i] = WHERE_FIELD_MSG
local j = i + 1
while syntax_errors[j] and syntax_errors[j].y and syntax_errors[j].y > e.y
and not still[syntax_errors[j].y] do
dropped[j] = true
j = j + 1
end
end
end
end
return msgs, dropped
end
local function record_method_names(src, name)
local out = {}
local depth
for line in (src .. "\n"):gmatch("([^\n]*)\n") do
if not depth then
if line:match("^%s*record%s+" .. name .. "%s*<") or line:match("^%s*record%s+" .. name .. "%s*$") then
depth = 1
end
elseif line:match("^%s*end%s*$") then
depth = depth - 1
if depth == 0 then break end
elseif line:match("^%s*record%s") or line:match("^%s*enum%s") or line:match("^%s*interface%s") then
depth = depth + 1
elseif depth == 1 then
local m = line:match("^%s*([%a_][%w_]*)%s*:%s*function%s*%(")
if m then out[#out + 1] = m end
end
end
return out
end
local function matcher_lister(result)
local known = {} return function(type_name)
local list = known[type_name]
if list == nil then
list = false
local decl = (result.dependencies or {})["htl.test"]
local fd = decl and io.open(decl, "rb")
if fd then
local names = record_method_names(fd:read("a"), type_name)
fd:close()
if #names > 0 then list = table.concat(names, ", ") end
end
known[type_name] = list
end
return list or nil
end
end
local function collect_errors(filename, result, src)
if result.htl_errors and result.htl_errors_for == filename then
return result.htl_errors, result.htl_error_fixes
end
local errors = {}
local error_fixes = {}
result.htl_errors, result.htl_error_fixes, result.htl_errors_for = errors, error_fixes, filename
local function source()
if src == nil then
local fd = io.open(filename, "rb")
src = fd and fd:read("a") or false
if fd then fd:close() end
end
return src or nil
end
local syntax_errors = result.syntax_errors or {}
local where_msgs, where_dropped = {}, {}
if #syntax_errors > 0 then
where_msgs, where_dropped = explain_where_field(filename, source(), syntax_errors)
end
for i, e in ipairs(syntax_errors) do
if not where_dropped[i] then
errors[#errors + 1] =
fmt(filename, { filename = e.filename, y = e.y, x = e.x, msg = where_msgs[i] or e.msg })
end
end
if result.ast and #syntax_errors == 0 then
local suspicious = false
for name in (source() or ""):gmatch("require%s*%(?%s*[\"']([^\"']+)[\"']") do
local found, fd = tl.search_module(name, true)
if fd then fd:close() end
if found and norm_path(found) == norm_path(filename) then suspicious = true break end
end
if suspicious then
for _, e in ipairs(self_require_errors(filename, result.ast)) do errors[#errors + 1] = fmt(filename, e) end
end
end
local hinted = {} local extensible
local function extensible_allows(e, key)
if extensible == nil then extensible = extensible_keys(filename, result) end
return extensible[(e.y or 0) .. ":" .. (e.x or 0)] == key
end
local matchers_of = matcher_lister(result)
for _, e in ipairs(result.type_errors or {}) do
local msg = explain_self_require(filename, e)
local own = e.filename == nil or e.filename == filename
local fix
if own then
local explained = explain_arity(result.ast, e, msg)
if explained ~= msg then hinted[e.y] = true end
msg, fix = explain_forward_ref(result.ast, src, e, explained)
end
local expect = msg:match("^invalid key '[%w_]+' in type (Expect%d*)%s*<")
if expect then
local list = matchers_of(expect)
if list then msg = msg .. "; the matchers are " .. list .. " (README, \"Tests\")" end
end
local dropped = own and hinted[e.y] and msg:find("(unresolved generic)", 1, true)
if not dropped then
local key = own and msg:match("unknown field ([%w_]+)$")
dropped = key and extensible_allows(e, key)
end
if not dropped then
errors[#errors + 1] = fmt(filename, { filename = e.filename, y = e.y, x = e.x, msg = msg })
error_fixes[#errors] = fix or false
end
end
for i = 1, #errors do
if error_fixes[i] == nil then error_fixes[i] = false end
end
return errors, error_fixes
end
local function collect_type_enums(t, path, out, seen, depth)
if type(t) ~= "table" or seen[t] or depth > 12 then return end
seen[t] = true
if t.typename == "enum" and t.enumset then
out[path] = t.enumset
end
if t.def then collect_type_enums(t.def, path, out, seen, depth + 1) end
if t.fields then
for k, v in pairs(t.fields) do
collect_type_enums(v, path .. "." .. tostring(k), out, seen, depth + 1)
end
end
end
local function checked_enums(result, env)
local out, seen = {}, {}
for _, node in ipairs(result.ast or {}) do
if (node.kind == "local_type" or node.kind == "global_type") and node.value and node.value.newtype then
collect_type_enums(node.value.newtype, node.var and node.var.tk or "?", out, seen, 0)
end
end
if result.type then collect_type_enums(result.type, "<module>", out, seen, 0) end
for name, mod in pairs(env.modules or {}) do
collect_type_enums(mod, name, out, seen, 0)
end
return out
end
local PROFILE = os.getenv("HTL_PROFILE") ~= nil
local function prof(label, filename, t0)
if PROFILE then
io.stderr:write(string.format("profile: %-8s %7.1f ms %s\n", label, (os.clock() - t0) * 1000, filename))
end
end
local store = {}
local function store_from(env)
for name, ty in pairs(env.modules) do
local fname = env.module_filenames[name]
local result = fname and env.loaded[fname]
if result and result.type == ty then
store[name] = { filename = fname, type = ty, result = result }
end
end
end
local function seed_env(env)
for name, e in pairs(store) do
if env.modules[name] == nil then
local found, fd = tl.search_module(name, true)
if fd then fd:close() end
if found == e.filename then
env.modules[name] = e.type
env.module_filenames[name] = e.filename
env.loaded[e.filename] = e.result
end
end
end
end
function H.reset_store()
store = {}
end
local function dependency_errors(filename, result, env)
local out = {}
local seen = { [filename] = true }
local function walk(res, requirer)
local names = {}
for name in pairs(res.dependencies or {}) do names[#names + 1] = name end
table.sort(names)
for _, name in ipairs(names) do
local fname = res.dependencies[name]
if not seen[fname] then
seen[fname] = true
local dep = env.loaded and env.loaded[fname]
if not dep then
local e = store[name]
if e and e.filename == fname then dep = e.result end
end
if dep then
local errs = collect_errors(fname, dep)
for _, text in ipairs(errs) do
out[#out + 1] = { file = fname, required_by = requirer, text = text }
end
walk(dep, fname)
end
end
end
end
walk(result, filename)
return out
end
function H.check(filename, env, opts)
opts = opts or {}
env = env or new_env() local t0 = os.clock()
if not env.htl_seeded then
env.htl_seeded = true
if opts.seed ~= false then seed_env(env) end
end
local result, err = tl.check_file(filename, env)
prof("check", filename, t0)
if result and opts.store ~= false then store_from(env) end
if not result then
return { ok = false, errors = { tostring(err) }, warnings = {} }
end
t0 = os.clock()
local errors, error_fixes = collect_errors(filename, result)
local warnings = warnings_of(filename, result)
local deps = {}
for _, fname in pairs(result.dependencies or {}) do deps[#deps + 1] = fname end
table.sort(deps)
local lints, lint_fixes = {}, {}
if opts.lints ~= false and result.ast and #(result.syntax_errors or {}) == 0 then
local src
local fd = io.open(filename, "rb")
if fd then src = fd:read("a"); fd:close() end
if src then
local t1 = os.clock()
local enums = checked_enums(result, env)
prof("enums", filename, t1)
t1 = os.clock()
local subject = subject_enum_resolver(result, filename)
prof("get_types", filename, t1)
t1 = os.clock()
local cast_at, enum_table_at = enum_boundary_resolvers(result, filename)
local found = lint.run(src, filename, H.lint_cfg, {
enums = enums,
subject_enum = subject,
struct_at = struct_resolver(result, filename),
sealed_at = sealed_resolver(result, filename),
nilable_at = nilable_resolver(result, filename),
deps = H.deps,
union_at = union_resolver(result, filename),
cast_at = cast_at,
enum_table_at = enum_table_at,
})
prof("lint.run", filename, t1)
for _, l in ipairs(found or {}) do
lints[#lints + 1] = fmt(filename, l)
lint_fixes[#lints] = l.fix or false
end
end
end
local requires = {}
if opts.lints ~= false and result.ast then requires = require_sites(result.ast) end
prof("lint+req", filename, t0)
local dep_errors = dependency_errors(filename, result, env)
return { ok = #errors == 0, errors = errors, error_fixes = error_fixes, warnings = warnings, deps = deps,
lints = lints, lint_fixes = lint_fixes, requires = requires, dependency_errors = dep_errors, result = result }
end
function H.check_written(filename)
return H.check(filename, nil, { seed = false, store = false })
end
function H.gen(filename, opts)
local c = H.check(filename, H.env, opts)
if not c.ok then
return nil, c
end
if c.result.htl_code then
return c.result.htl_code, c
end
local t0 = os.clock()
local code, gerr = tl.generate(c.result.ast, H.GEN_TARGET)
prof("generate", filename, t0)
if code then c.result.htl_code = code end
if not code then
c.ok = false
c.errors = { filename .. ": generate failed: " .. tostring(gerr) }
return nil, c
end
return code, c
end
function H.gen_string(src, filename)
local result = tl.check_string(src, H.env, filename)
local errors = collect_errors(filename, result, src)
local warnings = warnings_of(filename, result, src)
local c = { ok = #errors == 0, errors = errors, warnings = warnings, deps = {}, lints = {}, result = result }
if not c.ok or not result.ast then
return nil, c
end
local code, gerr = tl.generate(result.ast, H.GEN_TARGET)
if not code then
c.ok = false
c.errors = { filename .. ": generate failed: " .. tostring(gerr) }
return nil, c
end
return code, c
end
function H.type_only_module(module_name, decl_path)
return setmetatable({}, {
__index = function(_, key)
error(string.format(
"module '%s' is declaration-only here (%s): '%s' has no implementation on this path. " ..
"It must be provided by the host program (e.g. a Rust #[host_module] via cargo run) " ..
"or by a .tl/.lua module with that name.",
module_name, decl_path, tostring(key)), 2)
end,
})
end
function H.record_fields(type_path)
local module, tname = type_path:match("^([^.]+)%.(.+)$")
if not module then return nil end
local mod = H.env.modules and H.env.modules[module]
if not mod then
tl.check_string(string.format('local m = require("%s")\nreturn m\n', module), H.env,
"<record_fields " .. module .. ">")
mod = H.env.modules and H.env.modules[module]
end
if not mod then return nil end
local t = mod
for seg in tname:gmatch("[^.]+") do
if t.def then t = t.def end
if not (t.fields and t.fields[seg]) then return nil end
t = t.fields[seg]
end
if t.def then t = t.def end
if not t.fields then return nil end
local names = {}
for k in pairs(t.fields) do names[#names + 1] = k end
table.sort(names)
return names
end
function H.lua_requires(src, filename)
local ast, errs = tl.parse(src, filename, "lua")
if not ast or (errs and #errs > 0) then return {} end
return require_sites(ast)
end
function H.resolve_module(name)
local found, fd = tl.search_module(name, true)
if fd then fd:close() end
local lua_path = package.searchpath(name, package.path)
return found, lua_path
end
function H.module_candidates(name)
local out, seen = {}, {}
local relative = (name:gsub("%.", "/"))
for _, ext in ipairs({ { ".tl", "source" }, { ".d.tl", "declaration" }, { ".lua", "lua" } }) do
for template in package.path:gmatch("[^;]+") do
if template:sub(-4) == ".lua" then
local p = (template:sub(1, -5) .. ext[1]):gsub("%?", relative)
if not seen[p] then
seen[p] = true
local fd = io.open(p, "r")
if fd then
fd:close()
out[#out + 1] = { path = p, kind = ext[2], dir = H.template_dir(template) }
end
end
end
end
end
return out
end
function H.template_dir(template)
local head = template:match("^([^?]*)") or ""
head = head:gsub("/+$", "")
if head == "" then return "." end
return head
end
function H.search_dirs()
local out, seen = {}, {}
for template in package.path:gmatch("[^;]+") do
local dir = H.template_dir(template)
if not seen[dir] then
seen[dir] = true
out[#out + 1] = dir
end
end
return out
end
function H.declaration_sites(name)
local out = {}
for _, c in ipairs(H.module_candidates(name)) do
if c.kind == "declaration" then
out[#out + 1] = c.path
end
end
return out
end
local EXEC_KINDS = {
local_declaration = true, assignment = true, ["return"] = true, ["if"] = true,
["while"] = true, ["repeat"] = true, forin = true, fornum = true, ["goto"] = true,
["break"] = true, ["do"] = true, local_function = true, global_function = true,
record_function = true, op = true,
}
local FN_KINDS = { local_function = true, global_function = true, record_function = true }
local function owner_name(n)
if type(n) ~= "table" then return nil end
if n.tk then return n.tk end
if n.kind == "op" and n.op and n.op.op == "." then
local a, b = owner_name(n.e1), owner_name(n.e2)
if a and b then return a .. "." .. b end
end
return nil
end
local function function_name(n)
local base = n.name and n.name.tk
if not base then return nil end
if n.kind ~= "record_function" then return base end
local owner = owner_name(n.fn_owner)
if not owner then return base end
return owner .. (n.is_method and ":" or ".") .. base
end
function H.executable_ranges(filename)
local fd = io.open(filename, "rb")
if not fd then return nil end
local src = fd:read("a")
fd:close()
local ast, errs = tl.parse(src, filename, "tl")
if not ast or (errs and #errs > 0) then return nil end
local ranges = {}
local funcs = {}
local seen = {}
local function go(n)
if type(n) ~= "table" or seen[n] then return end
seen[n] = true
if FN_KINDS[n.kind] and n.y then
local last = n.yend or n.y
local name = function_name(n)
if name and last > n.y + 1 then
funcs[#funcs + 1] = { name = name, y = n.y, last = last }
end
end
if n.kind == "statements" then
for i, s in ipairs(n) do
if type(s) == "table" and s.kind and EXEC_KINDS[s.kind] and s.y then
local nxt = n[i + 1]
local last = (type(nxt) == "table" and nxt.y and nxt.y - 1) or s.yend or s.y
if last < s.y then last = s.y end
ranges[#ranges + 1] = { s.y, last }
if s.kind == "if" and s.if_blocks then
for bi = 2, #s.if_blocks do
local b = s.if_blocks[bi]
if b.exp and b.y then ranges[#ranges + 1] = { b.y, b.y } end
end
end
end
end
end
for k, v in pairs(n) do
if k ~= "if_parent" and k ~= "type" and k ~= "newtype" and k ~= "decltuple" and k ~= "expected"
and type(v) == "table" then go(v) end
end
end
go(ast)
table.sort(ranges, function(a, b) return a[1] < b[1] end)
table.sort(funcs, function(a, b) return a.y < b.y end)
return ranges, funcs
end
function H.check_stub(src, filename)
local result = tl.check_string(src, new_env(), filename)
return collect_errors(filename, result, src)
end
function H.contract_check(filename, modname, type_path, require_fields)
local module = type_path:match("^([^.]+)%.")
local out = { errors = {}, missing = nil }
if not module then
out.errors[1] = "contract type must be written as <module>.<Type>: " .. tostring(type_path)
return out
end
local stub = string.format('local %s = require("%s")\nlocal m: %s = require("%s")\nreturn m\n',
module, module, type_path, modname)
out.errors = H.check_stub(stub, "<contract " .. type_path .. " for " .. modname .. ">")
if #out.errors > 0 then return out end
if not require_fields then return out end
local declared = H.record_fields(type_path)
if not declared then return out end
local wanted = declared
if type(require_fields) == "table" then
local is_declared = {}
for _, f in ipairs(declared) do is_declared[f] = true end
local unknown = {}
for _, f in ipairs(require_fields) do
if not is_declared[f] then unknown[#unknown + 1] = f end
end
if #unknown > 0 then
table.sort(unknown)
out.bad_require_fields = unknown
return out
end
wanted = require_fields
end
local fd = io.open(filename, "rb")
if not fd then return out end
local src = fd:read("a")
fd:close()
local ast = tl.parse(src, filename, "tl")
if not ast then return out end
local ret, ret_i
for i = #ast, 1, -1 do
local s = ast[i]
if type(s) == "table" and s.kind == "return" then ret, ret_i = s, i break end
end
if not ret or not ret.exps or not ret.exps[1] then return out end
local exp = ret.exps[1]
local present = {}
local function strip_cast(e) if e and e.kind == "op" and e.op and e.op.op == "as" then return e.e1 end
return e
end
exp = strip_cast(exp)
if exp.kind == "variable" then
local name = exp.tk
local found
for i = ret_i - 1, 1, -1 do
local s = ast[i]
if type(s) == "table" and s.vars then
for vi, v in ipairs(s.vars) do
if (s.kind == "local_declaration" or s.kind == "assignment")
and (v.kind == "variable" or v.kind == "identifier") and v.tk == name
and s.exps and s.exps[vi] then
found = s.exps[vi]
elseif s.kind == "assignment" and v.kind == "op" and v.op and v.op.op == "."
and v.e1 and v.e1.kind == "variable" and v.e1.tk == name and v.e2 and v.e2.tk then
present[v.e2.tk] = true
end
end
end
if found then break end
end
if not found then return out end
exp = strip_cast(found)
end
if exp.kind == "op" and exp.op and exp.op.op == "@funcall" and exp.e2 and exp.e2[1]
and exp.e2[1].kind == "literal_table" and #exp.e2 == 1 then
exp = exp.e2[1]
end
exp = strip_cast(exp)
if exp.kind ~= "literal_table" then return out end
for _, item in ipairs(exp) do
if type(item) == "table" and item.key and item.key.kind == "string" then
present[(item.key.tk or ""):sub(2, -2)] = true
elseif type(item) == "table" and item.key and item.key.kind == "identifier" then
present[item.key.tk] = true
end
end
out.missing = {}
for _, f in ipairs(wanted) do
if not present[f] then out.missing[#out.missing + 1] = f end
end
out.missing_y, out.missing_x = ret.y, ret.x
return out
end
local function resolve_for_require(module_name)
local found, fd = tl.search_module(module_name, false)
if not found then
local dfound, dfd = tl.search_module(module_name, true)
if dfound and dfound:match("%.d%.tl$") then
dfd:close()
local lua_path = package.searchpath(module_name, package.path)
if lua_path then
return "yield", "\n\ttype-only '" .. dfound .. "' (implementation served by the .lua searcher)"
end
return "type_only", dfound
elseif dfd then
dfd:close()
end
return "missing", "\n\tno .tl module '" .. module_name .. "' on package.path"
end
fd:close()
local code, c = H.gen(found, { lints = false })
if not code then
error(table.concat(c.errors, "\n"), 0)
end
return "code", code, found
end
H.gen_for_require = resolve_for_require
local function strict_searcher(module_name)
local kind, a, b = resolve_for_require(module_name)
if kind == "code" then
local chunk, lerr = load(a, "@" .. b, "t")
if not chunk then
error("htl: generated Lua failed to load: " .. tostring(lerr), 0)
end
return function(modname)
return chunk(modname, b)
end, b
elseif kind == "type_only" then
return function() return H.type_only_module(module_name, a) end, a
end
return a
end
function H.install_searcher()
table.insert(package.searchers, 2, strict_searcher)
end
function H.get_path()
return package.path
end
function H.set_path(p)
package.path = p
end
function H.begin_program()
H.env = new_env() end
function H.add_path(dir)
local templates = dir .. "/?.lua;" .. dir .. "/?/init.lua;" .. dir .. "/?/?.lua"
if package.path == nil or package.path == "" then
package.path = templates
else
package.path = templates .. ";" .. package.path
end
end
function H.reset_path()
package.path = ""
end
return H