@thi.ng/tangle
Version:
Literate programming code block tangling / codegen utility, inspired by org-mode & noweb
230 lines (229 loc) • 6.74 kB
JavaScript
import { isPlainObject, isString } from "@thi.ng/checks";
import { compareByKey } from "@thi.ng/compare";
import { FMT_ISO_SHORT } from "@thi.ng/date";
import { defError, illegalArgs, illegalState } from "@thi.ng/errors";
import { readText } from "@thi.ng/file-io";
import { split } from "@thi.ng/strings";
import { assocObj, map, transduce } from "@thi.ng/transducers";
import { extname, isAbsolute, resolve, sep } from "node:path";
import {
BLOCK_FORMATS,
COMMENT_FORMATS,
LOGGER
} from "./api.js";
const UnknownBlockError = defError(
(err) => `can't include unknown block ID: ${err[0]} (via ${err[1]})`,
() => ""
);
const __extractBlocks = (src, { format, logger }) => {
let nextID = 0;
const blocks = {};
const prefix = new RegExp(
(isString(format.prefix) ? `^${format.prefix.replace("+", "\\+")}` : format.prefix.source) + `(\\w+)\\s+(.+)$`,
"gm"
);
const suffix = isString(format.suffix) ? new RegExp(`${format.suffix.replace("+", "\\+")}`) : format.suffix;
let matchPrefix;
while (matchPrefix = prefix.exec(src)) {
let { id, tangle, noweb, publish } = __parseBlockHeader(matchPrefix[2]);
!id && (id = `__block-${nextID++}`);
const matchStart = matchPrefix.index;
const start = src.indexOf("\n", matchStart + 1) + 1;
logger.debug(
"codeblock ID:",
id,
"matchStart:",
matchStart,
"start:",
start
);
const matchSuffix = suffix.exec(src.substring(start));
if (!matchSuffix) illegalState("no codeblock end found");
const end = start + matchSuffix.index;
const matchEnd = end + matchSuffix[0].length + 1;
logger.debug(
"codeblock ID:",
id,
"end:",
end,
"matchEnd:",
matchEnd,
matchSuffix[0]
);
const body = src.substring(start, end - 1);
blocks[id] = {
id,
lang: matchPrefix[1],
tangle,
publish,
noweb,
start,
end,
matchStart,
matchEnd,
body: format.xform ? format.xform(body) : body
};
}
return blocks;
};
const __resolveBlock = (block, ref, ctx) => {
if (block.resolved) return;
if (block.noweb === "no") {
block.resolved = true;
return;
}
ctx.logger.debug("resolve", block.id);
const re = /<<(.+)>>/g;
let match;
let body = block.body;
while (match = re.exec(body)) {
const paramIdx = match[1].indexOf(" ");
let [childID, params] = paramIdx > 0 ? [
match[1].substring(0, paramIdx),
JSON.parse(match[1].substring(paramIdx).trim())
] : [match[1]];
let childBlock;
if (childID.indexOf("#") > 0) {
const [file, blockID] = childID.split("#");
childBlock = __loadAndResolveBlocks(
ctx.fs.resolve(ctx.fs.resolve(ref.path, ".."), file),
ctx
).blocks[blockID];
childID = blockID;
} else {
childID = childID.replace("#", "");
childBlock = ref.blocks[childID];
}
if (!childBlock) throw new UnknownBlockError([childID, block.id]);
__resolveBlock(childBlock, ref, ctx);
const newBody = isPlainObject(params) ? __parametricBody(childBlock.body, params) : childBlock.body;
block.body = block.body.replace(`<<${match[1]}>>`, newBody);
block.edited = true;
}
block.resolved = true;
block.body = block.body.replace(/\\</g, "<");
};
const __resolveBlocks = (ref, ctx) => {
for (let id in ref.blocks) {
__resolveBlock(ref.blocks[id], ref, ctx);
}
return ref;
};
const __parseFileMeta = (src) => {
if (!src.startsWith("---\n")) return {};
const res = {};
for (let line of split(src.substring(4))) {
if (line === "---") break;
const [key, val] = line.split(/:\s+/g);
res[key.trim()] = val.trim();
}
return res;
};
const __parseBlockHeader = (header) => transduce(
map((x) => x.split(":")),
assocObj(),
header.split(/\s+/)
);
const __parametricBody = (body, params) => body.replace(
/\{\{(\w+)\}\}/g,
(_, id) => params[id] != null ? params[id] : id
);
const __commentForLang = (lang, body) => {
const syntax = COMMENT_FORMATS[lang];
return isString(syntax) ? `${syntax} ${body}` : `${syntax[0]} ${body} ${syntax[1]}`;
};
const __loadAndResolveBlocks = (path, ctx) => {
path = ctx.fs.resolve(path);
if (!ctx.files[path]) {
const src = ctx.fs.read(path, ctx.logger);
const blocks = __extractBlocks(src, ctx);
const ref = ctx.files[path] = { path, src, blocks };
__resolveBlocks(ref, ctx);
}
return ctx.files[path];
};
const tangleFile = (path, ctx = {}) => {
const fmt = ctx.format || BLOCK_FORMATS[extname(path)];
!fmt && illegalArgs(`unsupported file type: ${extname(path)}`);
const $ctx = {
files: {},
outputs: {},
format: fmt,
logger: LOGGER,
fs: {
isAbsolute,
resolve,
read: readText
},
...ctx,
opts: {
comments: true,
...ctx.opts
}
};
const { path: $path, src, blocks } = __loadAndResolveBlocks(path, $ctx);
const parentDir = $ctx.fs.resolve($path, "..");
const meta = __parseFileMeta(src);
const sorted = Object.values(blocks).sort(compareByKey("start"));
let prev = 0;
let res = [];
for (let block of sorted) {
if (meta.publish) {
res.push(src.substring(prev, Math.max(prev, block.matchStart)));
if (block.publish !== "no") {
res.push(
`${fmt.prefix}${block.lang}
${block.body}
${fmt.suffix}
`
);
}
}
if (block.tangle) {
const dest = $ctx.fs.isAbsolute(block.tangle) ? block.tangle : $ctx.fs.resolve(
parentDir,
`${meta.tangle || "."}${sep}${block.tangle}`
);
let body = block.body;
if (!$ctx.outputs[dest]) {
if ($ctx.opts.comments && COMMENT_FORMATS[block.lang]) {
body = [
__commentForLang(
block.lang,
`Tangled @ ${FMT_ISO_SHORT()} - DO NOT EDIT!`
),
__commentForLang(block.lang, `Source: ${$path}`),
"",
body
].join("\n");
}
$ctx.outputs[dest] = body;
} else {
$ctx.outputs[dest] += "\n\n" + body;
}
}
prev = block.matchEnd;
}
res.push(src.substring(prev));
if (meta.publish) {
const dest = $ctx.fs.resolve(parentDir, meta.publish);
$ctx.outputs[dest] = res.join("").trim();
}
return $ctx;
};
const tangleString = (fileID, files, ctx = {}) => tangleFile(fileID, {
fs: {
isAbsolute: (path) => path[0] === "/" || path[0] === "\\",
resolve: (...path) => path[path.length - 1],
read: (path, logger) => {
logger.debug("reading file ref", path);
const body = files[path];
return body !== void 0 ? body : illegalArgs(`missing file for ref: ${path}`);
}
},
...ctx
});
export {
tangleFile,
tangleString
};