Skip to content

Commit 49d4647

Browse files
committed
feat apilinks.json generator
Closes #152 Signed-off-by: flakey5 <[email protected]>
1 parent ba071f5 commit 49d4647

File tree

15 files changed

+642
-8
lines changed

15 files changed

+642
-8
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,6 @@ Options:
3939
-o, --output <path> Specify the relative or absolute output directory
4040
-v, --version <semver> Specify the target version of Node.js, semver compliant (default: "v22.6.0")
4141
-c, --changelog <url> Specify the path (file: or https://) to the CHANGELOG.md file (default: "https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md")
42-
-t, --target [mode...] Set the processing target modes (choices: "json-simple", "legacy-html", "legacy-html-all", "man-page", "legacy-json", "legacy-json-all", "addon-verify")
42+
-t, --target [mode...] Set the processing target modes (choices: "json-simple", "legacy-html", "legacy-html-all", "man-page", "legacy-json", "legacy-json-all", "addon-verify", "api-links")
4343
-h, --help display help for command
4444
```

bin/cli.mjs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,22 @@ program
6868
*/
6969
const { input, output, target = [], version, changelog } = program.opts();
7070

71-
const { loadFiles } = createLoader();
72-
const { parseApiDocs } = createParser();
71+
const { loadFiles, loadJsFiles } = createLoader();
72+
const { parseApiDocs, parseJsSources } = createParser();
7373

7474
const apiDocFiles = loadFiles(input);
7575

7676
const parsedApiDocs = await parseApiDocs(apiDocFiles);
7777

78-
const { runGenerators } = createGenerator(parsedApiDocs);
78+
const sourceFiles = loadJsFiles(
79+
parsedApiDocs
80+
.map(apiDoc => apiDoc.source_link_local)
81+
.filter(path => path !== undefined && path.endsWith('.js'))
82+
);
83+
84+
const parsedJsFiles = await parseJsSources(sourceFiles);
85+
86+
const { runGenerators } = createGenerator(parsedApiDocs, parsedJsFiles);
7987

8088
// Retrieves Node.js release metadata from a given Node.js version and CHANGELOG.md file
8189
const { getAllMajors } = createNodeReleases(changelog);

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"prettier": "3.4.2"
3030
},
3131
"dependencies": {
32+
"acorn": "^8.14.0",
3233
"commander": "^13.0.0",
3334
"github-slugger": "^2.0.0",
3435
"glob": "^11.0.0",

src/generators.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,20 @@ import availableGenerators from './generators/index.mjs';
1919
* the final generators in the chain.
2020
*
2121
* @param {ApiDocMetadataEntry} input The parsed API doc metadata entries
22+
* @param {Array<import('acorn').Program>} parsedJsFiles
2223
*/
23-
const createGenerator = input => {
24+
const createGenerator = (input, parsedJsFiles) => {
2425
/**
2526
* We store all the registered generators to be processed
2627
* within a Record, so we can access their results at any time whenever needed
2728
* (we store the Promises of the generator outputs)
2829
*
2930
* @type {{ [K in keyof AllGenerators]: ReturnType<AllGenerators[K]['generate']> }}
3031
*/
31-
const cachedGenerators = { ast: Promise.resolve(input) };
32+
const cachedGenerators = {
33+
ast: Promise.resolve(input),
34+
'ast-js': Promise.resolve(parsedJsFiles),
35+
};
3236

3337
/**
3438
* Runs the Generator engine with the provided top-level input and the given generator options

src/generators/api-links/index.mjs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
'use strict';
2+
3+
import { basename, dirname, join } from 'node:path';
4+
import { writeFile } from 'node:fs/promises';
5+
import { getGitRepository, getGitTag } from '../../utils/git.mjs';
6+
import { extractExports } from './utils/extractExports.mjs';
7+
import { findDefinitions } from './utils/findDefinitions.mjs';
8+
9+
/**
10+
* This generator is responsible for mapping publicly accessible functions in
11+
* Node.js to their source locations in the Node.js repository.
12+
*
13+
* This is a top-level generator. It takes in the raw AST tree of the JavaScript
14+
* source files. It outputs a `apilinks.json` file into the specified output
15+
* directory.
16+
*
17+
* @typedef {Array<JsProgram>} Input
18+
*
19+
* @type {import('../types.d.ts').GeneratorMetadata<Input, Record<string, string>>}
20+
*/
21+
export default {
22+
name: 'api-links',
23+
24+
version: '1.0.0',
25+
26+
description:
27+
'Creates a mapping of publicly accessible functions to their source locations in the Node.js repository.',
28+
29+
dependsOn: 'ast-js',
30+
31+
/**
32+
* Generates the `apilinks.json` file.
33+
*
34+
* @param {Input} input
35+
* @param {Partial<GeneratorOptions>} options
36+
*/
37+
async generate(input, { output }) {
38+
/**
39+
* @type {Record<string, string>}
40+
*/
41+
const definitions = {};
42+
43+
/**
44+
* @type {string}
45+
*/
46+
let baseGithubLink;
47+
48+
input.forEach(program => {
49+
/**
50+
* Mapping of definitions to their line number
51+
* @type {Record<string, number>}
52+
* @example { 'someclass.foo', 10 }
53+
*/
54+
const nameToLineNumberMap = {};
55+
56+
const programBasename = basename(program.path, '.js');
57+
58+
const exports = extractExports(
59+
program,
60+
programBasename,
61+
nameToLineNumberMap
62+
);
63+
64+
findDefinitions(program, programBasename, nameToLineNumberMap, exports);
65+
66+
if (!baseGithubLink) {
67+
const directory = dirname(program.path);
68+
69+
const repository = getGitRepository(directory);
70+
71+
const tag = getGitTag(directory);
72+
73+
baseGithubLink = `https://github.com/${repository}/blob/${tag}`;
74+
}
75+
76+
const githubLink =
77+
`${baseGithubLink}/lib/${programBasename}.js`.replaceAll('\\', '/');
78+
79+
Object.keys(nameToLineNumberMap).forEach(key => {
80+
const lineNumber = nameToLineNumberMap[key];
81+
82+
definitions[key] = `${githubLink}#L${lineNumber}`;
83+
});
84+
});
85+
86+
if (output) {
87+
await writeFile(
88+
join(output, 'apilinks.json'),
89+
JSON.stringify(definitions)
90+
);
91+
}
92+
93+
return definitions;
94+
},
95+
};

