Skip to content

Commit

Permalink
Add transform that replaces requires with imports (#49228)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: #49228

Changelog: [Internal]

Reviewed By: huntie

Differential Revision: D69247874

fbshipit-source-id: 8b4ea4b358c1c8714237e0109cf5a9492528016b
  • Loading branch information
j-piasecki authored and facebook-github-bot committed Feb 10, 2025
1 parent 47a2174 commit c0415ad
Show file tree
Hide file tree
Showing 3 changed files with 331 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

const replaceRequiresWithImports = require('../replaceRequiresWithImports.js');
const {parse, print} = require('hermes-transform');

const prettierOptions = {parser: 'babel'};

async function translate(code: string): Promise<string> {
const parsed = await parse(code);
const result = await replaceRequiresWithImports(parsed);
return print(result.ast, result.mutatedCode, prettierOptions);
}

describe('replaceRequiresWithImports', () => {
test('should replace require().default with a default import', async () => {
const code = `
const Foo: mixed = require('./Foo').default;
`;
const result = await translate(code);
expect(result).toMatchInlineSnapshot(`
"import Foo from \\"./Foo\\";
"
`);
});

test('should replace require() with a namespace import', async () => {
const code = `
const Foo: mixed = require('./Foo');
`;
const result = await translate(code);
expect(result).toMatchInlineSnapshot(`
"import * as Foo from \\"./Foo\\";
"
`);
});

test('should replace require().member with an import', async () => {
const code = `
const Foo: mixed = require('./Foo').foo;
`;
const result = await translate(code);
expect(result).toMatchInlineSnapshot(`
"import { foo as Foo } from \\"./Foo\\";
"
`);
});

test('should replace require()["member"] with an import', async () => {
const code = `
const Foo: mixed = require('./Foo')["foo"];
`;
const result = await translate(code);
expect(result).toMatchInlineSnapshot(`
"import { foo as Foo } from \\"./Foo\\";
"
`);
});

test('should replace spread require() with an import', async () => {
const code = `
const { foo, bar } = require('./Foo');
`;
const result = await translate(code);
expect(result).toMatchInlineSnapshot(`
"import { foo, bar } from \\"./Foo\\";
"
`);
});

test('should replace aliased spread require() with an import', async () => {
const code = `
const { foo: otherFoo, bar: otherBar } = require('./Foo');
`;
const result = await translate(code);
expect(result).toMatchInlineSnapshot(`
"import { foo as otherFoo, bar as otherBar } from \\"./Foo\\";
"
`);
});

test('should handle aliased and non-aliased values in spread require() with an import', async () => {
const code = `
const { default: defaultFoo, foo } = require('./Foo');
`;
const result = await translate(code);
expect(result).toMatchInlineSnapshot(`
"import { default as defaultFoo, foo } from \\"./Foo\\";
"
`);
});

test('should ignore unbound requires', async () => {
const code = `
require('./Foo');
`;
const result = await translate(code);
expect(result).toMatchInlineSnapshot(`
"require(\\"./Foo\\");
"
`);
});

test('should ignore local requires', async () => {
const code = `
function foo() {
const bar = require('./Bar');
}
`;
const result = await translate(code);
expect(result).toMatchInlineSnapshot(`
"function foo() {
const bar = require(\\"./Bar\\");
}
"
`);
});

test('should throw when encountering spread operator', async () => {
const tranlsateFn = async () => {
const code = `
const { foo, ...rest } = require('./Foo');
`;
await translate(code);
};
await expect(tranlsateFn()).rejects.toThrow();
});
});
194 changes: 194 additions & 0 deletions scripts/build/build-types/transforms/replaceRequiresWithImports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

import type {TransformVisitor} from 'hermes-transform';
import type {ParseResult} from 'hermes-transform/dist/transform/parse';
import type {TransformASTResult} from 'hermes-transform/dist/transform/transformAST';

const {transformAST} = require('hermes-transform/dist/transform/transformAST');

const visitors: TransformVisitor = context => ({
VariableDeclaration(node): void {
if (node.parent?.type !== 'Program') {
// Ignore declarations that are not in the top-level scope
return;
}

if (node.declarations.length !== 1) {
// Ignore mutliple declarations for now, those can be implemented if it
// turns out to be necessary.
return;
}

// Handle simple cases where `require` call is the only expression on the RHS
if (
node.declarations[0].init?.type === 'CallExpression' &&
node.declarations[0].init.callee.type === 'Identifier' &&
node.declarations[0].init.callee.name === 'require'
) {
const requiredModule = node.declarations[0].init.arguments[0];

if (node.declarations[0].id.type === 'Identifier') {
// $FlowExpectedError[incompatible-call] - we are replacing an expression with a statement but in the top-level scope
context.replaceNode(node, {
type: 'ImportDeclaration',
source: requiredModule,
specifiers: [
{
type: 'ImportNamespaceSpecifier',
local: {
type: 'Identifier',
name: node.declarations[0].id.name,
optional: false,
},
},
],
});
} else if (node.declarations[0].id.type === 'ObjectPattern') {
// $FlowExpectedError[incompatible-call] - we are replacing an expression with a statement but in the top-level scope
context.replaceNode(node, {
type: 'ImportDeclaration',
source: requiredModule,
specifiers: node.declarations[0].id.properties.map(property => {
if (property.type !== 'Property') {
throw new Error('Unexpected property type: ' + property.type);
}

if (property.key.type !== 'Identifier') {
throw new Error('Unexpected key type: ' + property.key.type);
}

if (property.value.type !== 'Identifier') {
throw new Error('Unexpected value type: ' + property.value.type);
}

return {
type: 'ImportSpecifier',
local: {
type: 'Identifier',
name: property.value.name,
optional: false,
},
imported: {
type: 'Identifier',
name: property.key.name,
optional: false,
},
};
}),
});
}
}

// Handle cases where `require` call is the first expression in a member expression
if (
node.declarations[0].init?.type === 'MemberExpression' &&
node.declarations[0].init.object.type === 'CallExpression' &&
node.declarations[0].init.object.callee.type === 'Identifier' &&
node.declarations[0].init.object.callee.name === 'require' &&
node.declarations[0].id.type === 'Identifier'
) {
const requiredModule = node.declarations[0].init.object.arguments[0];
const variableName = node.declarations[0].id.name;

// Handle member access via dot operator
if (node.declarations[0].init.property.type === 'Identifier') {
// Special treatment for `require().default` case to transform it to
// a default import
if (node.declarations[0].init.property.name === 'default') {
// $FlowExpectedError[incompatible-call] - we are replacing an expression with a statement but in the top-level scope
context.replaceNode(node, {
type: 'ImportDeclaration',
source: requiredModule,
specifiers: [
{
type: 'ImportDefaultSpecifier',
local: {
type: 'Identifier',
name: variableName,
optional: false,
},
},
],
});
} else {
// $FlowExpectedError[incompatible-call] - we are replacing an expression with a statement but in the top-level scope
context.replaceNode(node, {
type: 'ImportDeclaration',
source: requiredModule,
specifiers: [
{
type: 'ImportSpecifier',
local: {
type: 'Identifier',
name: variableName,
optional: false,
},
imported: {
type: 'Identifier',
name: node.declarations[0].init.property.name,
optional: false,
},
},
],
});
}
} else if (node.declarations[0].init.property.type === 'Literal') {
// Handle member access via bracket notation
// $FlowExpectedError[incompatible-call] - we are replacing an expression with a statement but in the top-level
context.replaceNode(node, {
type: 'ImportDeclaration',
source: requiredModule,
specifiers: [
{
type: 'ImportSpecifier',
local: {
type: 'Identifier',
name: variableName,
optional: false,
},
imported: {
type: 'Identifier',
name: node.declarations[0].init.property.value,
optional: false,
},
},
],
});
}
}
},
});

/**
* Replace `require` calls with `import` statements.
*
* In the type-land top-level requires can safely be replaced with import
* statements without impacring the runtime. This allows the modern Flow toolkit
* to be used in existing codebases without having to update each file still
* relying on require syntax.
*
* It's expecially useful in more complex cases where a type comes from an import
* but the implementation comes from a require, like so:
* import typeof FooClassT from './Foo';
* const FooClass: FooClassT = require('./Foo').default;
* const Foo: FooClass = new FooClass();
*
* Where the types would diverge in the resulting TS output generated by
* flow-api-translator.
*/
async function replaceRequiresWithImports(
source: ParseResult,
): Promise<TransformASTResult> {
return transformAST(source, visitors);
}

module.exports = replaceRequiresWithImports;
1 change: 1 addition & 0 deletions scripts/build/build-types/translateSourceFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type TransformFn = ParseResult => Promise<TransformASTResult>;

const preTransforms: Array<TransformFn> = [
require('./transforms/stripPrivateProperties'),
require('./transforms/replaceRequiresWithImports'),
];
const prettierOptions = {parser: 'babel'};
const unsupportedFeatureRegex =
Expand Down

0 comments on commit c0415ad

Please sign in to comment.