pankod/refine

View on GitHub
packages/cli/src/utils/swizzle/import.ts

Summary

Maintainability
C
1 day
Test Coverage
const packageRegex =
  /import(?:(?:(?:[ \n\t]+([^ *\n\t\{\},]+)[ \n\t]*(?:,|[ \n\t]+))?([ \n\t]*\{(?:[ \n\t]*[^ \n\t"'\{\}]+[ \n\t]*,?)+\})?[ \n\t]*)|[ \n\t]*\*[ \n\t]*as[ \n\t]+([^ \n\t\{\}]+)[ \n\t]+)from[ \n\t]*(?:['"])([^'"\n]+)(?:['"])(?:;?)/g;

const nameChangeRegex = /((?:\w|\s|_)*)( as )((?:\w|\s|_)*)( |,)?/g;

export type ImportMatch = {
  statement: string;
  importPath: string;
  defaultImport?: string;
  namedImports?: string;
  namespaceImport?: string;
};

export type NameChangeMatch = {
  statement: string;
  fromName: string;
  toName: string;
  afterCharacter?: string;
};

export const getImports = (content: string): Array<ImportMatch> => {
  const matches = content.matchAll(packageRegex);

  const imports: Array<ImportMatch> = [];

  for (const match of matches) {
    const [
      statement,
      defaultImport,
      namedImports,
      namespaceImport,
      importPath,
    ] = match;

    imports.push({
      statement,
      importPath,
      ...(defaultImport && { defaultImport }),
      ...(namedImports && { namedImports }),
      ...(namespaceImport && { namespaceImport }),
    });
  }

  return imports?.filter(Boolean);
};

export const getNameChangeInImport = (
  namedImportString: string,
): Array<NameChangeMatch> => {
  const matches = namedImportString.matchAll(nameChangeRegex);

  const nameChanges: Array<NameChangeMatch> = [];

  for (const match of matches) {
    const [statement, fromName, _as, toName, afterCharacter] = match;

    nameChanges.push({
      statement,
      fromName: fromName.trim(),
      toName: toName.trim(),
      afterCharacter,
    });
  }

  return nameChanges;
};

/** @internal */
export const getContentBeforeImport = (
  content: string,
  importMatch: ImportMatch,
): string => {
  // get the content before the import statement and between the last import statement and the current one
  const contentBeforeImport = content.substring(
    0,
    content.indexOf(importMatch.statement),
  );
  // get the last import statement
  const lastImportStatement = getImports(contentBeforeImport).pop();

  // if there is no last import statement, return the content before the current import statement
  if (!lastImportStatement) {
    return contentBeforeImport;
  }

  // get the content between the last import statement and the current one
  const contentBetweenImports = contentBeforeImport.substring(
    contentBeforeImport.indexOf(lastImportStatement?.statement) +
      lastImportStatement?.statement?.length,
  );

  // return the content before the current import statement and between the last import statement and the current one
  return contentBetweenImports;
};

/** @internal */
export const isImportHasBeforeContent = (
  content: string,
  importMatch: ImportMatch,
): boolean => {
  const contentBeforeImport = importMatch
    ? getContentBeforeImport(content, importMatch)
    : "";

  return !!contentBeforeImport.trim();
};

const IMPORT_ORDER = ["react", "@refinedev/core", "@refinedev/"];

export const reorderImports = (content: string): string => {
  let newContent = content;
  // imports can have comments before them, we need to preserve those comments and import statements.
  // so we need to filter out the imports with comments before.
  const allImports = getImports(content);
  // remove `import type` imports
  const allModuleImports = allImports.filter(
    (importMatch) => !importMatch.statement.includes("import type "),
  );
  const typeImports = allImports.filter((importMatch) =>
    importMatch.statement.includes("import type"),
  );

  const importsWithBeforeContent: ImportMatch[] = [];
  const importsWithoutBeforeContent: ImportMatch[] = [];

  allModuleImports.forEach((importMatch) => {
    if (isImportHasBeforeContent(content, importMatch)) {
      importsWithBeforeContent.push(importMatch);
    } else {
      importsWithoutBeforeContent.push(importMatch);
    }
  });

  // insertion point is the first import statement, others will be replaced to empty string and added to the first import line
  const insertionPoint = newContent.indexOf(
    importsWithoutBeforeContent?.[0]?.statement,
  );

  // remove all the imports without comments before
  importsWithoutBeforeContent.forEach((importMatch) => {
    newContent = newContent.replace(importMatch.statement, "");
  });

  // remove all type imports
  typeImports.forEach((importMatch) => {
    newContent = newContent.replace(importMatch.statement, "");
  });

  // we need to merge the imports from the same package unless one of them is a namespace import]
  const importsByPackage = importsWithoutBeforeContent.reduce(
    (acc, importMatch) => {
      const { importPath } = importMatch;

      if (acc[importPath]) {
        acc[importPath].push(importMatch);
      } else {
        acc[importPath] = [importMatch];
      }

      return acc;
    },
    {} as Record<string, ImportMatch[]>,
  );

  // merge the imports from the same package
  const mergedImports = Object.entries(importsByPackage).map(
    ([importPath, importMatches]) => {
      // example: A
      const defaultImport = importMatches.find(
        (importMatch) => importMatch.defaultImport,
      );

      // example: * as A
      const namespaceImport = importMatches.find(
        (importMatch) => importMatch.namespaceImport,
      );

      // example: { A, B }
      // example: { A as C, B }
      // content inside the curly braces should be merged
      const namedImports = importMatches
        .filter((importMatch) => importMatch.namedImports)
        .map((importMatch) => {
          // remove curly braces and trim then split by comma (can be multiline)
          const namedImports = (importMatch.namedImports ?? "")
            .replace(/{|}/g, "")
            .trim()
            .split(",")
            .map((namedImport) => namedImport.trim());

          return namedImports.filter(Boolean).join(", ");
        })
        .join(", ");

      let importLine = "";

      // default import and namespace import can not be used together
      // but we can use default import and named imports together
      // so we need to merge them
      if (namespaceImport) {
        importLine += `${namespaceImport.statement}\n`;
      }
      if (defaultImport || namedImports) {
        if (defaultImport && namedImports) {
          importLine += `import ${defaultImport.defaultImport}, { ${namedImports} } from "${importMatches[0].importPath}";\n`;
        } else if (defaultImport) {
          importLine += `import ${defaultImport.defaultImport} from "${importMatches[0].importPath}";\n`;
        } else {
          importLine += `import { ${namedImports} } from "${importMatches[0].importPath}";\n`;
        }
      }

      return [importPath, importLine] as [
        importPath: string,
        importLine: string,
      ];
    },
  );

  // sort the imports without comments before
  // sort should be done by IMPORT_ORDER and alphabetically
  // priority is exact match in IMPORT_ORDER, then includes match in IMPORT_ORDER, then alphabetically
  const sortedImports = [...mergedImports].sort(
    ([aImportPath], [bImportPath]) => {
      const aImportOrderIndex = IMPORT_ORDER.findIndex((order) =>
        aImportPath.includes(order),
      );
      const bImportOrderIndex = IMPORT_ORDER.findIndex((order) =>
        bImportPath.includes(order),
      );

      if (aImportOrderIndex === bImportOrderIndex) {
        return aImportPath.localeCompare(bImportPath);
      }

      if (aImportOrderIndex === -1) {
        return 1;
      }

      if (bImportOrderIndex === -1) {
        return -1;
      }

      return aImportOrderIndex - bImportOrderIndex;
    },
  );

  // add the sorted imports to the insertion point keep the before and after content
  // add the type imports after the sorted imports
  const joinedModuleImports = sortedImports
    .map(([, importLine]) => importLine)
    .join("");
  const joinedTypeImports = typeImports
    .map((importMatch) => importMatch.statement)
    .join("\n");

  newContent =
    newContent.substring(0, insertionPoint) +
    joinedModuleImports +
    joinedTypeImports +
    newContent.substring(insertionPoint);

  return newContent;
};