Skip to content

De barrelify #11

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

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
"dependencies": {
"@babel/parser": "^7.27.2",
"cli-highlight": "^2.1.11",
"enhanced-resolve": "^5.16.0",
"get-tsconfig": "^4.7.5",
"glob": "^11.0.2",
"is-builtin-module": "^4.0.0",
"recast": "^0.23.11",
"yargs": "^17.7.2"
},
Expand Down
17 changes: 17 additions & 0 deletions src/astUtils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,22 @@ const removeTypeFromTSUnionType = (node, typeToRemove) => {
node.types = node.types.filter(type => type?.literal?.value !== typeToRemove);
};

/**
* Give an ExportNamedDeclaration and a specifier, check if the specifier is in the export statement
*
* @typedef {Object} hasSpecifierParams
* @property {import("ast-types/gen/kinds").ExportNamedDeclarationKind} exportNode - the export node
* @property {import("ast-types/gen/kinds").ImportSpecifierKind} specifier - the specifier to find
*
* @param {hasSpecifierParams} param0
* @returns {boolean} - true if the specifier is in the export statement
*/
const exportNamedDeclarationHasSpecifier = ({ exportNode, specifier }) => {
return exportNode.specifiers?.some(
exportSpecifier => exportSpecifier.exported.name === specifier?.imported?.name
);
};

