describe.ts

7.4 KB · raw

1// §21.5: a description is the file's own words, found by syntax alone; no comment means none.2// Every function returns Markdown source (hard-wrapped lines kept) or null.34import MarkdownIt from "markdown-it";56type Style = "bang" | "slash" | "jvm" | "hash" | "python" | "markdown";78const STYLES: Record<string, Style> = {9	zig: "bang",10	rs: "bang",11	py: "python",12	md: "markdown",13	markdown: "markdown",14};15for (const ext of "v go c h cc cpp cxx hpp hh js mjs cjs jsx ts tsx mts cts".split(" ")) {16	STYLES[ext] = "slash";17}18for (const ext of "kt kts java swift".split(" ")) STYLES[ext] = "jvm";19for (const ext of "sh bash zsh nix toml yaml yml".split(" ")) STYLES[ext] = "hash";2021const SHELL_SHEBANG = /^#!.*\b(ba|z|da|k)?sh\b/;22const MODULE_FILES = ["mod.zig", "mod.rs", "index.ts", "__init__.py"];23const README = /^readme\.(md|markdown)$/i;2425/**26 * A license header is the first comment block when it carries an SPDX tag, a copyright line or27 * "licensed under"/"all rights reserved". Only that one block is skipped, so a description that28 * merely mentions a license elsewhere is kept.29 */30const LICENSE =31	/SPDX-License-Identifier|\bcopyright\b|©|\blicensed under\b|\ball rights reserved\b/i;32/**33 * Kotlin, Java and Swift put a file's words on its main declaration, after `package` and34 * `import`; those lines only name the file's place, so they are passed over, not counted as code.35 */36const PREAMBLE = /^(package|import)\s|^@file:/;37// Go build constraints are comments that describe nothing.38const DIRECTIVE = /^\/\/(go:|\s*\+build\b)/;3940// Parsing only: with HTML on, a leading `<!-- -->` or `<p align>` block is not taken for a paragraph.41const markdown = new MarkdownIt({ html: true });4243/** The description of one file from its text, by the kind its name says it is. */44export function describeFile(path: string, text: string): string | null {45	const name = path.split("/").pop() ?? path;46	const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);47	const ext = name.includes(".") ? (name.split(".").pop() as string).toLowerCase() : "";48	const style = STYLES[ext] ?? (ext === "" && SHELL_SHEBANG.test(lines[0] ?? "") ? "hash" : null);49	if (style === "bang") return bangLines(lines);50	if (style === "slash") return slashBlock(lines);51	if (style === "jvm") return slashBlock(lines.filter((line) => !PREAMBLE.test(line.trim())));52	if (style === "hash") return hashBlock(lines);53	if (style === "python") return docstring(lines);54	if (style === "markdown") return firstParagraph(text);55	return null;56}5758/** A folder's description: its README's, else its module file's, given each child's own. */59export function describeFolder(60	children: { name: string; description: string | null }[],61): string | null {62	const readme = children.find((child) => README.test(child.name))?.description;63	const moduleFile = MODULE_FILES.map((name) => children.find((c) => c.name === name)).find(64		Boolean,65	);66	return readme ?? moduleFile?.description ?? null;67}6869/** The first sentence of the first paragraph, with hard-wrapped lines joined: the row's one-liner. */70export function firstSentence(description: string): string {71	const paragraph = (description.trim().split(/\n\s*\n/)[0] as string).replace(/\s*\n\s*/g, " ");72	let inCode = false;73	for (let i = 0; i < paragraph.length; i++) {74		const c = paragraph[i] as string;75		if (c === "`") inCode = !inCode;76		const ends = i + 1 === paragraph.length || /\s/.test(paragraph[i + 1] as string);77		if (!inCode && ".!?".includes(c) && ends && !ABBREVIATION.test(paragraph.slice(0, i + 1))) {78			return paragraph.slice(0, i + 1);79		}80	}81	return paragraph;82}8384const ABBREVIATION = /\b(e\.g|i\.e|etc|vs|cf)\.$/i;8586function joined(lines: string[]): string | null {87	const text = lines.join("\n").trim();88	return text === "" ? null : text;89}9091// Zig and Rust: the `//!` lines at the top, after ordinary comments such as a license header.92function bangLines(lines: string[]): string | null {93	const body: string[] = [];94	for (const raw of lines) {95		const line = raw.trim();96		if (line.startsWith("//!")) body.push(line.replace(/^\/\/! ?/, ""));97		else if (body.length > 0 || !(line === "" || line.startsWith("//"))) break;98	}99	return joined(body);100}101102function slashBlock(lines: string[]): string | null {103	const block = commentBlocks(lines, (line) => line.startsWith("//"), true).find(104		(b) => !b.lines.every((line) => DIRECTIVE.test(line)),105	);106	if (!block) return null;107	if (!block.starred) return joined(block.lines.map((line) => line.replace(/^\/\/[/!]? ?/, "")));108	return joined(109		block.lines.map((line) => line.replace(/\s*\*\/$/, "").replace(/^(\/\*[*!]?|\*(?!\/)) ?/, "")),110	);111}112113// Shell, Nix, TOML, YAML: `#` lines at the top, after a shebang and a license header.114function hashBlock(lines: string[]): string | null {115	const rest = lines[0]?.startsWith("#!") ? lines.slice(1) : lines;116	const block = commentBlocks(rest, (line) => line.startsWith("#"))[0];117	return block ? joined(block.lines.map((line) => line.replace(/^#+ ?/, ""))) : null;118}119120type Block = { lines: string[]; starred: boolean };121122/**123 * The comment blocks before the first line of code, license header dropped. A block is a run of124 * line comments, or with `starred` one `/* … *\/` comment; blank lines separate blocks.125 */126function commentBlocks(127	lines: string[],128	isComment: (trimmed: string) => boolean,129	starred = false,130): Block[] {131	const blocks: Block[] = [];132	let i = 0;133	while (i < lines.length) {134		const line = (lines[i] as string).trim();135		if (line === "") {136			i++;137		} else if (starred && line.startsWith("/*")) {138			const start = i;139			while (i < lines.length && !(lines[i] as string).includes("*/")) i++;140			if (i === lines.length) break;141			const body = lines.slice(start, ++i).map((l) => l.trim());142			blocks.push({ lines: body, starred: true });143			if (!(lines[i - 1] as string).trim().endsWith("*/")) break;144		} else if (isComment(line)) {145			const body: string[] = [];146			while (i < lines.length && isComment((lines[i] as string).trim())) {147				body.push((lines[i++] as string).trim());148			}149			blocks.push({ lines: body, starred: false });150		} else {151			break;152		}153	}154	if (blocks[0] && LICENSE.test(blocks[0].lines.join("\n"))) blocks.shift();155	return blocks;156}157158// Python: the module docstring, the first statement when it is a string; comments may precede it.159function docstring(lines: string[]): string | null {160	let i = 0;161	while (i < lines.length && /^\s*(#.*)?$/.test(lines[i] as string)) i++;162	const open = /^[rRuU]?("""|'''|"|')/.exec(lines[i] ?? "");163	if (!open) return null;164	const quote = open[1] as string;165	const first = (lines[i] as string).slice(open[0].length);166	const body: string[] = [];167	for (let line = first, n = i; n < lines.length; line = lines[++n] ?? "") {168		const close = line.indexOf(quote);169		if (close >= 0) {170			body.push(line.slice(0, close));171			return joined(dedent(body));172		}173		if (quote.length === 1) return null;174		body.push(line);175	}176	return null;177}178179// Like Python's inspect.cleandoc: continuation lines lose their common indent.180function dedent(lines: string[]): string[] {181	const rest = lines.slice(1).filter((line) => line.trim() !== "");182	const indent = Math.min(...rest.map((line) => (/^\s*/.exec(line) as RegExpExecArray)[0].length));183	return [184		(lines[0] as string).trim(),185		...lines.slice(1).map((line) => line.slice(Number.isFinite(indent) ? indent : 0)),186	];187}188189function firstParagraph(text: string): string | null {190	const tokens = markdown.parse(text, {});191	const map = tokens.find((token) => token.type === "paragraph_open" && token.level === 0)?.map;192	return map ? joined(text.split(/\r?\n/).slice(map[0], map[1])) : null;193}