Files
stack/packages/library/src/reader.mjs
T

271 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The commit reader of design 1.3. It reads a commit of her repo, never a working tree: it lists the
// tree with `git ls-tree -r -z` and reads blobs with `cat-file`, through the same bounded git()
// and gitBytes() as packages/project, so hooks, protocols and the environment are fixed the same
// way. Nothing in the repo is checked out or run (D7). Each R-rule is a problem, never a refusal
// of the commit; the affected file or link is left out of the index.
import { git, gitBytes } from '../../project/src/git.mjs';
import { classify, excluded } from './scope.mjs';
import { extractLinks, isAttachment, parseWikilink, resolver } from './links.mjs';
import { LibraryError, problem, raise, sortProblems } from './problems.mjs';
export const MAX_BLOB = 1024 * 1024;
const MAX_LISTING = 32 * 1024 * 1024;
const SHA = /^[0-9a-f]{40}$/;
const ENTRY = /^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40}) +(-|\d+)\t(.+)$/s;
export const LINK_FIELDS = Object.freeze(['appears_in', 'relationships']);
const READERS = 8;
async function local(promise, message) {
try { return await promise; } catch (e) { raise('library-git', `${message} (${e.code ?? 'error'})`); }
}
export async function headCommit(repo) {
return local(git(repo, ['rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}']), 'repository has no readable HEAD commit');
}
// Every entry of the commit's tree: { mode, type, oid, size, path }. Sizes come from `-l`, so no
// blob is opened to list it.
export async function listTree(repo, commit) {
if (typeof commit !== 'string' || !SHA.test(commit)) raise('library-commit', 'expected a full commit id');
const kind = await local(git(repo, ['cat-file', '-t', commit]), 'commit is not in the repository');
if (kind !== 'commit') raise('library-commit', 'not a commit');
const bytes = await local(gitBytes(repo, ['ls-tree', '-r', '-z', '-l', '--full-tree', commit], MAX_LISTING), 'tree listing refused');
let text;
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
catch { raise('library-git', 'tree listing is not UTF-8'); }
const entries = text.split('\0');
if (entries.pop() !== '') raise('library-git', 'invalid tree listing');
return entries.map(entry => {
const m = ENTRY.exec(entry);
if (!m) raise('library-git', 'invalid tree listing');
return { mode: m[1], type: m[2], oid: m[3], size: m[4] === '-' ? null : Number(m[4]), path: m[5] };
});
}
// R1: only a 100644 blob up to 1 MiB of UTF-8 text without NUL is read.
export const readable = entry => entry.mode === '100644' && entry.type === 'blob'
&& Number.isSafeInteger(entry.size) && entry.size <= MAX_BLOB;
// Text of each readable entry, by path. An entry whose bytes aren't UTF-8 text is missing from the
// map, and the caller reports it under R1.
export async function readTexts(repo, entries) {
const texts = new Map(), queue = [...entries];
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false });
const worker = async () => {
for (let entry = queue.shift(); entry; entry = queue.shift()) {
const bytes = await local(gitBytes(repo, ['cat-file', 'blob', entry.oid], MAX_BLOB), 'blob read refused');
let value;
try { value = decoder.decode(bytes); } catch { continue; }
if (!value.includes('\0')) texts.set(entry.path, value);
}
};
await Promise.all(Array.from({ length: READERS }, worker));
return texts;
}
const QUOTED = /^"(?:[^"\\]|\\.)*"$|^'(?:[^']|'')*'$/;
const PLAIN_START = /^[^\s\[\]{}&*!|>%@`"'#,]/;
function scalar(raw) {
const value = raw.trim();
if (parseWikilink(value)) return { ok: true, value };
if (QUOTED.test(value)) {
if (value[0] === "'") return { ok: true, value: value.slice(1, -1).replace(/''/g, "'") };
try { return { ok: true, value: JSON.parse(value) }; } catch { return { ok: false }; }
}
if (value === '' || value === '~' || value === 'null') return { ok: true, value: null };
if (!PLAIN_START.test(value) || /: |\s#/.test(value) || value.endsWith(':')) return { ok: false };
if (value === 'true' || value === 'false') return { ok: true, value: value === 'true' };
if (/^-?(0|[1-9]\d*)(\.\d+)?$/.test(value)) return { ok: true, value: Number(value) };
return { ok: true, value };
}
function flow(raw) {
const inner = raw.trim().slice(1, -1), items = [];
let current = '', quote = null;
for (const ch of inner) {
if (quote) { current += ch; if (ch === quote) quote = null; continue; }
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
if (ch === '[' || ch === ']' || ch === '{' || ch === '}') return { ok: false };
if (ch === ',') { items.push(current); current = ''; continue; }
current += ch;
}
if (quote) return { ok: false };
if (current.trim() || items.length) items.push(current);
const values = [];
for (const item of items) { const s = scalar(item); if (!s.ok || !item.trim()) return { ok: false }; values.push(s.value); }
return { ok: true, value: values };
}
function value(raw) {
const text = raw.trim();
if (text.startsWith('[') && !parseWikilink(text)) return text.endsWith(']') ? flow(text) : { ok: false };
return scalar(text);
}
// Front matter of design 1.3 (R3): scalars, quoted strings, flow lists and block lists of scalars or
// wikilinks. Anything else, a nested map, a block scalar or an anchor for example, leaves the whole
// front matter as raw text with one R3 problem at the line where parsing stopped.
// Returns { frontmatter, raw, bodyLine, stop } with 1-based line numbers. Parsed front matter also
// returns `itemLines`, each block list key's item lines in order.
export function parseFrontMatter(lines) {
if (lines[0]?.replace(/^/, '').trimEnd() !== '---') return { frontmatter: null, raw: null, bodyLine: 1, stop: null };
const close = lines.findIndex((text, i) => i > 0 && /^(---|\.\.\.)\s*$/.test(text));
if (close < 0) return { frontmatter: null, raw: null, bodyLine: 1, stop: 1 };
const raw = lines.slice(1, close).join('\n'), data = {}, itemLines = {};
let list = null;
for (let i = 1; i < close; i++) {
const text = lines[i], line = i + 1;
if (!text.trim() || /^\s*#/.test(text)) continue;
const item = /^(\s*)- (.*)$|^(\s*)-$/.exec(text);
if (item && list) {
const parsed = scalar(item[2] ?? '');
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
data[list].push(parsed.value);
itemLines[list].push(line);
continue;
}
const pair = /^([^\s:#\-][^:]*?|"[^"]+"|'[^']+')\s*:(?:\s+(.*))?$/.exec(text);
if (!pair) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
const key = pair[1].replace(/^(["'])(.*)\1$/, '$2');
if (Object.hasOwn(data, key) || key === '__proto__') return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
if (pair[2] === undefined || !pair[2].trim()) {
const next = lines[i + 1];
if (i + 1 < close && /^\s*-( |$)/.test(next)) { data[key] = []; itemLines[key] = []; list = key; }
else { data[key] = null; list = null; }
continue;
}
list = null;
const parsed = value(pair[2]);
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
data[key] = parsed.value;
}
return { frontmatter: data, raw: null, bodyLine: close + 2, stop: null, itemLines };
}
export const splitLines = text => text.replace(/^/, '').split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
// One parsed file: its lines (all of them, numbered from 1), front matter and the links it holds.
// Front matter links come from LINK_FIELDS values that are wikilinks; other values are names. A
// link in a block list carries its item's line, one in a scalar or flow value its key's line.
export function parseFile(text, { markdown = false } = {}) {
const lines = splitLines(text);
const fm = parseFrontMatter(lines);
const numbered = lines.map((t, i) => [i + 1, t]);
const links = extractLinks(numbered.slice(fm.bodyLine - 1), { markdown });
if (fm.frontmatter) {
for (const field of LINK_FIELDS) {
const raw = fm.frontmatter[field];
const values = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
const items = fm.itemLines[field];
values.forEach((v, i) => {
const link = typeof v === 'string' ? parseWikilink(v.trim()) : null;
if (link) links.push({ ...link, field, line: items ? items[i] : frontMatterLine(lines, fm.bodyLine, field) });
});
}
}
return { lines: numbered, bodyLine: fm.bodyLine, frontmatter: fm.frontmatter, frontmatterRaw: fm.raw, stop: fm.stop, links };
}
function frontMatterLine(lines, bodyLine, field) {
const i = lines.slice(0, bodyLine - 1).findIndex(t => new RegExp(`^["']?${field}["']?\\s*:`).test(t));
return i < 0 ? undefined : i + 1;
}
// Read a commit under a checked mapping (mapping.mjs). Returns the indexed files and every
// R1-R8 problem: { commit, files: [...], problems: [...] }, frozen, sorted by path.
export async function readLibrary({ repo, commit, mapping }) {
if (!mapping || mapping.kind !== 'library') throw new LibraryError('library-mapping-invalid', 'expected a checked library mapping');
const entries = await listTree(repo, commit);
const problems = [], twins = new Set(), byLower = new Map();
for (const e of entries) byLower.set(e.path.toLowerCase(), [...(byLower.get(e.path.toLowerCase()) ?? []), e.path]);
for (const group of byLower.values()) if (group.length > 1) for (const path of group) twins.add(path);
const candidates = [];
for (const entry of entries) {
const where = classify(mapping, entry.path);
if (where.scope === 'unmapped') { problems.push(problem('R2', entry.path)); continue; }
if (twins.has(entry.path)) { problems.push(problem('R7', entry.path)); continue; }
if (!readable(entry)) { problems.push(problem('R1', entry.path)); continue; }
candidates.push({ entry, where });
}
const texts = await readTexts(repo, candidates.map(c => c.entry));
const files = [];
for (const { entry, where } of candidates) {
if (!texts.has(entry.path)) { problems.push(problem('R1', entry.path)); continue; }
const parsed = parseFile(texts.get(entry.path));
if (parsed.stop !== null) problems.push(problem('R3', entry.path, parsed.stop));
files.push({ path: entry.path, ...where, name: entry.path.split('/').pop().replace(/\.md$/, ''), ...parsed });
}
const resolve = resolver(files.map(f => f.path), { vaultPrefix: mapping.vaultPrefix });
const byPath = new Map(files.map(f => [f.path, f]));
for (const file of files) {
const kept = [];
for (const link of file.links) {
if (!link.file) continue;
const target = resolve(file.path, link.file);
if (!target.path) { if (!isAttachment(link)) problems.push(problem('R4', file.path, link.line)); continue; }
const to = byPath.get(target.path);
if (file.scope === 'canon') {
if (to.scope === 'playground') problems.push(problem('R5', file.path, link.line));
const sources = mapping.universes[file.universe].sources ?? [];
// A hybrid's link into a path its exclude covers is R6 too (C2-15). An excluded path is always in a
// source, so a link gets at most one R6.
if ((to.universe !== file.universe && !sources.includes(to.universe)) || excluded(mapping, file.universe, to.path))
problems.push(problem('R6', file.path, link.line));
}
kept.push({ ...link, path: target.path, step: target.step });
}
file.links = kept;
delete file.stop;
}
problems.push(...pinProblems(mapping, files, byPath));
files.sort((a, b) => a.path < b.path ? -1 : 1);
return deepFreeze({ commit, files, problems: sortProblems(problems) });
}
// R8 (C2-15, design 1.4): each pin of each hybrid. The pinned entity gets R8 when it isn't indexed (absent,
// R1, R2 or R7) or its front matter doesn't hold `field` as a scalar whose text equals String(value); a
// list, null, a missing key and R3 front matter all count as changed. A hybrid canon file is a version of
// the entity when classify gives it the entity's type and its name matches: the trimmed front matter
// `name` on both sides, or the file names when either has no text name. A version that sets `field` to
// another value gets R8 on that key's line. A link alone never makes a version.
function pinProblems(mapping, files, byPath) {
const out = [];
for (const [hybrid, entry] of Object.entries(mapping.universes)) {
if (entry.kind !== 'hybrid') continue;
for (const { pin } of entry.constraints.filter(c => c.pin)) {
const entity = byPath.get(pin.entity);
if (!entity) out.push(problem('R8', pin.entity));
else if (!pinned(entity, pin)) out.push(problem('R8', pin.entity, keyLine(entity, pin.field)));
const type = classify(mapping, pin.entity).type;
const stem = pin.entity.split('/').pop().replace(/\.md$/, '');
const name = entity ? textName(entity) : null;
for (const file of files) {
if (file.universe !== hybrid || file.scope !== 'canon' || file.type !== type) continue;
const own = textName(file);
if ((name !== null && own !== null ? own !== name : file.name !== stem)) continue;
if (file.frontmatter && Object.hasOwn(file.frontmatter, pin.field) && !pinned(file, pin))
out.push(problem('R8', file.path, keyLine(file, pin.field)));
}
}
}
// Two pins (or two hybrids) on one missing or changed entity give one entry per path and line (Filbert N1).
const seen = new Set();
return out.filter(p => { const key = `${p.path}\0${p.line ?? ''}`; return !seen.has(key) && seen.add(key); });
}
const textName = file => typeof file.frontmatter?.name === 'string' && file.frontmatter.name.trim() ? file.frontmatter.name.trim() : null;
function pinned(file, { field, value }) {
const held = file.frontmatter && Object.hasOwn(file.frontmatter, field) ? file.frontmatter[field] : undefined;
return ['string', 'number', 'boolean'].includes(typeof held) && String(held) === String(value);
}
// The front matter line of `field` (bare or quoted key), or undefined.
function keyLine(file, field) {
if (file.bodyLine < 3) return undefined;
const key = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const at = file.lines.slice(1, file.bodyLine - 2).find(([, t]) => new RegExp(`^["']?${key}["']?\\s*:`).test(t));
return at?.[0];
}
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); for (const v of Object.values(value)) deepFreeze(v); }
return value;
}