Edit

kc3-lang/libgit2/script/api-docs/api-generator.js

Branch :

  • Show log

    Commit

  • Author : Edward Thomson
    Date : 2024-12-23 16:11:52
    Hash : 53c14952
    Message : docs: properly parse enum values For enum values that are constructed, not literal integers, we need to parse the inner / implicit expression. For example: ``` GIT_DIFF_REVERSE = (1u << 0) ```

  • script/api-docs/api-generator.js
  • #!/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)).
                filter((filename) => filename !== 'deprecated.h' || !options.deprecateHard).
                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 implicitValue = single(member.inner, (attr => attr.kind === 'ImplicitCastExpr'));
            const commentText = single(member.inner, (attr => attr.kind === 'FullComment'));
            const commentData = commentText ? parseComment(`enum:${decl.name}:member:${member.name}`, location, commentText, options) : undefined;
    
            let value = undefined;
    
            if (explicitValue && explicitValue.value) {
                value = explicitValue.value;
            } else if (implicitValue) {
                const innerExplicit = single(implicitValue.inner, (attr => attr.kind === 'ConstantExpr'));
    
                value = innerExplicit?.value;
            }
    
            result.members.push({
                name: member.name,
                value: value,
                ...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('--validate-only')
           .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;
    
    	if (!options.validateOnly) {
    		console.log(JSON.stringify(simplified, null, 2));
    	}
        } catch (e) {
            console.error(e);
            process.exit(1);
        }
    })();