export {
collapseConditionalExpressionIfMatchingIdentifier,
isCallExpressionWithName,
Expand Down Expand Up @@ -378,4 +394,5 @@ export {
removeElementsStartingAtIndexAndReplaceWithNewBody,
removeImportSpecifier,
removeTypeFromTSUnionType,
exportNamedDeclarationHasSpecifier,
};
10 changes: 4 additions & 6 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env node

import { readFileSync, writeFile } from "fs";
import { sync } from "glob";
import { print } from "recast";

import { readFileSync, writeFileSync } from "node:fs";

import { parseCode } from "./parser.js";
import { AVAILABLE_TRANSFORMS } from "./availableTransforms.js";
import { validTransformName } from "./validTransformName.js";
Expand Down Expand Up @@ -37,6 +38,7 @@ filePaths.forEach(filePath => {
const node = transformer({
ast,
transformToRun: transform,
filePath,
options,
});

Expand All @@ -47,10 +49,6 @@ filePaths.forEach(filePath => {
if (dryRun) {
dryRunOutput(transformedCode, filePath);
} else {
writeFile(filePath, transformedCode, writeError => {
if (writeError) {
throw writeError();
}
});
writeFileSync(filePath, transformedCode);
}
});
4 changes: 2 additions & 2 deletions src/transformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { visit, types } from "recast";

const builder = types.builders;

const transformer = ({ ast, transformToRun, options }) => {
const visitMethods = transformToRun({ ast, builder, options });
const transformer = ({ ast, transformToRun, filePath, options }) => {
const visitMethods = transformToRun({ ast, builder, filePath, options });

const visitMethodsWithTraverse = Object.keys(visitMethods).reduce((acc, methodName) => {
// using function here for this binding
Expand Down
29 changes: 29 additions & 0 deletions src/transforms/deBarrelify/barrelFileUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Given path, determines if it is a barrel file (index file)
*
* @param {string} path
* @returns {boolean}
*/
const isBarrelFile = path => {
// TODO: add support for specifying a list of barrel file names
return (
path.endsWith("/index.ts") ||
path.endsWith("/index.tsx") ||
path.endsWith("/helpers.ts") ||
path.endsWith("/helpers.tsx")
);
};

/**
* Given a path, remove the barrel file name from it
* ex: src/components/index.ts -> src/components/
*
* @param {string} path
* @returns {string} - the path with the barrel file name removed
*/
const removeBarrelFileFromPath = path => {
// TODO: also need to modify this to account for different barrel file names
return path.replace(/(index|helpers)\.tsx?/, "");
};

export { isBarrelFile, removeBarrelFileFromPath };
247 changes: 247 additions & 0 deletions src/transforms/deBarrelify/deBarrelify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { readFileSync } from "node:fs";
import { parseCode } from "../../parser.js";
import { visit } from "recast";
import isBuiltinModule from "is-builtin-module";

import { resolveAbsolutePath } from "./resolver.js";
import { isBarrelFile, removeBarrelFileFromPath } from "./barrelFileUtils.js";

import { replaceImportDeclarationWithDeepImport } from "./replaceImportDeclarationWithDeepImport/replaceImportDeclarationWithDeepImport.js";
import { exportNamedDeclarationHasSpecifier } from "../../astUtils/index.js";

/**
* Type definitions for VSCode autocompletion!
*
* @typedef {Object} TransformParams
* @property {*} ast - The resulting AST as parsed by babel
* @property {import("ast-types/gen/builders").builders} builder - Recast builder for transforming the AST
* @property {*} options - Options passed into the transform from the CLI (if any)
*/

/**
* @typedef {Object} findSpecifierSourceParams
* @property {string} filePath - the file path
* @property {import("ast-types/gen/kinds").ImportSpecifierKind} specifier - the specifier to find
*
* @typedef {Object} findSpecifierSourceResult
* @property {string|undefined} specifierSource - the found specifier
* @property {boolean} importAs - true if the specifier was imported as wildcard
* @property {string[]} potentialSpecifierSources - the potential specifier sources
*
* @param {findSpecifierSourceParams}
* @returns {findSpecifierSourceResult}
*/
const findSpecifierSource = ({ filePath, specifier }) => {
let specifierSource;
let importAs = false;
let potentialSpecifierSources = [];

const code = readFileSync(filePath, { encoding: "utf-8", flag: "r" });
const barrelFileAst = parseCode(code);

visit(barrelFileAst, {
visitImportDeclaration: function (importPath) {
if (importPath.node.specifiers.some(spec => spec.local.name === specifier?.imported?.name)) {
// DONE: identify the import name
specifierSource = importPath.node.source.value;
importAs = true;
return false; // stop parsing, found what we needed
}

this.traverse(importPath);
},
visitExportNamedDeclaration: function (exportPath) {
if (exportNamedDeclarationHasSpecifier({ exportNode: exportPath.node, specifier })) {
if (exportPath.node.source) {
// DONE: identify the export name
specifierSource = exportPath.node?.source?.value;
} else if (!specifierSource) {
// prevent conflict with visitImportDeclaration if the specifierSource is already set
// This can conflict in cases where an import statement is used to load a module which is then exported as a separate statement
// ex: app/javascript/styles/index.ts
// If the export statement doesn't have a source, it's likely defined in the current filePath, set that as the returned source
specifierSource = filePath;
}
return false; // stop parsing, found what we needed
}

this.traverse(exportPath);
},
visitExportAllDeclaration: function (exportPath) {
potentialSpecifierSources.push(exportPath.node.source.value);
this.traverse(exportPath);
},

// Look at adding support for visitExportNamedDeclaration with wildcard
});

return { specifierSource, importAs, potentialSpecifierSources };
};

/**
* @typedef {Object} transformImportParams
* @property {import("ast-types/gen/builders").builders} builder - Recast builder for transforming the AST
* @property {import("ast-types/lib/node-path").NodePath<import("ast-types/gen/kinds").ImportDeclarationKind, any>} path - the path to replace
* @property {string} importSource - import source
* @property {import("ast-types/gen/kinds").ImportSpecifierKind} specifier - the specifier to import
*
* @param {transformImportParams} param0
* @returns {boolean} - true if the import was transformed
*/
const transformImport = ({ builder, path, importSource, specifier }) => {
// DONE: visit the barrel file and parse it's contents into an AST
const { specifierSource, importAs, potentialSpecifierSources } = findSpecifierSource({
filePath: importSource,
specifier,
});

// If there's no found specifier, we don't do anything more
// If specifier if the same as the importSource, we don't do anything more
if (specifierSource && specifierSource !== importSource) {
const deeperResolvedPath = resolveAbsolutePath({
context: {},
resolveContext: {},
folderPath: removeBarrelFileFromPath(`./${importSource}`),
importSource: specifierSource,
});

// DONE: if it's a not a barrel file, create a new importDeclaration for this specifier with the path to this file
if (!isBarrelFile(deeperResolvedPath)) {
replaceImportDeclarationWithDeepImport({
builder,
path,
newImportSource: deeperResolvedPath,
specifier,
importAs,
});

return true;
} else {
// DONE: if it's a barrel file, go down again
return transformImport({
builder,
path,
importSource: deeperResolvedPath,
specifier,
});
}
} else if (potentialSpecifierSources.length > 0) {
// loop through each potentialSpecifierSource, dive into if further and check if the specifier is found
// if it is, replace the import with the deeperResolvedPath
// if it's not, continue to the next potentialSpecifierSource
// if nothing is found, do nothing

for (let i = 0; i < potentialSpecifierSources.length; i++) {
const deeperResolvedPath = resolveAbsolutePath({
context: {},
resolveContext: {},
folderPath: removeBarrelFileFromPath(`./${importSource}`),
importSource: potentialSpecifierSources[i],
});

if (isBarrelFile(deeperResolvedPath)) {
const wasImportTransformed = transformImport({
builder,
path,
importSource: deeperResolvedPath,
specifier,
});

if (wasImportTransformed) {
return true;
}
} else {
replaceImportDeclarationWithDeepImport({
builder,
path,
newImportSource: deeperResolvedPath,
specifier,
importAs,
});

return true;
}
}
}

return false;
};

/**
* @typedef {Object} isSpecifiedNamesToIgnoreParams
* @property {import("ast-types/lib/node-path").NodePath<import("ast-types/gen/kinds").ImportDeclarationKind, any>} path - the path to replace
* @property {string} specifiedNamespace - import namespace to operate on
*
* @param {isSpecifiedNamesToIgnoreParams} param0
* @returns {boolean} - true if the import should be transformed
*/
const isSpecifiedNamespaceToTransform = ({ path, specifiedNamespace }) => {
return specifiedNamespace && path.node.source.value.startsWith(specifiedNamespace);
};

/**
* @typedef {Object} isSpecifiedNamesToIgnoreParams
* @property {import("ast-types/lib/node-path").NodePath<import("ast-types/gen/kinds").ImportDeclarationKind, any>} path - the path to replace
* @property {string} specifiedNamespace - import namespace to operate on
*
* @param {isSpecifiedNamesToIgnoreParams} param0
* @returns {boolean} - true if the import should be ignored
*/
const isNotSpecifiedNamespaceToTransform = ({ path, specifiedNamespace }) => {
return specifiedNamespace && !path.node.source.value.startsWith(specifiedNamespace);
};

/**
* @param {TransformParams} param0
* @returns {import("ast-types").Visitor}
*/
const transform = ({ builder, filePath, options }) => {
const folderPath = filePath.split("/").slice(0, -1).join("/");
const specifiedNamespace = options?.[0];
const ignoreSpecifiedNamespace = !!options?.[1];

return {
visitImportDeclaration: path => {
let importTransformed = false;

// Verify that the import is not a node builtin module
if (
((isSpecifiedNamespaceToTransform({ path, specifiedNamespace }) &&
!ignoreSpecifiedNamespace) ||
!specifiedNamespace ||
(isNotSpecifiedNamespaceToTransform({ path, specifiedNamespace }) &&
ignoreSpecifiedNamespace)) &&
!isBuiltinModule(path.node.source.value)
) {
const resolvedPath = resolveAbsolutePath({
context: {},
resolveContext: {},
folderPath,
importSource: path.node.source.value,
});

if (resolvedPath && isBarrelFile(resolvedPath)) {
path.node.specifiers.forEach(specifier => {
const wasImportTransformed = transformImport({
builder,
path,
importSource: resolvedPath,
specifier,
});

if (wasImportTransformed && !importTransformed) {
importTransformed = true;
}
});
}

// DONE: If the import was transformed into a deep import, delete the original import
if (importTransformed) {
path.prune();
}
}
},
};
};

export { transform };
Loading