src/generators/api-links/types.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface ProgramExports {
2+
ctors: Array<string>;
3+
identifiers: Array<string>;
4+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// @ts-check
2+
'use strict';
3+
4+
/**
5+
* @param {import('acorn').AssignmentExpression} expression
6+
* @param {import('acorn').SourceLocation} loc
7+
* @param {string} basename
8+
* @param {Record<string, number>} nameToLineNumberMap
9+
* @returns {import('../types').ProgramExports | undefined}
10+
*/
11+
function extractExpression(expression, loc, basename, nameToLineNumberMap) {
12+
/**
13+
* @example `a=b`, lhs=`a` and rhs=`b`
14+
*/
15+
let { left: lhs, right: rhs } = expression;
16+
17+
if (lhs.type !== 'MemberExpression') {
18+
return undefined;
19+
}
20+
21+
if (lhs.object.type === 'MemberExpression') {
22+
lhs = lhs.object;
23+
}
24+
25+
/**
26+
* @type {import('../types').ProgramExports}
27+
*/
28+
const exports = {
29+
ctors: [],
30+
identifiers: [],
31+
};
32+
33+
if (lhs.object.name === 'exports') {
34+
// Assigning a property in `module.exports` (i.e. `module.exports.asd = ...`)
35+
const { name } = lhs.property;
36+
37+
switch (rhs.type) {
38+
case 'FunctionExpression':
39+
// module.exports.something = () => {}
40+
nameToLineNumberMap[`${basename}.${name}`] = loc.start.line;
41+
break;
42+
43+
case 'Identifier':
44+
// module.exports.asd = something
45+
// TODO indirects?
46+
console.log('indir', name);
47+
break;
48+
49+
default:
50+
exports.identifiers.push(name);
51+
break;
52+
}
53+
} else if (lhs.object.name === 'module' && lhs.property.name === 'exports') {
54+
// Assigning `module.exports` as a whole, (i.e. `module.exports = {}`)
55+
while (rhs.type === 'AssignmentExpression') {
56+
// Move right until we find the value of the assignment
57+
// (i.e. `a=b`, we want `b`).
58+
rhs = rhs.right;
59+
}
60+
61+
switch (rhs.type) {
62+
case 'NewExpression':
63+
// module.exports = new Asd()
64+
exports.ctors.push(rhs.callee.name);
65+
break;
66+
67+
case 'ObjectExpression':
68+
// module.exports = {}
69+
// We need to go through all of the properties and add register them
70+
rhs.properties.forEach(({ value }) => {
71+
if (value.type !== 'Identifier') {
72+
return;
73+
}
74+
75+
exports.identifiers.push(value.name);
76+
77+
if (/^[A-Z]/.test(value.name[0])) {
78+
exports.ctors.push(value.name);
79+
}
80+
});
81+
82+
break;
83+
84+
default:
85+
exports.identifiers.push(rhs.name);
86+
break;
87+
}
88+
}
89+
90+
return exports;
91+
}
92+
93+
/**
94+
* @param {import('acorn').VariableDeclarator} declaration
95+
* @param {import('acorn').SourceLocation} loc
96+
* @param {string} basename
97+
* @param {Record<string, number>} nameToLineNumberMap
98+
* @returns {import('../types').ProgramExports | undefined}
99+
*/
100+
function extractVariableDeclaration(
101+
{ id, init },
102+
loc,
103+
basename,
104+
nameToLineNumberMap
105+
) {
106+
while (init && init.type === 'AssignmentExpression') {
107+
// Move left until we get to what we're assigning to
108+
// (i.e. `a=b`, we want `a`)
109+
init = init.left;
110+
}
111+
112+
if (!init || init.type !== 'MemberExpression') {
113+
// Doesn't exist or we're not writing to a member (probably a normal var,
114+
// like `const a = 123`)
115+
return undefined;
116+
}
117+
118+
/**
119+
* @type {import('../types').ProgramExports}
120+
*/
121+
const exports = {
122+
ctors: [],
123+
identifiers: [],
124+
};
125+
126+
if (init.object.name === 'exports') {
127+
// Assigning a property in `module.exports` (i.e. `module.exports.asd = ...`)
128+
nameToLineNumberMap[`${basename}.${init.property.name}`] = loc.start.line;
129+
} else if (
130+
init.object.name === 'module' &&
131+
init.property.name === 'exports'
132+
) {
133+
// Assigning `module.exports` as a whole, (i.e. `module.exports = {}`)
134+
exports.ctors.push(id.name);
135+
nameToLineNumberMap[id.name] = loc.start.line;
136+
}
137+
138+
return exports;
139+
}
140+
141+
/**
142+
* We need to find what a source file exports so we know what to include in
143+
* the final result. We can do this by going through every statement in the
144+
* program looking for assignments to `module.exports`.
145+
*
146+
* Noteworthy that exports can happen throughout the program so we need to
147+
* go through the entire thing.
148+
*
149+
* @param {import('acorn').Program} program
150+
* @param {string} basename
151+
* @param {Record<string, number>} nameToLineNumberMap
152+
* @returns {import('../types').ProgramExports}
153+
*/
154+
export function extractExports(program, basename, nameToLineNumberMap) {
155+
/**
156+
* @type {import('../types').ProgramExports}
157+
*/
158+
const exports = {
159+
ctors: [],
160+
identifiers: [],
161+
};
162+
163+
program.body.forEach(statement => {
164+
const { loc } = statement;
165+
if (!loc) {
166+
return;
167+
}
168+
169+
switch (statement.type) {
170+
case 'ExpressionStatement': {
171+
const { expression } = statement;
172+
if (expression.type !== 'AssignmentExpression' || !loc) {
173+
break;
174+
}
175+
176+
const expressionExports = extractExpression(
177+
expression,
178+
loc,
179+
basename,
180+
nameToLineNumberMap
181+
);
182+
183+
if (expressionExports) {
184+
exports.ctors.push(...expressionExports.ctors);
185+
exports.identifiers.push(...expressionExports.identifiers);
186+
}
187+
188+
break;
189+
}
190+
191+
case 'VariableDeclaration': {
192+
statement.declarations.forEach(declaration => {
193+
const variableExports = extractVariableDeclaration(
194+
declaration,
195+
loc,
196+
basename,
197+
nameToLineNumberMap
198+
);
199+
200+
if (variableExports) {
201+
exports.ctors.push(...variableExports.ctors);
202+
exports.identifiers.push(...variableExports.identifiers);
203+
}
204+
});
205+
206+
break;
207+
}
208+
209+
default:
210+
break;
211+
}
212+
});
213+
214+
return exports;
215+
}

0 commit comments

Comments
 (0)