UNPKG

@thi.ng/tangle

Version:

Literate programming code block tangling / codegen utility, inspired by org-mode & noweb

230 lines (229 loc) 6.74 kB
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 };