This repository was archived by the owner on Feb 3, 2025. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 54
Expand file tree
/
Copy pathexploreAPI.ts
More file actions
390 lines (353 loc) · 10.9 KB
/
Copy pathexploreAPI.ts
File metadata and controls
390 lines (353 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
import fs from "fs";
import path from "path";
import { addHook } from "pirates";
import * as espree from "espree";
import * as estraverse from "estraverse";
import { performance } from "perf_hooks";
function extend(accessPath: string, component: string) {
if (component === "default" && !accessPath.includes(".")) {
return accessPath;
} else if (component.match(/^[a-zA-Z_$][\w$]*$/)) {
return accessPath + "." + component;
} else if (component.match(/^\d+$/)) {
return accessPath + "[" + component + "]";
} else {
return accessPath + "['" + component.replace(/['\\]/g, "\\$&") + "']";
}
}
export type FunctionDescriptor = {
type: "function";
signature: string;
isAsync: boolean;
implementation: string;
isConstructor: boolean;
docComment?: string;
};
export type ApiElementDescriptor =
| {
type:
| "bigint"
| "boolean"
| "number"
| "string"
| "symbol"
| "object"
| "array"
| "undefined"
| "null";
}
| FunctionDescriptor;
export class API {
constructor(
private readonly elements = new Map<string, ApiElementDescriptor>()
) {}
set(accessPath: string, value: ApiElementDescriptor) {
this.elements.set(accessPath, value);
}
get(accessPath: string) {
return this.elements.get(accessPath);
}
*getFunctions(packageName: string) {
for (const [accessPath, descriptor] of this.elements) {
if (descriptor.type === "function") {
yield new APIFunction(accessPath, descriptor, packageName);
}
}
}
toJSON() {
return [...this.elements];
}
static fromJSON(json: [string, ApiElementDescriptor][]) {
return new API(new Map(json));
}
}
/**
* A representation of an API function, including both its access path and a
* function descriptor.
*/
export class APIFunction {
constructor(
public readonly accessPath: string,
public readonly descriptor: FunctionDescriptor,
public readonly packageName: string
) {}
/**
* Parse a given signature into an API function.
*
* The signature is expected to consist of an optional initial `class `, then
* a dot-separated access path (starting with the package name, ending with
* the function name), followed by a parenthesised list of parameters,
* optionally followed by ` async`.
*
* Example:
*
* ```
* zip-a-folder.ZipAFolder.tar(srcFolder, tarFilePath, zipAFolderOptions) async
* ```
*/
public static fromSignature(
signature: string,
implementation: string = ""
): APIFunction {
const match = signature.match(
/^(class )?([-\w$]+(?:\.[\w$]+)*)(\(.*\))( async)?$/
);
if (!match) throw new Error(`Invalid signature: ${signature}`);
const [, isConstructor, accessPath, parameters, isAsync] = match;
const descriptor: FunctionDescriptor = {
type: "function",
signature: parameters,
isAsync: !!isAsync,
isConstructor: !!isConstructor,
implementation,
};
return new APIFunction(accessPath, descriptor, accessPath.split(".")[0]); // infer package name from accesspath. This will not work for package names that contain a "."
}
/** Serialize the API function to a JSON object. */
public toJSON(): object {
return {
accessPath: this.accessPath,
descriptor: this.descriptor,
packageName: this.packageName,
};
}
/** Deserialize an API function from a JSON object. */
public static fromJSON(json: object): APIFunction {
const { accessPath, descriptor, packageName } = json as any;
return new APIFunction(accessPath, descriptor, packageName);
}
/** The name of the function itself. */
public get functionName(): string {
return this.accessPath.split(".").pop()!;
}
/** The full signature of the function. */
public get signature(): string {
const { signature, isAsync, isConstructor } = this.descriptor;
return (
(isConstructor ? "class " : "") +
this.accessPath +
signature +
(isAsync ? " async" : "")
);
}
}
const funcToString = Function.prototype.toString;
/**
* Determine if a function is a constructor
*/
function isConstructor(fn: Function) {
return funcToString.call(fn).startsWith("class ");
}
function getSignature(fn: Function) {
let funcStr = funcToString.call(fn);
if (isConstructor(fn)) {
// if funcStr does not contain the word 'constructor', then there it is a default constructor with no arguments
if (!funcStr.match(/constructor\s*\(/)) {
return "()";
} else {
// otherwise, find the signature of the constructor
let match = funcStr.match(/constructor\s*\(([^)]*)\)/);
if (match) {
return "(" + match[1] + ")";
} else {
throw new Error(`Could not find constructor signature in ${funcStr}`);
}
}
}
let openingParen = funcStr.indexOf("("),
closingParen = funcStr.indexOf(")");
if (openingParen === -1 || closingParen === -1) {
return "()";
}
let funcSig = funcStr.slice(openingParen + 1, closingParen);
let nrArgs = funcSig.split(",").length;
if (fn.length <= nrArgs) {
return `(${funcSig})`;
} else {
let pseudoArgs = [];
for (let i = 1; i <= fn.length; i++) {
pseudoArgs.push(`arg${i}`);
}
return `(${pseudoArgs.join(", ")})`;
}
}
/**
* Normalizes a function implementation to unify whitespace. This allows matching functions identified through parsing the
* source code to those identified dynamically from the object graph.
* @param source implementation source code to normalize
* @returns normalized source code
*/
export function normalizeFunctionSource(source: string) {
return source.replace(/\s+/g, " ").replace(/(?<!\w)\s+|\s+(?!\w)/g, "");
}
function describe(
value: any,
docComments: Map<string, string>
): ApiElementDescriptor {
const type = typeof value;
switch (type) {
case "bigint":
case "boolean":
case "number":
case "string":
case "symbol":
case "undefined":
return { type };
case "object":
if (value === null) {
return { type: "null" };
} else if (Array.isArray(value)) {
return { type: "array" };
}
return { type: "object" };
case "function":
const isConstr = isConstructor(value);
const signature = getSignature(value);
const implementation = funcToString.call(value);
const isAsync = implementation.startsWith("async ");
const docComment = docComments.get(
normalizeFunctionSource(implementation)
);
return {
type: "function",
signature,
implementation,
isAsync,
isConstructor: isConstr,
docComment,
};
}
}
function getProperties(obj: object) {
let props = new Set<string>();
// add enumerable properties
for (let prop in obj) {
props.add(prop);
}
// also add non-enumerable properties (such as static methods)
const propDescs = Object.getOwnPropertyDescriptors(obj);
for (let prop in propDescs) {
const propDesc = propDescs[prop];
if ("value" in propDesc) props.add(prop);
}
return props;
}
/**
* Determines the set of (`path`, `type`) pairs that constitute an API.
*
* @param pkgName the name of the package to explore
* @param pkgExports the object returned by `require(pkgName)`
*/
function exploreExports(
pkgName: string,
pkgExports: any,
docComments: Map<string, string>
): API {
const api = new API();
const seen = new Set<any>();
function explore(accessPath: string, value: any) {
if (seen.has(value)) {
return;
} else {
seen.add(value);
}
const descriptor = describe(value, docComments);
if (descriptor.type !== "object" && descriptor.type !== "null") {
api.set(accessPath, descriptor);
}
exploreProperties(accessPath, descriptor, value);
}
function exploreProperties(
accessPath: string,
descriptor: ApiElementDescriptor,
value: any
) {
if (["array", "function", "object"].includes(descriptor.type)) {
for (const prop of getProperties(value)) {
// skip private properties as well as special properties of classes, functions, and arrays
if (
prop.startsWith("_") ||
["super", "super_", "constructor"].includes(prop) ||
(descriptor.type === "function" &&
["arguments", "caller", "length", "name"].includes(prop)) ||
(descriptor.type === "array" && prop === "length")
) {
continue;
}
explore(extend(accessPath, prop), value[prop]);
}
}
}
explore(pkgName, pkgExports);
return api;
}
/**
* Sanitize package name by replacing non-alphanumeric characters with underscores.
* @param pkgName the package name to sanitize
*/
export function sanitizePackageName(pkgName: string) {
return pkgName.replace(/[^a-zA-Z0-9_$]/g, "_");
}
/**
* Populates the `docComments` map with the doc comments found in the given code.
* @param code the code to search for functions and their corresponding docComments in
* @param docComments the map to populate with doc comments, where the map key is the normalized function source code
* @returns the passed code as is
*/
export function findDocComments(
code: string,
docComments: Map<string, string>
): string {
performance.mark("doc-comment-extraction-start");
try {
const ast = espree.parse(code, {
ecmaVersion: "latest",
loc: true,
comment: true,
});
const comments = ast.comments.filter(
(comment: any) => comment.type === "Block"
);
estraverse.traverse(ast, {
enter(node) {
if (
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression"
) {
const { start, end } = node as any;
const functionSource = normalizeFunctionSource(
code.slice(start, end)
);
//doc comment ends on immediately preceding line
const fnDocComment = comments.find(
(comment: any) => comment.loc.end.line == node.loc!.start.line - 1
);
if (fnDocComment) docComments.set(functionSource, fnDocComment.value);
}
},
});
} catch (e: any) {
console.warn(`Error parsing code with espree: ${e}`); //failed parsing throws a SyntaxError exception
}
performance.measure("doc-comment-extraction", "doc-comment-extraction-start");
return code;
}
export function exploreAPI(pkgPath: string): API {
performance.mark("api-exploration-start");
const pkgName = JSON.parse(
fs.readFileSync(path.join(pkgPath, "package.json"), "utf8")
).name;
const docComments: Map<string, string> = new Map();
const revert = addHook((code, filename) =>
findDocComments(code, docComments)
);
const pkgExports = require(pkgPath);
revert();
const api = exploreExports(pkgName, pkgExports, docComments);
performance.measure("api-exploration", "api-exploration-start");
return api;
}
if (require.main === module) {
// Usage: node exploreAPI.js <pkgPath>
console.log(JSON.stringify(exploreAPI(process.argv[2]), null, 2));
}