Skip to content
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