* Copyright (c) 2025 Huawei Technologies Co., Ltd.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import pathUtils from 'node:path';
import Case from 'case';
import { Command } from './types';
import {
AbsolutePath,
DescriptiveError,
FS,
PackageJSON,
ProjectDependenciesManager,
maybeMakeDirectories,
maybeRemoveFilesInDirectory,
} from '../core';
import {
UberSchema,
LibraryData,
UberGeneratorV1,
UberGeneratorV2,
AppBuildTimeGlueCodeGenerator,
CodegenError,
} from '../codegen';
import { Logger, RealFS } from '../io';
export const commandCodegenHarmony: Command = {
name: 'codegen-harmony',
description:
'Generates boilerplate, types, and utilities from Turbo Module and Fabric Component specifications. This command is meant to be executed during the application build process.',
options: [
{
name: '--rnoh-module-path <path>',
description: '[deprecated] Specifies the relative path to the rnoh OHOS module',
},
{
name: '--ets-output-path <path>',
description:
'Specifies the relative path to the output directory intended for storing generated ETS files.',
},
{
name: '--cpp-output-path <path>',
description:
'Specifies the relative path to the output directory intended for storing generated C++ files',
default: './harmony/entry/src/main/cpp/generated',
},
{
name: '--project-root-path <path>',
description: 'Relative path to package root',
default: '.',
},
{
name: '--debug [boolean]',
description: 'Enables logging details',
default: false,
},
{
name: '--no-safety-check [boolean]',
description:
'Skips the check that prevents file operations outside the current working directory. This command permanently deletes previously generated files. Files are generated in the path specified by cpp-output-path.',
},
],
func: async (_argv, _config, args: any) => {
const logger = new Logger();
const fs = new RealFS();
try {
const MAX_SUPPORTED_CODEGEN_VERSION = 2;
validateArgs(args);
let etsOutputPath = args.etsOutputPath || args.rnohModulePath;
const isEndWithGenerated = /\/generated\/?$/.test(etsOutputPath);
etsOutputPath = new AbsolutePath(etsOutputPath);
if (!isEndWithGenerated) {
etsOutputPath = etsOutputPath.copyWithNewSegment('generated');
}
const enableSafetyCheck: boolean = args.safetyCheck;
const cppOutputPath = new AbsolutePath(args.cppOutputPath);
const projectRootPath = new AbsolutePath(args.projectRootPath);
const uberSchemaFromArkTSLibraries = await UberSchema.fromProject(
fs,
projectRootPath,
(codegenVersion, packageName) => {
throwErrorIfUnsupportedCodegenVersion(
codegenVersion,
MAX_SUPPORTED_CODEGEN_VERSION,
packageName
);
return codegenVersion === 1;
}
);
const cApiLibrariesData = await collectLibrariesData(
fs,
projectRootPath,
(codegenVersion, packageName) => {
throwErrorIfUnsupportedCodegenVersion(
codegenVersion,
MAX_SUPPORTED_CODEGEN_VERSION,
packageName
);
return codegenVersion === 2;
}
);
const codegenNoticeLines = [
'This code was generated by "react-native codegen-harmony"',
'',
'Do not edit this file as changes may cause incorrect behavior and will be',
'lost once the code is regenerated.',
];
const uberGeneratorV1 = new UberGeneratorV1(
cppOutputPath,
etsOutputPath,
[...codegenNoticeLines, '', '@generatorVersion: 1']
);
const uberGeneratorV2 = new UberGeneratorV2(
cppOutputPath,
etsOutputPath,
[...codegenNoticeLines, '', '@generatorVersion: 2']
);
const glueCodeGenerator = new AppBuildTimeGlueCodeGenerator(
cppOutputPath,
etsOutputPath,
codegenNoticeLines
);
const fileContentByPath = new Map<AbsolutePath, string>();
const appendToFileContentByPath = (
fileContent: string,
path: AbsolutePath
) => {
fileContentByPath.set(path, fileContent);
};
uberGeneratorV1
.generate(uberSchemaFromArkTSLibraries)
.forEach(appendToFileContentByPath);
uberGeneratorV2
.generate(cApiLibrariesData)
.forEach(appendToFileContentByPath);
glueCodeGenerator
.generate({
v1: uberGeneratorV1.getGlueCodeData(),
v2: uberGeneratorV2.getGlueCodeData(),
})
.forEach(appendToFileContentByPath);
if (args.debug) {
const uberSchema = await UberSchema.fromProject(fs, projectRootPath);
logger.debug((styles) =>
styles.gray(JSON.stringify(uberSchema.getValue(), null, 2))
);
}
saveCodegenResult(
fs,
fileContentByPath,
cppOutputPath,
etsOutputPath,
enableSafetyCheck
);
logCodegenResult(logger, fileContentByPath, projectRootPath);
} catch (err) {
if (err instanceof DescriptiveError) {
logger.descriptiveError(err);
process.exit(1);
}
throw err;
}
},
};
function validateArgs(args: any) {
if (!args.rnohModulePath && !args.etsOutputPath) {
throw new DescriptiveError({
whatHappened: '--ets-output-path is required',
whatCanUserDo: [
'Please provide an output path for the ETS file.',
'It is recommended to place it in the same directory as the custom component or TurboModule.',
'Do not place it inside @rnoh/react-native-openharmony, otherwise a jscrash may occur when using react_native_openharmony_release2.har.'
],
});
}
}
async function collectLibrariesData(
fs: FS,
projectRootPath: AbsolutePath,
onShouldAcceptCodegenConfig: (version: number, packageName: string) => boolean
): Promise<LibraryData[]> {
const packageJSONs: PackageJSON[] = [
PackageJSON.fromProjectRootPath(fs, projectRootPath, projectRootPath),
];
await new ProjectDependenciesManager(fs, projectRootPath).forEachAsync(
(dependency) => {
packageJSONs.push(dependency.readPackageJSON());
}
);
const results: LibraryData[] = [];
for (const packageJSON of packageJSONs) {
const codegenConfigs = packageJSON.getCodegenConfigs();
for (const codegenConfig of codegenConfigs) {
if (
codegenConfig &&
onShouldAcceptCodegenConfig(
codegenConfig.getVersion(),
packageJSON.name
)
) {
results.push({
libraryCppName: deriveCppDirectoryNameFromNpmPackageName(
packageJSON.name
),
uberSchema: UberSchema.fromCodegenConfig(codegenConfig),
});
}
}
}
return results;
}
function throwErrorIfUnsupportedCodegenVersion(
codegenVersion: number,
maxSupportedCodegenVersion: number,
packageName: string
) {
if (codegenVersion > maxSupportedCodegenVersion) {
throw new CodegenError({
whatHappened: `Package "${packageName}" requires codegenVersion: ${codegenVersion}, which is not supported (maxSupportedCodegenVersion=${maxSupportedCodegenVersion}).`,
whatCanUserDo: [
`Try downgrading "${packageName}".`,
'Update the "@react-native-oh/react-native-harmony-cli" package.',
],
});
}
}
function saveCodegenResult(
fs: FS,
fileContentByPath: Map<AbsolutePath, string>,
cppOutputPath: AbsolutePath,
etsOutputPath: AbsolutePath,
enableSafetyCheck: boolean
) {
prepareDirectory(cppOutputPath, enableSafetyCheck);
prepareDirectory(etsOutputPath, enableSafetyCheck);
prepareDirectory(
etsOutputPath.copyWithNewSegment('components'),
enableSafetyCheck
);
prepareDirectory(
etsOutputPath.copyWithNewSegment('turboModules'),
enableSafetyCheck
);
fileContentByPath.forEach((_fileContent, path) => {
prepareDirectory(path.getDirectoryPath(), enableSafetyCheck);
});
fileContentByPath.forEach((fileContent, path) => {
fs.writeTextSync(path, fileContent);
});
}
function prepareDirectory(path: AbsolutePath, enableSafetyCheck: boolean) {
maybeMakeDirectories(path);
if (enableSafetyCheck && !path.getValue().startsWith(process.cwd())) {
throw new DescriptiveError({
whatHappened: `Tried to remove files in ${path.getValue()}\nand that path is outside current working directory`,
whatCanUserDo: ['Run codegen from different location'],
});
}
maybeRemoveFilesInDirectory(path);
}
function logCodegenResult(
logger: Logger,
fileContentByPath: Map<AbsolutePath, string>,
projectRootPath: AbsolutePath
) {
const sortedRelativePathStrings = Array.from(fileContentByPath.keys()).map(
(path) => pathUtils.relative(projectRootPath.getValue(), path.getValue())
);
sortedRelativePathStrings.sort();
sortedRelativePathStrings.forEach((pathStr) => {
logger.debug((styles) => styles.gray(`• ${pathStr}`));
});
logger.debug(() => '');
logger.info(
(styles) =>
`Generated ${styles.green(styles.bold(fileContentByPath.size))} file(s)`
);
logger.debug(() => '');
}
function deriveCppDirectoryNameFromNpmPackageName(
npmPackageName: string
): string {
let result = npmPackageName;
if (npmPackageName.includes('/')) {
result.replace('@', '');
result = npmPackageName.replace('/', '__');
}
result = Case.snake(result);
return result;
}