Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create EntryFilesAnalyzer class to analyze a set of entry files #258

Merged
merged 10 commits into from
Apr 22, 2024
8 changes: 8 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import {
AstAnalyser,
AstAnalyserOptions,

EntryFilesAnalyser,
EntryFilesAnalyserOptions,

SourceParser,
runASTAnalysis,
runASTAnalysisOnFile,
Expand All @@ -23,6 +28,9 @@ declare const warnings: Record<WarningName, Pick<WarningDefault, "experimental"
export {
warnings,
AstAnalyser,
AstAnalyserOptions,
EntryFilesAnalyser,
EntryFilesAnalyserOptions,
SourceParser,
runASTAnalysis,
runASTAnalysisOnFile,
Expand Down
99 changes: 99 additions & 0 deletions src/EntryFilesAnalyser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Import Node.js Dependencies
import fs from "node:fs/promises";
import path from "node:path";

// Import Internal Dependencies
import { AstAnalyser } from "./AstAnalyser.js";

const kDefaultExtensions = ["js", "cjs", "mjs", "node"];

export class EntryFilesAnalyser {
/**
* @constructor
* @param {object} [options={}]
* @param {AstAnalyser} [options.astAnalyzer=new AstAnalyser()]
* @param {function} [options.loadExtensions]
*/
constructor(options = {}) {
this.astAnalyzer = options.astAnalyzer ?? new AstAnalyser();
this.allowedExtensions = options.loadExtensions
? options.loadExtensions(kDefaultExtensions)
: kDefaultExtensions;
}

/**
* Asynchronously analyze a set of entry files yielding analysis reports.
*
* @param {(string | URL)[]} entryFiles
* @yields {Object} - Yields an object containing the analysis report for each file.
*/
async* analyse(entryFiles) {
this.analyzedDeps = new Set();

for (const file of entryFiles) {
yield* this.#analyzeFile(file);
}
}

async* #analyzeFile(file) {
const filePath = file instanceof URL ? file.pathname : file;
const report = await this.astAnalyzer.analyseFile(file);

yield { url: filePath, ...report };

if (!report.ok) {
return;
}

yield* this.#analyzeDeps(report.dependencies, path.dirname(filePath));
}

async* #analyzeDeps(deps, basePath) {
for (const [name] of deps) {
const depPath = await this.#getInternalDepPath(name, basePath);
if (depPath && !this.analyzedDeps.has(depPath)) {
this.analyzedDeps.add(depPath);

yield* this.#analyzeFile(depPath);
}
}
}

async #getInternalDepPath(name, basePath) {
const depPath = path.join(basePath, name);
const existingExt = path.extname(name);
if (existingExt !== "") {
if (!this.allowedExtensions.includes(existingExt.slice(1))) {
return null;
}

if (await this.#fileExists(depPath)) {
return depPath;
}
}

for (const ext of this.allowedExtensions) {
const depPathWithExt = `${depPath}.${ext}`;
if (await this.#fileExists(depPathWithExt)) {
return depPathWithExt;
}
}

return null;
}

async #fileExists(path) {
try {
await fs.access(path, fs.constants.F_OK);

return true;
}
catch (error) {
if (error.code !== "ENOENT") {
throw error;
}

return false;
}
}
}
95 changes: 95 additions & 0 deletions test/EntryFilesAnalyser.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Import Node.js Dependencies
import { describe, it } from "node:test";
import assert from "node:assert";

// Import Internal Dependencies
import { EntryFilesAnalyser } from "../src/EntryFilesAnalyser.js";
import { AstAnalyser } from "../src/AstAnalyser.js";

const FIXTURE_URL = new URL("fixtures/entryFiles/", import.meta.url);

