Files
libgit2/script/api-docs/api-generator.js
Edward Thomson 89cc5ef8e8 Include documentation generator
libgit2 has a new documentation generator that generates API schema from
our headers, then produces reference documentation that is included into
the website directly.
2024-11-25 23:00:07 +00:00

1544 lines
43 KiB
JavaScript
Executable File

#!/usr/bin/env node
const path = require('node:path');
const child_process = require('node:child_process');
const fs = require('node:fs').promises;
const util = require('node:util');
const process = require('node:process');
const { program } = require('commander');
const includePath = (p) => `${p}/include`;
const ancientIncludePath = (p) => `${p}/src/git`;
const legacyIncludePath = (p) => `${p}/src/git2`;
const standardIncludePath = (p) => `${includePath(p)}/git2`;
const systemIncludePath = (p) => `${includePath(p)}/git2/sys`;
const fileIgnoreList = [ 'stdint.h', 'inttypes.h' ];
const apiIgnoreList = [ 'GIT_BEGIN_DECL', 'GIT_END_DECL', 'GIT_WIN32' ];
// Some older versions of libgit2 need some help with includes
const defaultIncludes = [
'checkout.h', 'common.h', 'diff.h', 'email.h', 'oidarray.h', 'merge.h', 'remote.h', 'types.h'
];
// We're unable to fully map `types.h` defined types into groups;
// provide some help.
const groupMap = {
'filemode': 'tree',
'treebuilder': 'tree',
'note': 'notes',
'packbuilder': 'pack',
'reference': 'refs',
'push': 'remote' };
async function headerPaths(p) {
const possibleIncludePaths = [
ancientIncludePath(p),
legacyIncludePath(p),
standardIncludePath(p),
systemIncludePath(p)
];
const includePaths = [ ];
const paths = [ ];
for (const possibleIncludePath of possibleIncludePaths) {
try {
await fs.stat(possibleIncludePath);
includePaths.push(possibleIncludePath);
}
catch (e) {
if (e?.code !== 'ENOENT') {
throw e;
}
}
}
if (!includePaths.length) {
throw new Error(`no include paths for ${p}`);
}
for (const fullPath of includePaths) {
paths.push(...(await fs.readdir(fullPath)).
filter((filename) => filename.endsWith('.h')).
filter((filename) => !fileIgnoreList.includes(filename)).
map((filename) => `${fullPath}/${filename}`));
}
return paths;
}
function trimPath(basePath, headerPath) {
const possibleIncludePaths = [
ancientIncludePath(basePath),
legacyIncludePath(basePath),
standardIncludePath(basePath),
systemIncludePath(basePath)
];
for (const possibleIncludePath of possibleIncludePaths) {
if (headerPath.startsWith(possibleIncludePath + '/')) {
return headerPath.substr(possibleIncludePath.length + 1);
}
}
throw new Error("header path is not beneath include root");
}
function parseFileAst(path, ast) {
let currentFile = undefined;
const fileData = [ ];
for (const node of ast.inner) {
if (node.loc?.file && currentFile != node.loc.file) {
currentFile = node.loc.file;
} else if (node.loc?.spellingLoc?.file && currentFile != node.loc.spellingLoc.file) {
currentFile = node.loc.spellingLoc.file;
}
if (currentFile != path) {
continue;
}
fileData.push(node);
}
return fileData;
}
function includeBase(path) {
const segments = path.split('/');
while (segments.length > 1) {
if (segments[segments.length - 1] === 'git2' ||
segments[segments.length - 1] === 'git') {
segments.pop();
return segments.join('/');
}
segments.pop();
}
throw new Error(`could not resolve include base for ${path}`);
}
function readAst(path, options) {
return new Promise((resolve, reject) => {
let errorMessage = '';
const chunks = [ ];
const processArgs = [ path, '-Xclang', '-ast-dump=json', `-I${includeBase(path)}` ];
if (options?.deprecateHard) {
processArgs.push(`-DGIT_DEPRECATE_HARD`);
}
if (options?.includeFiles) {
for (const file of options.includeFiles) {
processArgs.push(`-include`);
processArgs.push(file)
}
}
const process = child_process.spawn('clang', processArgs);
process.stderr.on('data', (message) => {
errorMessage += message;
});
process.stdout.on('data', (chunk) => {
chunks.push(chunk);
});
process.on('close', (code) => {
if (code != 0 && options.strict) {
reject(new Error(`clang exit code ${code}: ${errorMessage}`));
}
else if (code != 0) {
resolve([ ]);
}
else {
const ast = JSON.parse(Buffer.concat(chunks).toString());
resolve(parseFileAst(path, ast));
}
});
process.on('error', function (err) {
reject(err);
});
});
}
async function readFile(path) {
const buf = await fs.readFile(path);
return buf.toString();
}
function ensure(message, test) {
if (!test) {
throw new Error(message);
}
}
function ensureDefined(name, value) {
if (!value) {
throw new Error(`could not find ${name} for declaration`);
}
return value;
}
function groupifyId(location, id) {
if (!id) {
throw new Error(`could not find id in declaration`);
}
if (!location || !location.file) {
throw new Error(`unspecified location`);
}
return `${location.file}-${id}`;
}
function blockCommentText(block) {
ensure('block does not have a single paragraph element', block.inner.length === 1 && block.inner[0].kind === 'ParagraphComment');
return commentText(block.inner[0]);
}
function richBlockCommentText(block) {
ensure('block does not have a single paragraph element', block.inner.length === 1 && block.inner[0].kind === 'ParagraphComment');
return richCommentText(block.inner[0]);
}
function paramCommentText(param) {
ensure('param does not have a single paragraph element', param.inner.length === 1 && param.inner[0].kind === 'ParagraphComment');
return richCommentText(param.inner[0]);
}
function appendCommentText(chunk) {
return chunk.startsWith(' ') ? "\n" + chunk : chunk;
}
function commentText(para) {
let text = '';
for (const comment of para.inner) {
// docbook allows backslash escaped text, and reports it differently.
// we restore the literal `\`.
if (comment.kind === 'InlineCommandComment') {
text += `\\${comment.name}`;
}
else if (comment.kind === 'TextComment') {
text += text ? "\n" + comment.text : comment.text;
} else {
throw new Error(`unknown paragraph comment element: ${comment.kind}`);
}
}
return text.trim();
}
function nextText(para, idx) {
if (!para.inner[idx + 1] || para.inner[idx + 1].kind !== 'TextComment') {
throw new Error("expected text comment");
}
return para.inner[idx + 1].text;
}
function inlineCommandData(data, command) {
ensure(`${command} information does not follow @${command}`, data?.kind === 'TextComment');
const result = data.text.match(/^(?:\[([^\]]+)\])? ((?:[a-zA-Z0-9\_]+)|`[a-zA-Z0-9\_\* ]+`)(.*)/);
ensure(`${command} data does not follow @${command}`, result);
const [ , attr, spec, remain ] = result;
return [ attr, spec.replace(/^`(.*)`$/, "$1"), remain ]
}
function richCommentText(para) {
let text = '';
let extendedType = undefined;
let subkind = undefined;
let versionMacro = undefined;
let initMacro = undefined;
let initFunction = undefined;
let lastComment = undefined;
for (let i = 0; i < para.inner?.length; i++) {
const comment = para.inner[i];
if (comment.kind === 'InlineCommandComment' &&
comment.name === 'type') {
const [ attr, data, remain ] = inlineCommandData(para.inner[++i], "type");
extendedType = { kind: attr, type: data };
text += remain;
}
else if (comment.kind === 'InlineCommandComment' &&
comment.name === 'flags') {
subkind = 'flags';
}
else if (comment.kind === 'InlineCommandComment' &&
comment.name === 'options') {
const [ attr, data, remain ] = inlineCommandData(para.inner[++i], "options");
if (attr === 'version') {
versionMacro = data;
}
else if (attr === 'init_macro') {
initMacro = data;
}
else if (attr === 'init_function') {
initFunction = data;
}
subkind = 'options';
text += remain;
}
// docbook allows backslash escaped text, and reports it differently.
// we restore the literal `\`.
else if (comment.kind === 'InlineCommandComment') {
text += `\\${comment.name}`;
}
else if (comment.kind === 'TextComment') {
// clang oddity: it breaks <things in brackets> into two
// comment blocks, assuming that the trailing > should be a
// blockquote newline sort of thing. unbreak them.
if (comment.text.startsWith('>') &&
lastComment &&
lastComment.loc.offset + lastComment.text.length === comment.loc.offset) {
text += comment.text;
} else {
text += text ? "\n" + comment.text : comment.text;
}
}
else if (comment.kind === 'HTMLStartTagComment' && comment.name === 'p') {
text += "\n";
}
else {
throw new Error(`unknown paragraph comment element: ${comment.kind}`);
}
lastComment = comment;
}
return {
text: text.trim(),
extendedType: extendedType,
subkind: subkind,
versionMacro: versionMacro,
initMacro: initMacro,
initFunction: initFunction
}
}
function join(arr, elem) {
if (arr) {
return [ ...arr, elem ];
}
return [ elem ];
}
function joinIfNotEmpty(arr, elem) {
if (!elem || elem === '') {
return arr;
}
if (arr) {
return [ ...arr, elem ];
}
return [ elem ];
}
function pushIfNotEmpty(arr, elem) {
if (elem && elem !== '') {
arr.push(elem);
}
}
function single(arr, fn, message) {
let result = undefined;
if (!arr) {
return undefined;
}
for (const match of arr.filter(fn)) {
if (result) {
throw new Error(`multiple matches in array for ${fn}${message ? ' (' + message + ')': ''}`);
}
result = match;
}
return result;
}
function updateLocation(location, decl) {
location.file = trimBase(decl.loc?.spellingLoc?.file || decl.loc?.file) || location.file;
location.line = decl.loc?.spellingLoc?.line || decl.loc?.line || location.line;
location.column = decl.loc?.spellingLoc?.col || decl.loc?.col || location.column;
return location;
}
async function readFileLocation(startLocation, endLocation) {
if (startLocation.file != endLocation.file) {
throw new Error("cannot read across files");
}
const data = await fs.readFile(startLocation.file, "utf8");
const lines = data.split(/\r?\n/).slice(startLocation.line - 1, endLocation.line);
lines[lines.length - 1] = lines[lines.length - 1].slice(0, endLocation.column);
lines[0] = lines[0].slice(startLocation.column - 1);
return lines
}
function formatLines(lines) {
let result = "";
let continuation = false;
for (const i in lines) {
if (!continuation) {
lines[i] = lines[i].trimStart();
}
continuation = lines[i].endsWith("\\");
if (continuation) {
lines[i] = lines[i].slice(0, -1);
} else {
lines[i] = lines[i].trimEnd();
}
result += lines[i];
}
if (continuation) {
throw new Error("unterminated literal continuation");
}
return result;
}
async function parseExternalRange(location, range) {
const startLocation = {...location};
startLocation.file = trimBase(range.begin.spellingLoc.file || startLocation.file);
startLocation.line = range.begin.spellingLoc.line || startLocation.line;
startLocation.column = range.begin.spellingLoc.col || startLocation.column;
const endLocation = {...startLocation};
endLocation.file = trimBase(range.end.spellingLoc.file || endLocation.file);
endLocation.line = range.end.spellingLoc.line || endLocation.line;
endLocation.column = range.end.spellingLoc.col || endLocation.column;
const lines = await readFileLocation(startLocation, endLocation);
return formatLines(lines);
}
async function parseLiteralRange(location, range) {
const startLocation = updateLocation({...location}, { loc: range.begin });
const endLocation = updateLocation({...location}, { loc: range.end });
const lines = await readFileLocation(startLocation, endLocation);
return formatLines(lines);
}
async function parseRange(location, range) {
return range.begin.spellingLoc ? parseExternalRange(location, range) : parseLiteralRange(location, range);
}
class ParserError extends Error {
constructor(message, location) {
if (!location) {
super(`${message} at (unknown)`);
}
else {
super(`${message} at ${location.file}:${location.line}`);
}
this.name = 'ParserError';
}
}
function validateParsing(test, message, location) {
if (!test) {
throw new ParserError(message, location);
}
}
function parseComment(spec, location, comment, options) {
let result = { };
let last = undefined;
for (const c of comment.inner.filter(c => c.kind === 'ParagraphComment' || c.kind === 'VerbatimLineComment')) {
if (c.kind === 'ParagraphComment') {
const commentData = richCommentText(c);
result.comment = joinIfNotEmpty(result.comment, commentData.text);
delete commentData.text;
result = { ...result, ...commentData };
}
else if (c.kind === 'VerbatimLineComment') {
result.comment = joinIfNotEmpty(result.comment, c.text.trim());
}
else {
throw new Error(`unknown comment ${c.kind}`);
}
}
for (const c of comment.inner.filter(c => c.kind !== 'ParagraphComment' && c.kind !== 'VerbatimLineComment')) {
if (c.kind === 'BlockCommandComment' && c.name === 'see') {
result.see = joinIfNotEmpty(result.see, blockCommentText(c));
}
else if (c.kind === 'BlockCommandComment' && c.name === 'note') {
result.notes = joinIfNotEmpty(result.notes, blockCommentText(c));
}
else if (c.kind === 'BlockCommandComment' && c.name === 'deprecated') {
result.deprecations = joinIfNotEmpty(result.deprecations, blockCommentText(c));
}
else if (c.kind === 'BlockCommandComment' && c.name === 'warning') {
result.warnings = joinIfNotEmpty(result.warnings, blockCommentText(c));
}
else if (c.kind === 'BlockCommandComment' &&
(c.name === 'return' || (c.name === 'returns' && !options.strict))) {
const returnData = richBlockCommentText(c);
result.returns = {
extendedType: returnData.extendedType,
comment: returnData.text
};
}
else if (c.kind === 'ParamCommandComment') {
ensure('param has a name', c.param);
const paramDetails = paramCommentText(c);
result.params = join(result.params, {
name: c.param,
direction: c.direction,
values: paramDetails.type,
extendedType: paramDetails.extendedType,
comment: paramDetails.text
});
}
else if (options.strict) {
if (c.kind === 'BlockCommandComment') {
throw new ParserError(`unknown block command comment ${c.name}`, location);
}
else if (c.kind === 'VerbatimBlockComment') {
throw new Error(`unknown verbatim command comment ${c.name}`, location);
}
else {
throw new Error(`unknown comment ${c.kind} in ${kind}`);
}
}
}
return result;
}
async function parseFunction(location, decl, options) {
let result = {
kind: 'function',
id: groupifyId(location, decl.id),
name: ensureDefined('name', decl.name),
location: {...location}
};
// prototype
const [ , returnType, ] = decl.type.qualType.match(/(.*?)(?: )?\((.*)\)$/) || [ ];
ensureDefined('return type declaration', returnType);
result.returns = { type: returnType };
for (const paramDecl of decl.inner.filter(attr => attr.kind === 'ParmVarDecl')) {
updateLocation(location, paramDecl);
const inner = paramDecl.inner || [];
const innerLocation = {...location};
let paramAnnotations = undefined;
for (const annotateDecl of inner.filter(attr => attr.kind === 'AnnotateAttr')) {
updateLocation(innerLocation, annotateDecl);
paramAnnotations = join(paramAnnotations, await parseRange(innerLocation, annotateDecl.range));
}
result.params = join(result.params, {
name: paramDecl.name,
type: paramDecl.type.qualType,
annotations: paramAnnotations
});
}
// doc comment
const commentText = single(decl.inner, (attr => attr.kind === 'FullComment'));
if (commentText) {
const commentData = parseComment(`function:${decl.name}`, location, commentText, options);
if (result.params) {
if (options.strict && (!commentData.params || result.params.length > commentData.params.length)) {
throw new ParserError(`not all params are documented`, location);
}
if (options.strict && result.params.length < commentData.params.length) {
throw new ParserError(`additional params are documented`, location);
}
}
if (commentData.params) {
for (const i in result.params) {
let match;
for (const j in commentData.params) {
if (result.params[i].name === commentData.params[j].name) {
match = j;
break;
}
}
if (options.strict && (!match || match != i)) {
throw new ParserError(
`param documentation does not match param name '${result.params[i].name}'`,
location);
}
if (match) {
result.params[i] = { ...result.params[i], ...commentData.params[match] };
}
}
} else if (options.strict && result.params) {
throw new ParserError(`no params documented for ${decl.name}`, location);
}
if (options.strict && !commentData.returns && result.returns.type != 'void') {
throw new ParserError(`return information is not documented for ${decl.name}`, location);
}
result.returns = { ...result.returns, ...commentData.returns };
delete commentData.params;
delete commentData.returns;
result = { ...result, ...commentData };
}
else if (options.strict) {
throw new ParserError(`no documentation for function ${decl.name}`, location);
}
return result;
}
function parseEnum(location, decl, options) {
let result = {
kind: 'enum',
id: groupifyId(location, decl.id),
name: decl.name,
referenceName: decl.name ? `enum ${decl.name}` : undefined,
members: [ ],
comment: undefined,
location: {...location}
};
for (const member of decl.inner.filter(attr => attr.kind === 'EnumConstantDecl')) {
ensure('enum constant has a name', member.name);
const explicitValue = single(member.inner, (attr => attr.kind === 'ConstantExpr'));
const commentText = single(member.inner, (attr => attr.kind === 'FullComment'));
const commentData = commentText ? parseComment(`enum:${decl.name}:member:${member.name}`, location, commentText, options) : undefined;
result.members.push({
name: member.name,
value: explicitValue ? explicitValue.value : undefined,
...commentData
});
}
const commentText = single(decl.inner, (attr => attr.kind === 'FullComment'));
if (commentText) {
result = { ...result, ...parseComment(`enum:${decl.name}`, location, commentText, options) };
}
return result;
}
function resolveFunctionPointerTypedef(location, typedef) {
const signature = typedef.type.match(/^((?:const )?[^\s]+(?:\s+\*+)?)\s*\(\*\)\((.*)\)$/);
const [ , returnType, paramData ] = signature;
const params = paramData.split(/,\s+/);
if (options.strict && (!typedef.params || params.length != typedef.params.length)) {
throw new ParserError(`not all params are documented for function pointer typedef ${typedef.name}`, typedef.location);
}
if (!typedef.params) {
typedef.params = [ ];
}
for (const i in params) {
if (!typedef.params[i]) {
typedef.params[i] = { };
}
typedef.params[i].type = params[i];
}
if (typedef.returns === undefined && returnType === 'void') {
typedef.returns = { type: 'void' };
}
else if (typedef.returns !== undefined) {
typedef.returns.type = returnType;
}
else if (options.strict) {
throw new ParserError(`return type is not documented for function pointer typedef ${typedef.name}`, typedef.location);
}
}
function parseTypedef(location, decl, options) {
updateLocation(location, decl);
let result = {
kind: 'typedef',
id: groupifyId(location, decl.id),
name: ensureDefined('name', decl.name),
type: ensureDefined('type.qualType', decl.type.qualType),
targetId: undefined,
comment: undefined,
location: {...location}
};
const elaborated = single(decl.inner, (attr => attr.kind === 'ElaboratedType'));
if (elaborated !== undefined && elaborated.ownedTagDecl?.id) {
result.targetId = groupifyId(location, elaborated.ownedTagDecl?.id);
}
const commentText = single(decl.inner, (attr => attr.kind === 'FullComment'));
if (commentText) {
const commentData = parseComment(`typedef:${decl.name}`, location, commentText, options);
result = { ...result, ...commentData };
}
if (isFunctionPointer(result.type)) {
resolveFunctionPointerTypedef(location, result);
}
return result;
}
function parseStruct(location, decl, options) {
let result = {
kind: 'struct',
id: groupifyId(location, decl.id),
name: decl.name,
referenceName: decl.name ? `struct ${decl.name}` : undefined,
comment: undefined,
members: [ ],
location: {...location}
};
for (const member of decl.inner.filter(attr => attr.kind === 'FieldDecl')) {
let memberData = {
'name': member.name,
'type': member.type.qualType
};
const commentText = single(member.inner, (attr => attr.kind === 'FullComment'));
if (commentText) {
memberData = {...memberData, ...parseComment(`struct:${decl.name}:member:${member.name}`, location, commentText, options)};
}
result.members.push(memberData);
}
const commentText = single(decl.inner, (attr => attr.kind === 'FullComment'));
if (commentText) {
const commentData = parseComment(`struct:${decl.name}`, location, commentText, options);
result = { ...result, ...commentData };
}
return result;
}
function newResults() {
return {
all: [ ],
functions: [ ],
enums: [ ],
typedefs: [ ],
structs: [ ],
macros: [ ]
};
};
const returnMap = { };
const paramMap = { };
function simplifyType(givenType) {
let type = givenType;
if (type.startsWith('const ')) {
type = type.substring(6);
}
while (type.endsWith('*') && type !== 'void *' && type !== 'char *') {
type = type.substring(0, type.length - 1).trim();
}
if (!type.length) {
throw new Error(`invalid type: ${result.returns.extendedType || result.returns.type}`);
}
return type;
}
function createAndPush(arr, name, value) {
if (!arr[name]) {
arr[name] = [ ];
}
if (arr[name].length && arr[name][arr[name].length - 1] === value) {
return;
}
arr[name].push(value);
}
function addReturn(result) {
if (!result.returns) {
return;
}
let type = simplifyType(result.returns.extendedType?.type || result.returns.type);
createAndPush(returnMap, type, result.name);
}
function addParameters(result) {
if (!result.params) {
return;
}
for (const param of result.params) {
let type = param.extendedType?.type || param.type;
if (!type && options.strict) {
throw new Error(`parameter ${result.name} erroneously documented when not specified`);
} else if (!type) {
continue;
}
type = simplifyType(type);
if (param.direction === 'out') {
createAndPush(returnMap, type, result.name);
}
else {
createAndPush(paramMap, type, result.name);
}
}
}
function addResult(results, result) {
results[`${result.kind}s`].push(result);
results.all.push(result);
addReturn(result);
addParameters(result);
}
function mergeResults(one, two) {
const results = newResults();
for (const inst of Object.keys(results)) {
results[inst].push(...one[inst]);
results[inst].push(...two[inst]);
}
return results;
}
function getById(results, id) {
ensure("id is set", id !== undefined);
return single(results.all.all, (item => item.id === id), id);
}
function getByKindAndName(results, kind, name) {
ensure("kind is set", kind !== undefined);
ensure("name is set", name !== undefined);
return single(results.all[`${kind}s`], (item => item.name === name), name);
}
function getByName(results, name) {
ensure("name is set", name !== undefined);
return single(results.all.all, (item => item.name === name), name);
}
function isFunctionPointer(type) {
return type.match(/^(?:const )?[A-Za-z0-9_]+\s+\**\(\*/);
}
function resolveCallbacks(results) {
// expand callback types
for (const fn of results.all.functions) {
for (const param of fn.params || [ ]) {
const typedef = getByName(results, param.type);
if (typedef === undefined) {
continue;
}
param.referenceType = typedef.type;
}
}
for (const struct of results.all.structs) {
for (const member of struct.members) {
const typedef = getByKindAndName(results, 'typedef', member.type);
if (typedef === undefined) {
continue;
}
member.referenceType = typedef.type;
}
}
}
function trimBase(path) {
if (!path) {
return path;
}
for (const segment of [ 'git2', 'git' ]) {
const base = [ includeBase(path), segment ].join('/');
if (path.startsWith(base + '/')) {
return path.substr(base.length + 1);
}
}
throw new Error(`header path ${path} is not beneath standard root`);
}
function resolveTypedefs(results) {
for (const typedef of results.all.typedefs) {
let target = typedef.targetId ? getById(results, typedef.targetId) : undefined;
if (target) {
// update the target's preferred name with the short name
target.referenceName = typedef.name;
if (target.name === undefined) {
target.name = typedef.name;
}
}
else if (typedef.type.startsWith('struct ')) {
const path = typedef.location.file;
/*
* See if this is actually a typedef to a declared struct,
* then it is not actually opaque.
*/
if (results.all.structs.filter(fn => fn.name === typedef.name).length > 0) {
typedef.opaque = false;
continue;
}
opaque = {
kind: 'struct',
id: groupifyId(typedef.location, typedef.id),
name: typedef.name,
referenceName: typedef.type,
opaque: true,
comment: typedef.comment,
location: typedef.location,
group: typedef.group
};
addResult(results.files[path], opaque);
addResult(results.all, opaque);
}
else if (isFunctionPointer(typedef.type) ||
typedef.type === 'int64_t' ||
typedef.type === 'uint64_t') {
// standard types
// TODO : make these a list
}
else {
typedef.kind = 'alias';
typedef.typedef = true;
}
}
}
function lastCommentIsGroupDelimiter(decls) {
if (decls[decls.length - 1].inner &&
decls[decls.length - 1].inner.length > 0) {
return lastCommentIsGroupDelimiter(decls[decls.length - 1].inner);
}
if (decls.length >= 2 &&
decls[decls.length - 1].kind.endsWith('Comment') &&
decls[decls.length - 2].kind.endsWith('Comment') &&
decls[decls.length - 2].text === '@' &&
decls[decls.length - 1].text === '{') {
return true;
}
return false;
}
async function parseAst(decls, options) {
const location = {
file: undefined,
line: undefined,
column: undefined
};
const results = newResults();
/* The first decl might have picked up the javadoc _for the file
* itself_ based on the file's structure. Remove it.
*/
if (decls.length && decls[0].inner &&
decls[0].inner.length > 0 &&
decls[0].inner[0].kind === 'FullComment' &&
lastCommentIsGroupDelimiter(decls[0].inner[0].inner)) {
updateLocation(location, decls[0]);
delete decls[0].inner[0];
}
for (const decl of decls) {
updateLocation(location, decl);
ensureDefined('kind', decl.kind);
if (decl.kind === 'FunctionDecl') {
addResult(results, await parseFunction({...location}, decl, options));
}
else if (decl.kind === 'EnumDecl') {
addResult(results, parseEnum({...location}, decl, options));
}
else if (decl.kind === 'TypedefDecl') {
addResult(results, parseTypedef({...location}, decl, options));
}
else if (decl.kind === 'RecordDecl' && decl.tagUsed === 'struct') {
if (decl.completeDefinition) {
addResult(results, parseStruct({...location}, decl, options));
}
}
else if (decl.kind === 'VarDecl') {
if (options.strict) {
throw new Error(`unsupported variable declaration ${decl.kind}`);
}
}
else {
throw new Error(`unknown declaration type ${decl.kind}`);
}
}
return results;
}
function parseCommentForMacro(lines, macroIdx, name) {
let startIdx = -1, endIdx = 0;
const commentLines = [ ];
while (macroIdx > 0 &&
(line = lines[macroIdx - 1].trim()) &&
(line.trim() === '' ||
line.trim().endsWith('\\') ||
line.trim().match(/^#\s*if\s+/) ||
line.trim().startsWith('#ifdef ') ||
line.trim().startsWith('#ifndef ') ||
line.trim().startsWith('#elif ') ||
line.trim().startsWith('#else ') ||
line.trim().match(/^#\s*define\s+${name}\s+/))) {
macroIdx--;
}
if (macroIdx > 0 && lines[macroIdx - 1].trim().endsWith('*/')) {
endIdx = macroIdx - 1;
} else {
return '';
}
for (let i = endIdx; i >= 0; i--) {
if (lines[i].trim().startsWith('/**')) {
startIdx = i;
break;
}
else if (lines[i].trim().startsWith('/*')) {
break;
}
}
if (startIdx < 0) {
return '';
}
for (let i = startIdx; i <= endIdx; i++) {
let line = lines[i].trim();
if (i == startIdx) {
line = line.replace(/^\s*\/\*\*\s*/, '');
}
if (i === endIdx) {
line = line.replace(/\s*\*\/\s*$/, '');
}
if (i != startIdx) {
line = line.replace(/^\s*\*\s*/, '');
}
if (i == startIdx && (line === '@{' || line.startsWith("@{ "))) {
return '';
}
if (line === '') {
continue;
}
commentLines.push(line);
}
return commentLines.join(' ');
}
async function parseInfo(data) {
const fileHeader = data.match(/(.*)\n+GIT_BEGIN_DECL.*/s);
const headerLines = fileHeader ? fileHeader[1].split(/\n/) : [ ];
let lines = [ ];
const detailsLines = [ ];
let summary = undefined;
let endIdx = headerLines.length - 1;
for (let i = headerLines.length - 1; i >= 0; i--) {
let line = headerLines[i].trim();
if (line.match(/^\s*\*\/\s*$/)) {
endIdx = i;
}
if (line.match(/^\/\*\*(\s+.*)?$/)) {
lines = headerLines.slice(i + 1, endIdx);
break;
}
else if (line.match(/^\/\*(\s+.*)?$/)) {
break;
}
}
for (let line of lines) {
line = line.replace(/^\s\*/, '');
line = line.trim();
const comment = line.match(/^\@(\w+|{)\s*(.*)/);
if (comment) {
if (comment[1] === 'brief') {
summary = comment[2];
}
}
else if (line != '') {
detailsLines.push(line);
}
}
const details = detailsLines.length > 0 ? detailsLines.join("\n") : undefined;
return {
'summary': summary,
'details': details
};
}
async function parseMacros(path, data, options) {
const results = newResults();
const lines = data.split(/\r?\n/);
const macros = { };
for (let i = 0; i < lines.length; i++) {
const macro = lines[i].match(/^(\s*#\s*define\s+)([^\s\(]+)(\([^\)]+\))?\s*(.*)/);
let more = false;
if (!macro) {
continue;
}
let [ , prefix, name, args, value ] = macro;
if (name.startsWith('INCLUDE_') || name.startsWith('_INCLUDE_')) {
continue;
}
if (args) {
name = name + args;
}
if (macros[name]) {
continue;
}
macros[name] = true;
value = value.trim();
if (value.endsWith('\\')) {
value = value.substring(0, value.length - 1).trim();
more = true;
}
while (more) {
more = false;
let line = lines[++i];
if (line.endsWith('\\')) {
line = line.substring(0, line.length - 1);
more = true;
}
value += ' ' + line.trim();
}
const comment = parseCommentForMacro(lines, i, name);
const location = {
file: path,
line: i + 1,
column: prefix.length + 1,
};
if (options.strict && !comment) {
throw new ParserError(`no comment for ${name}`, location);
}
addResult(results, {
kind: 'macro',
name: name,
location: location,
value: value,
comment: comment,
});
}
return results;
}
function resolveUngroupedTypes(results) {
const groups = { };
for (const result of results.all.all) {
result.group = result.location.file;
if (result.group.endsWith('.h')) {
result.group = result.group.substring(0, result.group.length - 2);
groups[result.group] = true;
}
}
for (const result of results.all.all) {
if (result.location.file === 'types.h' &&
result.name.startsWith('git_')) {
let possibleGroup = result.name.substring(4);
do {
if (groupMap[possibleGroup]) {
result.group = groupMap[possibleGroup];
break;
}
else if (groups[possibleGroup]) {
result.group = possibleGroup;
break;
}
else if (groups[`sys/${possibleGroup}`]) {
result.group = `sys/${possibleGroup}`;
break;
}
let match = possibleGroup.match(/^(.*)_[^_]+$/);
if (!match) {
break;
}
possibleGroup = match[1];
} while (true);
}
}
}
function resolveReturns(results) {
for (const result of results.all.all) {
result.returnedBy = returnMap[result.name];
}
}
function resolveParameters(results) {
for (const result of results.all.all) {
result.parameterTo = paramMap[result.name];
}
}
async function parseHeaders(sourcePath, options) {
const results = { all: newResults(), files: { } };
for (const fullPath of await headerPaths(sourcePath)) {
const path = trimPath(sourcePath, fullPath);
const fileContents = await readFile(fullPath);
const ast = await parseAst(await readAst(fullPath, options), options);
const macros = await parseMacros(path, fileContents, options);
const info = await parseInfo(fileContents);
const filedata = mergeResults(ast, macros);
filedata['info'] = info;
results.files[path] = filedata;
results.all = mergeResults(results.all, filedata);
}
resolveCallbacks(results);
resolveTypedefs(results);
resolveUngroupedTypes(results);
resolveReturns(results);
resolveParameters(results);
return results;
}
function isFunctionPointer(type) {
return type.match(/^(const\s+)?[A-Za-z0-9_]+\s+\*?\(\*/);
}
function isEnum(type) {
return type.match(/^enum\s+/);
}
function isStruct(type) {
return type.match(/^struct\s+/);
}
/*
* We keep the `all` arrays around so that we can lookup; drop them
* for the end result.
*/
function simplify(results) {
const simplified = {
'info': { },
'groups': { }
};
results.all.all.sort((a, b) => {
if (!a.group) {
throw new Error(`missing group for api ${a.name}`);
}
if (!b.group) {
throw new Error(`missing group for api ${b.name}`);
}
const aSystem = a.group.startsWith('sys/');
const aName = aSystem ? a.group.substr(4) : a.group;
const bSystem = b.group.startsWith('sys/');
const bName = bSystem ? b.group.substr(4) : b.group;
if (aName !== bName) {
return aName.localeCompare(bName);
}
if (aSystem !== bSystem) {
return aSystem ? 1 : -1;
}
if (a.location.file !== b.location.file) {
return a.location.file.localeCompare(b.location.file);
}
if (a.location.line !== b.location.line) {
return a.location.line - b.location.line;
}
return a.location.column - b.location.column;
});
for (const api of results.all.all) {
delete api.id;
delete api.targetId;
const type = api.referenceType || api.type;
if (api.kind === 'typedef' && isFunctionPointer(type)) {
api.kind = 'callback';
api.typedef = true;
}
else if (api.kind === 'typedef' && (!isEnum(type) && !isStruct(type))) {
api.kind = 'alias';
api.typedef = true;
}
else if (api.kind === 'typedef') {
continue;
}
if (apiIgnoreList.includes(api.name)) {
continue;
}
// TODO: do a warning where there's a redefinition of a symbol
// There are occasions where we redefine a symbol. First, our
// parser is not smart enough to know #ifdef's around #define's.
// But also we declared `git_email_create_from_diff` twice (in
// email.h and sys/email.h) for several releases.
if (!simplified['groups'][api.group]) {
simplified['groups'][api.group] = { };
simplified['groups'][api.group].apis = { };
simplified['groups'][api.group].info = results.files[`${api.group}.h`].info;
}
simplified['groups'][api.group].apis[api.name] = api;
}
return simplified;
}
function joinArguments(next, previous) {
if (previous) {
return [...previous, next];
}
return [next];
}
async function findIncludes() {
const includes = [ ];
for (const possible of defaultIncludes) {
const includeFile = `${docsPath}/include/git2/${possible}`;
try {
await fs.stat(includeFile);
includes.push(`git2/${possible}`);
}
catch (e) {
if (e?.code !== 'ENOENT') {
throw e;
}
}
}
return includes;
}
async function execGit(path, command) {
const process = child_process.spawn('git', command, { cwd: path });
const chunks = [ ];
return new Promise((resolve, reject) => {
process.stdout.on('data', (chunk) => {
chunks.push(chunk);
});
process.on('close', (code) => {
resolve(code == 0 ? Buffer.concat(chunks).toString() : undefined);
});
process.on('error', function (err) {
reject(err);
});
});
}
async function readMetadata(path) {
let commit = await execGit(path, [ 'rev-parse', 'HEAD' ]);
if (commit) {
commit = commit.trimEnd();
}
let version = await execGit(path, [ 'describe', '--tags', '--exact' ]);
if (!version) {
const ref = await execGit(path, [ 'describe', '--all', '--exact' ]);
if (ref && ref.startsWith('heads/')) {
version = ref.substr(6);
}
}
if (version) {
version = version.trimEnd();
}
return {
'version': version,
'commit': commit
};
}
program.option('--output <filename>')
.option('--include <filename>', undefined, joinArguments)
.option('--no-includes')
.option('--deprecate-hard')
.option('--strict');
program.parse();
const options = program.opts();
if (program.args.length != 1) {
console.error(`usage: ${path.basename(process.argv[1])} docs`);
process.exit(1);
}
const docsPath = program.args[0];
if (options['include'] && !options['includes']) {
console.error(`usage: cannot combined --include with --no-include`);
process.exit(1);
}
(async () => {
try {
if (options['include']) {
includes = options['include'];
}
else if (!options['includes']) {
includes = [ ];
}
else {
includes = await findIncludes();
}
const parseOptions = {
deprecateHard: options.deprecateHard || false,
includeFiles: includes,
strict: options.strict || false
};
const results = await parseHeaders(docsPath, parseOptions);
const metadata = await readMetadata(docsPath);
const simplified = simplify(results);
simplified['info'] = metadata;
console.log(JSON.stringify(simplified, null, 2));
} catch (e) {
console.error(e);
process.exit(1);
}
})();