spiegel-keyman/core/tools/api-header-extractor/src/index.ts
2026-05-06 13:28:03 +02:00

318 lines
No EOL
7.8 KiB
TypeScript

/*
* Keyman is copyright (C) SIL International. MIT License.
*
* Low-friction tool to extract javadoc-style comments from Keyman API headers.
* Could be replaced in future with doxygen/moxygen type of toolchain, but
* for now this covers our needs with minimal fuss.
*
* Notes:
* - `@name` command is required for each documented item
* - `@param` and `@returns` are the only other supported commands
* - this is fairly naive so check outputs to make sure they are
* giving what you expect!
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
/** Stop processing header file after encountering this marker */
const EOF_MARKER = '/* $EOF */';
/** For every symbol found, keep a reference for hyperlinks */
interface ApiEndpoint {
filename: string;
type: string;
endpoint: string;
};
/** Filename and content for output files */
interface DocFile {
filename: string;
lines: string[];
};
type ProcessorMode = keyof typeof processor;
// global variables
let mode: ProcessorMode;
let currentFilename: string | undefined;
let lines: string[], result: string[], apiEndpoints: ApiEndpoint[], files: DocFile[];
let sourceFilename: string, sourceLine: number;
const processor = {
preamble: (line: string) => {
if(line.trim() == '---') {
mode = 'filename';
}
},
filename: (line: string) => {
if(!line.trim().startsWith('filename:')) {
throw new Error(`Expecting line to start with "filename:" but instead got "${line}"`);
}
cacheResult();
currentFilename = line.split(':', 2)[1].trim();
mode = 'title';
result = [
'---' // first line of markdown title section
];
},
title: (line: string) => {
result.push(line);
if(line.trim() == '---') {
mode = 'content';
result.push(`<!-- generated by core/tools/api-header-extractor from core/include/keyman/*.h on ${new Date().toISOString().substring(0,10)} -->`);
}
},
content: (line: string) => {
if(line.trim() == '---') {
mode = 'filename';
return;
}
if(line.trim() == EOF_MARKER) {
mode = 'eof';
return;
}
if(line.trim() == '/**') {
// This is start of a new function definition
mode = 'functionComment';
return;
}
if(line.trim().startsWith('//')) {
// ignore inline comments
return;
}
if(line.trim().includes('/*')) {
// ignore start of comment that is not a doc comment -- note that this may
// leave comment body in the documentation, so currently assuming that we
// don't use this pattern
warn(`Unexpected multi-line comment token`, line);
return;
}
if(line.trim() == '*/') {
// end of a multi-line comment, we don't emit
return;
}
result.push(line);
},
functionComment: (line: string) => {
line = line.trim();
if(isCommentFinisher(line)) return;
const { command, param } = getCommentCommand(line);
if(!command) {
result.push(param);
return;
}
if(command == 'name') {
const m = param.match(/^([^ ]+)\s*(.*)$/);
if(!m) {
console.log('Warning: misformatted @name line');
return;
}
const name=m[1], title=`${m[1]} ${m[2]}`.trim();
result.push('');
result.push(`# ${title} {#${name}}`);
apiEndpoints.push({filename: currentFilename!, endpoint: name, type: m[2].trim()});
} else if(command == 'param') {
result.push('');
result.push(`## Parameters`);
mode = 'functionCommentParam';
processor.functionCommentParam('* @param '+param);
} else if(command == 'returns') {
result.push('');
result.push(`## Returns`);
mode = 'functionCommentReturns';
processor.functionCommentReturns('* '+param);
}
},
functionCommentParam: (line: string) => {
line = line.trim();
if(isCommentFinisher(line)) return;
const { command, param } = getCommentCommand(line);
if(command != 'param' && command != null) {
mode = 'functionComment';
return processor.functionComment(line);
}
if(command == 'param') {
// expect @param <name> [desc]
const m = param.match(/^([^ ]+)\s*(.*)$/);
if(!m) {
warn(`Warning: misformatted @param line`, line);
return;
}
const name=m[1], desc=m[2];
result.push('');
result.push(`### ${name}`);
result.push(desc);
} else {
// continuation of `param`
result.push(param);
}
},
functionCommentReturns: (line: string) => {
line = line.trim();
if(isCommentFinisher(line)) return;
const { command, param } = getCommentCommand(line);
if(command != null) {
mode = 'functionComment';
return processor.functionComment(line);
}
// continuation of `returns`
result.push(param);
},
declaration: (line: string) => {
if(line.trim().startsWith('//')) {
// Ignore internal comments
return;
}
if(line.trim() == '/**') {
// This is start of a new function definition
result.push('```');
mode = 'functionComment';
return;
}
if(line.trim() == EOF_MARKER) {
mode = 'eof';
cacheResult();
return;
}
if(line.trim() != '') {
result.push(line);
}
},
eof: (_line: string) => {
// ignore all content after EOF_MARKER
}
}
function getCommentCommand(line: string) {
const m = line.match(/^\s*\*\s+@(name|param|returns)(\s+.*)?$/);
if(!m) {
return { command: null, param: line.substring(2).trim() }; // strips initial '* '
}
return {
command: m[1],
param: (m[2] ?? '').trim()
}
}
function isCommentFinisher(line: string) {
if(line.endsWith('*/')) {
mode = 'declaration';
result.push('');
result.push('```c');
return true;
}
if(line == '---') {
// Starting a new file
mode = 'filename';
return true;
}
if(!line.startsWith('*')) {
warn(`Line had an unexpected start`, line);
return true;
}
return false;
}
/**
* Save the current output file, if not empty, as we are
* about to start a new file
*/
function cacheResult() {
if(currentFilename != undefined && result.length > 1) {
files.push({
filename: currentFilename,
lines: result
});
}
}
/**
* Write the current file out including appending the cross-references
* @param dstFolder target folder
* @param file documentation file to write out
* @param refs string of cross-references to add to bottom of file
*/
function writeFile(dstFolder: string, file: DocFile, refs: string): void {
fs.writeFileSync(path.join(dstFolder, file.filename), file.lines.join('\n') + '\n\n' + refs, 'utf-8');
}
function buildRefs(): string {
let result = [];
for(let ep of apiEndpoints) {
const filename = ep.filename.substring(0,ep.filename.length-3); // Strip .md, rough but ready
result.push(`[${ep.endpoint}]: ${filename}#${ep.endpoint} "${ep.endpoint} ${ep.type}"`);
}
return result.join('\n');
}
function warn(message: string, line: string) {
console.warn(`${sourceFilename}:${sourceLine} ${message}\n ${line}`);
}
function main() {
if(process.argv.length < 4) {
console.error('Invalid parameters');
console.log('Usage: node index.js <dstPath> <srcFile...>');
process.exit(1);
}
const dstPath = process.argv[2];
const src = process.argv.slice(3);
apiEndpoints = [];
files = [];
// Process each file
for(const s of src) {
result = [];
currentFilename = undefined;
mode = 'preamble';
lines = fs.readFileSync(s, 'utf-8').replace(/\r/g, '').split('\n');
sourceFilename = s;
sourceLine = 1;
for(const line of lines) {
processor[mode](line);
sourceLine++;
}
cacheResult();
}
// Build heading references
const refs = buildRefs();
// Write out
for(const file of files) {
writeFile(dstPath, file, refs);
}
}
main();