local PI = require("tools.docs.pkg_info")
local T = require("alc_shapes.t")
local S = require("alc_shapes")
local EntitySchemas = require("tools.docs.entity_schemas")
local is_schema = T._internal.is_schema
local M = {}
local function read_file(path)
local f, err = io.open(path, "r")
if not f then
error(string.format(
"tools.docs.extract: cannot open '%s': %s", path, tostring(err)), 2)
end
local content = f:read("*a")
f:close()
return content
end
local function split_lines(s)
local lines = {}
local i = 1
local len = #s
while i <= len do
local j = s:find("\n", i, true)
if not j then
lines[#lines + 1] = s:sub(i)
break
end
lines[#lines + 1] = s:sub(i, j - 1)
i = j + 1
end
return lines
end
function M.extract_docstring(init_lua_path)
local content = read_file(init_lua_path)
local raw_lines = split_lines(content)
local out = {}
for i = 1, #raw_lines do
local line = raw_lines[i]
if line:sub(1, 3) == "---" then
local rest = line:sub(4)
if rest:sub(1, 1) == "@" then
break
end
if rest:sub(1, 1) == " " then
rest = rest:sub(2)
end
out[#out + 1] = rest
elseif line:match("^%s*$") then
break
else
break
end
end
while #out > 0 and out[#out]:match("^%s*$") do
out[#out] = nil
end
return table.concat(out, "\n")
end
function M.slugify(text)
local s = text:lower()
s = s:gsub("[^a-z0-9]+", "-")
s = s:gsub("^%-+", "")
s = s:gsub("%-+$", "")
return s
end
local function alloc_anchor(seen, base)
if not seen[base] then
seen[base] = 1
return base
end
local n = seen[base] + 1
while seen[base .. "-" .. n] do
n = n + 1
end
seen[base] = n
local unique = base .. "-" .. n
seen[unique] = 1
return unique
end
function M.split_sections(docstring)
local lines = split_lines(docstring)
if #lines == 0 then
return "", "", {}
end
local title = lines[1]
local i = 2
while i <= #lines and lines[i]:match("^%s*$") do
i = i + 1
end
local summary_parts = {}
while i <= #lines do
local line = lines[i]
if line:match("^%s*$") then
break
end
if line:sub(1, 3) == "## " or line:sub(1, 4) == "### " then
break
end
summary_parts[#summary_parts + 1] = line
i = i + 1
end
local summary = table.concat(summary_parts, " ")
while i <= #lines and lines[i]:match("^%s*$") do
i = i + 1
end
local sections = {}
local seen_anchors = {}
local cur_level, cur_heading, cur_anchor = nil, nil, nil
local cur_body = {}
local function flush()
if cur_heading then
local lo, hi = 1, #cur_body
while lo <= hi and cur_body[lo]:match("^%s*$") do
lo = lo + 1
end
while hi >= lo and cur_body[hi]:match("^%s*$") do
hi = hi - 1
end
local body_lines = {}
for j = lo, hi do
body_lines[#body_lines + 1] = cur_body[j]
end
sections[#sections + 1] = PI.make_section(
cur_level, cur_heading, cur_anchor,
table.concat(body_lines, "\n"))
end
end
while i <= #lines do
local line = lines[i]
local h2 = line:match("^## (.+)$")
local h3 = line:match("^### (.+)$")
if h2 and not line:match("^### ") then
flush()
cur_level = 2
cur_heading = h2
cur_anchor = alloc_anchor(seen_anchors, M.slugify(h2))
cur_body = {}
elseif h3 then
flush()
cur_level = 3
cur_heading = h3
cur_anchor = alloc_anchor(seen_anchors, M.slugify(h3))
cur_body = {}
else
cur_body[#cur_body + 1] = line
end
i = i + 1
end
flush()
return title, summary, sections
end
function M.load_pkg(pkg_name)
package.loaded[pkg_name] = nil
local ok, mod = pcall(require, pkg_name)
if not ok then
error(string.format(
"tools.docs.extract: failed to require('%s'): %s",
pkg_name, tostring(mod)), 2)
end
if type(mod) ~= "table" then
error(string.format(
"tools.docs.extract: require('%s') did not return a table",
pkg_name), 2)
end
if type(mod.meta) ~= "table" then
error(string.format(
"tools.docs.extract: pkg '%s' has no M.meta table", pkg_name), 2)
end
return mod
end
function M.build_pkg_info(pkg_name, init_path, source_path)
local docstring = M.extract_docstring(init_path)
local title, summary, sections = M.split_sections(docstring)
local mod = M.load_pkg(pkg_name)
local meta = mod.meta
local identity = {
name = meta.name or pkg_name,
version = meta.version or "",
category = meta.category or "",
description = meta.description or "",
source_path = source_path,
}
local narrative = {
title = title,
summary = summary,
sections = sections,
}
local shape = {
input = nil,
result = nil,
}
local spec = mod.spec
if type(spec) == "table" and type(spec.entries) == "table" then
local run = spec.entries.run
if type(run) == "table" then
if run.input ~= nil then
if type(run.input) == "string" then
shape.input = T.ref(run.input)
elseif is_schema(run.input) then
shape.input = run.input
else
error(string.format(
"tools.docs.extract: pkg '%s' spec.entries.run.input " ..
"must be a string or an alc_shapes schema (got type '%s')",
pkg_name, type(run.input)), 2)
end
end
if run.result ~= nil then
if type(run.result) == "string" then
shape.result = T.ref(run.result)
elseif is_schema(run.result) then
shape.result = run.result
else
error(string.format(
"tools.docs.extract: pkg '%s' spec.entries.run.result " ..
"must be a string or an alc_shapes schema (got type '%s')",
pkg_name, type(run.result)), 2)
end
end
end
end
local pi = PI.make_pkg_info(identity, narrative, shape)
S.assert_dev(pi, "PkgInfo", pkg_name, { registry = EntitySchemas })
return pi
end
M._internal = {
read_file = read_file,
split_lines = split_lines,
alloc_anchor = alloc_anchor,
is_schema = is_schema,
}
return M