describe("EntryFilesAnalyser", () => {
it("should analyze internal dependencies recursively", async(t) => {
const entryFilesAnalyser = new EntryFilesAnalyser();
const entryUrl = new URL("entry.js", FIXTURE_URL);
const deepEntryUrl = new URL("deps/deepEntry.js", FIXTURE_URL);

t.mock.method(AstAnalyser.prototype, "analyseFile");
const generator = entryFilesAnalyser.analyse([entryUrl, deepEntryUrl]);

// First entry
await assertReport(generator, entryUrl, true);
await assertReport(generator, new URL("deps/dep1.js", FIXTURE_URL), true);
await assertReport(generator, new URL("shared.js", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/dep2.js", FIXTURE_URL), true);

// Second entry
await assertReport(generator, deepEntryUrl, true);
await assertReport(generator, new URL("deps/dep3.js", FIXTURE_URL), true);

await assertAllReportsYielded(generator);

// Check that shared dependencies are not analyzed several times
const calls = AstAnalyser.prototype.analyseFile.mock.calls;
assert.strictEqual(calls.length, 6);
});

it("should detect internal deps that failed to be analyzed", async() => {
const entryFilesAnalyser = new EntryFilesAnalyser();
const entryUrl = new URL("entryWithInvalidDep.js", FIXTURE_URL);

const generator = entryFilesAnalyser.analyse([entryUrl]);

await assertReport(generator, entryUrl, true);

const invalidDepReport = await generator.next();
assert.ok(!invalidDepReport.value.ok);
assert.strictEqual(invalidDepReport.value.url, new URL("deps/invalidDep.js", FIXTURE_URL).pathname);
assert.strictEqual(invalidDepReport.value.warnings[0].kind, "parsing-error");

await assertReport(generator, new URL("deps/dep1.js", FIXTURE_URL), true);
await assertReport(generator, new URL("shared.js", FIXTURE_URL), true);

await assertAllReportsYielded(generator);
});

it("should extends default extensions", async() => {
const entryFilesAnalyser = new EntryFilesAnalyser({
loadExtensions: (exts) => [...exts, "jsx"]
});
const entryUrl = new URL("entryWithVariousDepExtensions.js", FIXTURE_URL);
const generator = entryFilesAnalyser.analyse([entryUrl]);

await assertReport(generator, entryUrl, true);
await assertReport(generator, new URL("deps/default.js", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/default.cjs", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/default.mjs", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/default.node", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/default.jsx", FIXTURE_URL), true);

await assertAllReportsYielded(generator);
jean-michelet marked this conversation as resolved.
Show resolved Hide resolved
});

it("should override default extensions", async() => {
const entryFilesAnalyser = new EntryFilesAnalyser({
loadExtensions: () => ["jsx"]
});
const entryUrl = new URL("entryWithVariousDepExtensions.js", FIXTURE_URL);
const generator = entryFilesAnalyser.analyse([entryUrl]);

await assertReport(generator, entryUrl, true);
await assertReport(generator, new URL("deps/default.jsx", FIXTURE_URL), true);

await assertAllReportsYielded(generator);
});

async function assertReport(generator, expectedUrl, expectedOk) {
const report = await generator.next();
assert.strictEqual(report.value.url, expectedUrl.pathname);
assert.strictEqual(report.value.ok, expectedOk);
}

async function assertAllReportsYielded(generator) {
assert.strictEqual((await generator.next()).value, undefined);
}
});
3 changes: 3 additions & 0 deletions test/fixtures/entryFiles/deps/deepEntry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require("./dep1");
require("./dep2");
require("./dep3");
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/default.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require('externalDep')
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require('externalDep')
7 changes: 7 additions & 0 deletions test/fixtures/entryFiles/deps/default.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react'

export default function Foo() {
return (
<div></div>
)
}
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/default.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import externalDep from 'externalDep';
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/default.node
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('some/addon');
2 changes: 2 additions & 0 deletions test/fixtures/entryFiles/deps/dep1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require("../shared");
require("externalDep");
3 changes: 3 additions & 0 deletions test/fixtures/entryFiles/deps/dep2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require("../shared");
require("../shared.js");
require("externalDep");
3 changes: 3 additions & 0 deletions test/fixtures/entryFiles/deps/dep3.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require("../shared");
require("../shared.js");
require("externalDep");
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/invalidDep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@invalidJs
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/validDep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require("externalDep");
2 changes: 2 additions & 0 deletions test/fixtures/entryFiles/entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require("./deps/dep1");
require("./deps/dep2.js"); // keep extension for testing purpose
2 changes: 2 additions & 0 deletions test/fixtures/entryFiles/entryWithInvalidDep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require("./deps/invalidDep");
require("./deps/dep1");
2 changes: 2 additions & 0 deletions test/fixtures/entryFiles/entryWithRequireDepWithExtension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require("./deps/dep1.js");
require("./deps/dep1");
5 changes: 5 additions & 0 deletions test/fixtures/entryFiles/entryWithVariousDepExtensions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
require("./deps/default.js");
require("./deps/default.cjs");
require("./deps/default.mjs");
require("./deps/default.node");
require("./deps/default.jsx");
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/shared.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require("externalDep");
17 changes: 17 additions & 0 deletions types/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { Statement } from "meriyah/dist/src/estree.js";

export {
AstAnalyser,
AstAnalyserOptions,
EntryFilesAnalyser,
EntryFilesAnalyserOptions,
SourceParser,
runASTAnalysis,
runASTAnalysisOnFile,
Expand Down Expand Up @@ -101,5 +104,19 @@ declare class AstAnalyser {
analyzeFile(pathToFile: string, options?: RuntimeFileOptions): Promise<ReportOnFile>;
}

interface EntryFilesAnalyserOptions {
astAnalyzer?: AstAnalyser;
loadExtensions?: (defaults: string[]) => string[];
}

declare class EntryFilesAnalyser {
constructor(options?: EntryFilesAnalyserOptions);

/**
* Asynchronously analyze a set of entry files yielding analysis reports.
*/
analyse(entryFiles: (string | URL)[]): AsyncGenerator<ReportOnFile & { url: string }>;
}

declare function runASTAnalysis(str: string, options?: RuntimeOptions & AstAnalyserOptions): Report;
declare function runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions & AstAnalyserOptions): Promise<ReportOnFile>;
Loading