diff --git a/README.md b/README.md index b7d1550..38e8b8a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Config plugin to auto-configure [`react-native-code-push`][lib] when the native ### Add the package to your npm dependencies -> Tested against Expo SDK 50 +> Prebuild is tested against Expo SDK 50 and SDK 57. Android is covered on both; +> on iOS the plugin writes `Info.plist` but does not yet touch the Swift +> `AppDelegate` that SDK 52 and up generate. ``` yarn add react-native-code-push react-native-code-push-plugin diff --git a/src/android/__tests__/expo-templates.test.ts b/src/android/__tests__/expo-templates.test.ts new file mode 100644 index 0000000..5c4cf0e --- /dev/null +++ b/src/android/__tests__/expo-templates.test.ts @@ -0,0 +1,123 @@ +import { applyImplementation } from "../buildscript-dependency"; +import { applyMainApplication } from "../main-application-dependency"; +import { + sdk49MainApplication, + sdk50AppBuildGradle, + sdk50MainApplication, + sdk57AppBuildGradle, + sdk57MainApplication, +} from "./templates"; + +describe("app/build.gradle", () => { + it("applies codepush.gradle on SDK 57, where no `apply from:` is left", () => { + expect(sdk57AppBuildGradle).not.toContain("apply from:"); + + const result = applyImplementation(sdk57AppBuildGradle); + + expect(result).toContain("apply from:"); + expect(result).toContain('"android/codepush.gradle"'); + }); + + it("applies codepush.gradle last, after `android` and `react` are configured", () => { + for (const template of [sdk50AppBuildGradle, sdk57AppBuildGradle]) { + const result = applyImplementation(template); + + expect(result.trimEnd().split("\n").at(-1)).toContain( + "android/codepush.gradle", + ); + // codepush.gradle iterates `android.buildTypes` as it is applied. + expect(result.indexOf("codepush.gradle")).toBeGreaterThan( + result.indexOf("android {"), + ); + } + }); + + it("leaves everything above it alone", () => { + const result = applyImplementation(sdk57AppBuildGradle); + + expect(result.startsWith(sdk57AppBuildGradle.trimEnd())).toBe(true); + }); + + it("is idempotent on both templates", () => { + for (const template of [sdk50AppBuildGradle, sdk57AppBuildGradle]) { + const once = applyImplementation(template); + + expect(applyImplementation(once)).toBe(once); + } + }); +}); + +describe("MainApplication", () => { + it("passes the CodePush bundle to the SDK 57 ReactHost factory", () => { + const result = applyMainApplication(sdk57MainApplication, "kt"); + + expect(result).toContain("import com.microsoft.codepush.react.CodePush"); + expect(result).toContain("jsBundleFilePath = CodePush.getJSBundleFile()"); + // Kotlin evaluates arguments in order, and getJSBundleFile() throws until + // PackageList has constructed the CodePush instance. + expect(result.indexOf("jsBundleFilePath")).toBeGreaterThan( + result.indexOf("PackageList(this).packages"), + ); + }); + + it("closes the SDK 57 factory call where it found it", () => { + const result = applyMainApplication(sdk57MainApplication, "kt"); + + // The commented-out `add(MyReactNativePackage())` sits between the opening + // parenthesis and the closing one, so a naive search finds the wrong `)`. + expect( + result.slice( + result.indexOf(" ExpoReactHostFactory"), + result.indexOf("\n }\n"), + ), + ).toBe(` ExpoReactHostFactory.getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + }, + jsBundleFilePath = CodePush.getJSBundleFile() + )`); + }); + + it("still overrides getJSBundleFile on the Kotlin ReactNativeHost template", () => { + const result = applyMainApplication(sdk50MainApplication, "kt"); + + expect(result).toContain("import com.microsoft.codepush.react.CodePush"); + expect(result).toContain( + "override fun getJSBundleFile(): String? = CodePush.getJSBundleFile()", + ); + expect(result).not.toContain("jsBundleFilePath"); + }); + + it("still overrides getJSBundleFile on the Java template, semicolons included", () => { + const result = applyMainApplication(sdk49MainApplication, "java"); + + expect(result).toContain("import com.microsoft.codepush.react.CodePush;"); + expect(result).toContain("return CodePush.getJSBundleFile();"); + expect(result).toContain("@Override"); + }); + + it("is idempotent on every template", () => { + const cases = [ + [sdk57MainApplication, "kt"], + [sdk50MainApplication, "kt"], + [sdk49MainApplication, "java"], + ] as const; + + for (const [template, language] of cases) { + const once = applyMainApplication(template, language); + + expect(applyMainApplication(once, language)).toBe(once); + } + }); + + it("says what it looked for when the template has neither hook", () => { + const unknown = `package com.example\n\nimport android.app.Application\n\nclass MainApplication : Application()\n`; + + expect(() => applyMainApplication(unknown, "kt")).toThrow( + /ExpoReactHostFactory\.getDefaultReactHost/, + ); + }); +}); diff --git a/src/android/__tests__/templates.ts b/src/android/__tests__/templates.ts new file mode 100644 index 0000000..d114505 --- /dev/null +++ b/src/android/__tests__/templates.ts @@ -0,0 +1,139 @@ +/** + * Trimmed copies of what `npx expo prebuild` writes, kept verbatim around the + * parts the mods touch. Regenerate by prebuilding a blank app on the SDK named. + */ + +/** Expo SDK 50, react-native 0.73. Autolinking is applied from the app module. */ +export const sdk50AppBuildGradle = `apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" +} + +android { + namespace "com.anonymous.sdk50app" +} + +dependencies { + implementation("com.facebook.react:react-android") +} + +apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), "../native_modules.gradle"); +applyNativeModulesAppBuildGradle(project) +`; + +/** + * Expo SDK 57, react-native 0.86. Autolinking moved into + * `autolinkLibrariesWithApp()`, so the file has no `apply from:` left. + */ +export const sdk57AppBuildGradle = `apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +android { + namespace "com.anonymous.sdk57app" +} + +dependencies { + implementation("com.facebook.react:react-android") +} +`; + +/** Expo SDK 50, Kotlin, `ReactNativeHost` wrapped by Expo. */ +export const sdk50MainApplication = `package com.anonymous.sdk50app + +import android.app.Application + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeHost +import com.facebook.react.defaults.DefaultReactNativeHost + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List { + return PackageList(this).packages + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + } + ) +} +`; + +/** Expo SDK 57, Kotlin. No `ReactNativeHost`, no method left to override. */ +export const sdk57MainApplication = `package com.anonymous.sdk57app + +import android.app.Application + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactHost + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ExpoReactHostFactory + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + ExpoReactHostFactory.getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + ) + } + + override fun onCreate() { + super.onCreate() + loadReactNative(this) + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } +} +`; + +/** Expo SDK 49, Java. */ +export const sdk49MainApplication = `package com.anonymous.sdk49app; + +import android.app.Application; + +import com.facebook.react.ReactNativeHost; +import com.facebook.react.defaults.DefaultReactNativeHost; + +import expo.modules.ApplicationLifecycleDispatcher; +import expo.modules.ReactNativeHostWrapper; + +public class MainApplication extends Application implements ReactApplication { + private final ReactNativeHost mReactNativeHost = new ReactNativeHostWrapper( + this, + new DefaultReactNativeHost(this) { + @Override + public boolean getUseDeveloperSupport() { + return BuildConfig.DEBUG; + } + } + ); +} +`; diff --git a/src/android/buildscript-dependency.ts b/src/android/buildscript-dependency.ts index f60ae6b..64bf8df 100644 --- a/src/android/buildscript-dependency.ts +++ b/src/android/buildscript-dependency.ts @@ -3,74 +3,55 @@ import type { ConfigPlugin } from "expo/config-plugins"; import { withAppBuildGradle } from "expo/config-plugins"; -import { addBelowAnchorIfNotFound } from "../utils/add-below-anchor-if-not-found"; import { codePushGradlePath } from "../utils/code-push-gradle-path"; +/** + * Appends `apply from: ` to the app module's build.gradle. + * + * This used to hunt for the `apply from: ... native_modules.gradle` line and + * insert below it. That line was never the point: it just happened to be the + * last statement in the RN 0.71-0.73 template. RN 0.75 folded autolinking into + * `autolinkLibrariesWithApp()` inside the `react { }` block, so by Expo SDK 56 + * there is no `apply from:` in the file at all and the search threw. + * + * The end of the file is where the apply has to go regardless of template. + * `codepush.gradle` reads the `react` extension and iterates + * `android.buildTypes` while it is being applied, so it has to run after both + * blocks have been configured. Nothing in it depends on autolinking, so running + * after that is fine too. + */ export function applyImplementation(appBuildGradle: string) { - const codePushImplementation = `apply from: ${codePushGradlePath( - "android/codepush.gradle", - )}`; - - // Make sure the project does not have the dependency already, in any of the - // shapes we have generated over time. + // Every shape this plugin has ever written names the file, so one check + // covers projects prebuilt by an older version. if (appBuildGradle.includes("codepush.gradle")) { return appBuildGradle; } - // The default on Expo 50 - const reactNative73Include = `apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), "../native_modules.gradle");`; - if (appBuildGradle.includes(reactNative73Include)) { - return addBelowAnchorIfNotFound( - appBuildGradle, - reactNative73Include, - codePushImplementation, - ); - } - - // Seems to be the default on Expo 49 - const reactNative71Include = `apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json')"].execute(null, rootDir).text.trim(), "../native_modules.gradle");`; - if (appBuildGradle.includes(reactNative71Include)) { - return addBelowAnchorIfNotFound( - appBuildGradle, - reactNative71Include, - codePushImplementation, - ); - } - - // For compatibility - const reactNativeFileClassGradleInclude = `'apply from: new File(reactNativeRoot, "react.gradle")`; - if (appBuildGradle.includes(reactNativeFileClassGradleInclude)) { - return addBelowAnchorIfNotFound( - appBuildGradle, - reactNativeFileClassGradleInclude, - codePushImplementation, - ); - } - - // For compatibility - const reactNativeRawGradleInclude = `apply from: "../../node_modules/react-native/react.gradle"`; - if (appBuildGradle.includes(reactNativeRawGradleInclude)) { - return addBelowAnchorIfNotFound( - appBuildGradle, - reactNativeRawGradleInclude, - codePushImplementation, - ); - } + const codePushImplementation = `apply from: ${codePushGradlePath( + "android/codepush.gradle", + )}`; - throw new Error( - "Cannot find a suitable place to insert the CodePush buildscript dependency.", - ); + return `${appBuildGradle.trimEnd()}\n\n${codePushImplementation}\n`; } /** - * Update `/build.gradle` by adding the codepush.gradle file - * as an additional build task definition underneath react.gradle + * Update `/android/app/build.gradle` by applying the codepush.gradle + * file, which registers the bundle-hash tasks CodePush needs at build time. * https://github.com/microsoft/react-native-code-push/blob/master/docs/setup-android.md#plugin-installation-and-configuration-for-react-nactive-060-version-and-above-android */ export const withAndroidBuildscriptDependency: ConfigPlugin< PluginConfigType > = (config) => { return withAppBuildGradle(config, (buildGradleProps) => { + // `apply from:` is Groovy. Expo's template is Groovy, but a project that + // switched to the Kotlin DSL would get a build file that no longer parses, + // so say what happened instead of writing it. + if (buildGradleProps.modResults.language !== "groovy") { + throw new Error( + `Cannot apply codepush.gradle: android/app/build.gradle is ${buildGradleProps.modResults.language}, and this plugin only writes the Groovy syntax Expo's template uses.`, + ); + } + buildGradleProps.modResults.contents = applyImplementation( buildGradleProps.modResults.contents, ); diff --git a/src/android/main-application-dependency.ts b/src/android/main-application-dependency.ts index c870440..b774c8c 100644 --- a/src/android/main-application-dependency.ts +++ b/src/android/main-application-dependency.ts @@ -4,101 +4,87 @@ import type { ConfigPlugin } from "expo/config-plugins"; import { withMainApplication } from "expo/config-plugins"; import { addBelowAnchorIfNotFound } from "../utils/add-below-anchor-if-not-found"; +import { addImport } from "../utils/add-import"; +import { insertLastArgument } from "../utils/insert-last-argument"; + +type Language = "java" | "kt"; + +const CODE_PUSH_CLASS = "com.microsoft.codepush.react.CodePush"; + +/** Once this reads back, the file has already been through here. */ +const CODE_PUSH_BUNDLE_CALL = "CodePush.getJSBundleFile()"; /** - * Updates the `MainApplication.java` by adding the CodePush runtime initialization code - * https://github.com/microsoft/react-native-code-push/blob/master/docs/setup-android.md#plugin-installation-and-configuration-for-react-native-060-version-and-above-android + * Expo SDK 56 dropped `ReactNativeHost` from the template. `MainApplication` + * now builds a `ReactHost` through this factory, which takes the bundle path as + * an argument instead of exposing a method to override. */ -export const withAndroidMainApplicationDependency: ConfigPlugin< - PluginConfigType -> = (config) => { - return withMainApplication(config, (mainApplicationProps) => { - // Import the plugin class. - const hostWrapperClass = "import expo.modules.ReactNativeHostWrapper"; - const codePushClass = "import com.microsoft.codepush.react.CodePush"; - - // Expo 49 uses Java and requires the ; - if ( - mainApplicationProps.modResults.contents.includes(`${hostWrapperClass};`) - ) { - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - `${hostWrapperClass};`, - `${codePushClass};`, - ); - } +const REACT_HOST_FACTORY = "ExpoReactHostFactory.getDefaultReactHost"; - // Expo 50 uses Kotlin and does not require the ; - else if ( - mainApplicationProps.modResults.contents.includes(hostWrapperClass) - ) { - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - hostWrapperClass, - codePushClass, - ); - } +/** + * Points the React runtime at the bundle CodePush picked: the update it + * downloaded, or the one baked into the APK when there is nothing newer. + * https://github.com/microsoft/react-native-code-push/blob/master/docs/setup-android.md#plugin-installation-and-configuration-for-react-native-060-version-and-above-android + */ +export function applyMainApplication(contents: string, language: Language) { + if (contents.includes(CODE_PUSH_BUNDLE_CALL)) return contents; - /** - * Override the getJSBundleFile method in order to let - * the CodePush runtime determine where to get the JS - * bundle location from on each app start - */ + const withImport = addImport(contents, CODE_PUSH_CLASS, language); - // The default on Expo 50, which uses kotlin - const kotlinAnchor = `override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"`; - if (mainApplicationProps.modResults.contents.includes(kotlinAnchor)) { - const kotlinJSBundleFileOverride = ` - override fun getJSBundleFile(): String? { - return CodePush.getJSBundleFile() - } - `; - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - kotlinAnchor, - kotlinJSBundleFileOverride, - ); - return mainApplicationProps; - } + // Expo SDK 56 and up. The argument has to be appended rather than slotted in + // anywhere: Kotlin evaluates arguments in source order, and + // `CodePush.getJSBundleFile()` throws until a `CodePush` instance exists, + // which the `packageList` argument above it is what creates. + const reactHost = insertLastArgument( + withImport, + REACT_HOST_FACTORY, + `jsBundleFilePath = ${CODE_PUSH_BUNDLE_CALL}`, + ); + if (reactHost !== null) return reactHost; - const javaJSBundleFileOverride = ` + // Expo SDK 49 through 55, and anything else still on `ReactNativeHost`. Both + // languages share the anchor: Kotlin writes + // `object : DefaultReactNativeHost(this) {`, Java writes + // `new DefaultReactNativeHost(this) {`. + const override = + language === "kt" + ? ` + override fun getJSBundleFile(): String? = ${CODE_PUSH_BUNDLE_CALL} +` + : ` @Override protected String getJSBundleFile() { - return CodePush.getJSBundleFile(); - }\n`; - - // The default on Expo 49 - const defaultReactNativeAnchor = "new DefaultReactNativeHost(this) {"; - if ( - mainApplicationProps.modResults.contents.includes( - defaultReactNativeAnchor, - ) - ) { - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - defaultReactNativeAnchor, - javaJSBundleFileOverride, - ); + return ${CODE_PUSH_BUNDLE_CALL}; + } +`; - return mainApplicationProps; + for (const anchor of [ + "DefaultReactNativeHost(this) {", + "ReactNativeHost(this) {", + ]) { + if (withImport.includes(anchor)) { + return addBelowAnchorIfNotFound(withImport, anchor, override); } + } - // This is for compatibility, as it follows the Codepush instructions up-to-spec. - const reactNativeHostAnchor = "new ReactNativeHost(this) {"; - if ( - mainApplicationProps.modResults.contents.includes(reactNativeHostAnchor) - ) { - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - reactNativeHostAnchor, - javaJSBundleFileOverride, - ); + throw new Error( + `Cannot find a suitable place to insert the CodePush getJSBundleFile code. MainApplication has neither a "${REACT_HOST_FACTORY}" call nor a "ReactNativeHost" subclass to hook into.`, + ); +} - return mainApplicationProps; - } - - throw new Error( - "Cannot find a suitable place to insert the CodePush getJSBundleFile code.", +/** + * Updates `MainApplication` so the CodePush runtime decides where the JS bundle + * comes from on each app start. + */ +export const withAndroidMainApplicationDependency: ConfigPlugin< + PluginConfigType +> = (config) => { + return withMainApplication(config, (mainApplicationProps) => { + mainApplicationProps.modResults.contents = applyMainApplication( + mainApplicationProps.modResults.contents, + mainApplicationProps.modResults.language, ); + + return mainApplicationProps; }); }; diff --git a/src/utils/__tests__/add-import.test.ts b/src/utils/__tests__/add-import.test.ts new file mode 100644 index 0000000..a7b2e58 --- /dev/null +++ b/src/utils/__tests__/add-import.test.ts @@ -0,0 +1,62 @@ +import { addImport } from "../add-import"; + +const kotlin = `package com.example + +import android.app.Application +import com.facebook.react.ReactApplication + +class MainApplication : Application() +`; + +describe("addImport", () => { + it("adds the import below the last one", () => { + const result = addImport( + kotlin, + "com.microsoft.codepush.react.CodePush", + "kt", + ); + + expect(result).toContain( + "import com.facebook.react.ReactApplication\nimport com.microsoft.codepush.react.CodePush\n", + ); + }); + + it("terminates Java imports with a semicolon", () => { + const java = `package com.example;\n\nimport android.app.Application;\n`; + + expect( + addImport(java, "com.microsoft.codepush.react.CodePush", "java"), + ).toContain("import com.microsoft.codepush.react.CodePush;"); + }); + + it("does not add the same import twice", () => { + const once = addImport( + kotlin, + "com.microsoft.codepush.react.CodePush", + "kt", + ); + + expect(addImport(once, "com.microsoft.codepush.react.CodePush", "kt")).toBe( + once, + ); + }); + + it("does not mistake a longer path for the same import", () => { + const result = addImport( + kotlin, + "com.facebook.react.ReactApplicationContext", + "kt", + ); + + expect(result).toContain( + "import com.facebook.react.ReactApplicationContext", + ); + expect(result).toContain("import com.facebook.react.ReactApplication\n"); + }); + + it("throws when there is no import block to anchor to", () => { + expect(() => addImport("package com.example\n", "a.B", "kt")).toThrow( + /no import statements/, + ); + }); +}); diff --git a/src/utils/__tests__/insert-last-argument.test.ts b/src/utils/__tests__/insert-last-argument.test.ts new file mode 100644 index 0000000..605f2a4 --- /dev/null +++ b/src/utils/__tests__/insert-last-argument.test.ts @@ -0,0 +1,75 @@ +import { insertLastArgument } from "../insert-last-argument"; + +describe("insertLastArgument", () => { + it("appends to a single-line call", () => { + expect(insertLastArgument(`foo(a, b)`, "foo", "c = 1")).toBe( + `foo(a, b,\n c = 1)`, + ); + }); + + it("fills an empty argument list", () => { + expect(insertLastArgument(`foo()`, "foo", "c = 1")).toBe(`foo(c = 1)`); + }); + + it("keeps the closing parenthesis on its own line", () => { + const source = ` foo(\n a = 1\n )\n`; + + expect(insertLastArgument(source, "foo", "b = 2")).toBe( + ` foo(\n a = 1,\n b = 2\n )\n`, + ); + }); + + it("ignores parentheses inside line comments", () => { + const source = `foo(\n a = 1 // ignore ) this\n)`; + + // The comma has to land before the comment, not after it, or the comment + // swallows it and the call stops compiling. + expect(insertLastArgument(source, "foo", "b = 2")).toBe( + `foo(\n a = 1,\n b = 2 // ignore ) this\n)`, + ); + }); + + it("ignores parentheses inside block comments", () => { + const source = `foo(a /* ) */, b)`; + + expect(insertLastArgument(source, "foo", "c")).toBe( + `foo(a /* ) */, b,\n c)`, + ); + }); + + it("ignores parentheses inside strings, escapes included", () => { + const source = `foo("a )", "b \\" )", c)`; + + expect(insertLastArgument(source, "foo", "d")).toBe( + `foo("a )", "b \\" )", c,\n d)`, + ); + }); + + it("ignores parentheses inside Kotlin raw strings", () => { + const source = `foo("""a ) b""", c)`; + + expect(insertLastArgument(source, "foo", "d")).toBe( + `foo("""a ) b""", c,\n d)`, + ); + }); + + it("matches the outermost parenthesis, not the first nested one", () => { + const source = `foo(bar(baz()), qux)`; + + expect(insertLastArgument(source, "foo", "extra")).toBe( + `foo(bar(baz()), qux,\n extra)`, + ); + }); + + it("returns null when the call is absent", () => { + expect(insertLastArgument(`bar(a)`, "foo", "b")).toBeNull(); + }); + + it("returns null when the parentheses never close", () => { + expect(insertLastArgument(`foo(a, bar(b)`, "foo", "c")).toBeNull(); + }); + + it("returns null when a string swallows the rest of the file", () => { + expect(insertLastArgument(`foo(a, "unterminated)`, "foo", "c")).toBeNull(); + }); +}); diff --git a/src/utils/add-import.ts b/src/utils/add-import.ts new file mode 100644 index 0000000..602266d --- /dev/null +++ b/src/utils/add-import.ts @@ -0,0 +1,53 @@ +const escapeForRegExp = (value: string) => + value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + +/** + * Adds an import to a Java or Kotlin source file, below the last import already + * there. + * + * The mods used to hang this off `import expo.modules.ReactNativeHostWrapper`, + * which prebuild stopped emitting once `MainApplication` moved to `ReactHost`. + * Every import block ends somewhere, so anchoring on the block itself survives + * the templates shuffling their imports around. + * + * @param source The file contents. + * @param importPath The class to import, e.g. `com.microsoft.codepush.react.CodePush`. + * @param language `java` needs the trailing semicolon, `kt` does not. + * @returns The source with the import added, or unchanged if it was already there. + */ +export function addImport( + source: string, + importPath: string, + language: "java" | "kt", +) { + const statement = + language === "java" ? `import ${importPath};` : `import ${importPath}`; + + const lines = source.split("\n"); + + // Static imports and Kotlin aliases both start the same way, so matching the + // path with a word boundary is enough to spot a duplicate. + const alreadyImported = lines.some((line) => + new RegExp( + `^\\s*import\\s+(static\\s+)?${escapeForRegExp(importPath)}\\b`, + ).test(line), + ); + if (alreadyImported) return source; + + let lastImport = -1; + for (let index = lines.length - 1; index >= 0; index--) { + if (/^\s*import\s+\S/.test(lines[index] ?? "")) { + lastImport = index; + break; + } + } + + if (lastImport === -1) { + throw new Error( + `Cannot add "${statement}": the file has no import statements to add it below.`, + ); + } + + lines.splice(lastImport + 1, 0, statement); + return lines.join("\n"); +} diff --git a/src/utils/insert-last-argument.ts b/src/utils/insert-last-argument.ts new file mode 100644 index 0000000..c1ab20a --- /dev/null +++ b/src/utils/insert-last-argument.ts @@ -0,0 +1,141 @@ +/** What sits at a given index while scanning a call's argument list. */ +type Scanned = + /** A comment or string literal whose last character is at `end`. */ + | { end: number; isLiteral: boolean } + /** A comment or literal that never closes, so the source cannot be read. */ + | "unterminated" + /** Anything else: a character that counts as code. */ + | "code"; + +/** + * Classifies the token starting at `index`, so the scanner below can step over + * the parentheses that live inside comments and strings. Covers what prebuild + * emits in Java and Kotlin: `//` and block comments, `"` strings with escapes, + * Kotlin's `"""` raw strings, and Java char literals. + */ +const scanAt = (source: string, index: number): Scanned => { + const character = source[index] ?? ""; + const next = source[index + 1]; + + if (character === "/" && next === "/") { + const lineEnd = source.indexOf("\n", index); + return lineEnd === -1 ? "unterminated" : { end: lineEnd, isLiteral: false }; + } + + if (character === "/" && next === "*") { + const commentEnd = source.indexOf("*/", index + 2); + return commentEnd === -1 + ? "unterminated" + : { end: commentEnd + 1, isLiteral: false }; + } + + if (source.startsWith('"""', index)) { + const rawEnd = source.indexOf('"""', index + 3); + return rawEnd === -1 + ? "unterminated" + : { end: rawEnd + 2, isLiteral: true }; + } + + if (character === '"' || character === "'") { + let cursor = index + 1; + while (cursor < source.length && source[cursor] !== character) { + // A backslash escapes whatever follows it, including the quote. + cursor += source[cursor] === "\\" ? 2 : 1; + } + return cursor >= source.length + ? "unterminated" + : { end: cursor, isLiteral: true }; + } + + return "code"; +}; + +type CallBounds = { + /** Index of the `)` that closes the call. */ + closeIndex: number; + /** + * Index just past the last character of code inside the call, so trailing + * whitespace and comments are excluded. A comma appended here cannot land + * inside a `//` comment, where it would be silently commented out. + */ + endOfArguments: number; +}; + +/** Walks from a call's opening parenthesis to its match. */ +const findCallBounds = ( + source: string, + openIndex: number, +): CallBounds | null => { + let depth = 0; + let endOfArguments = openIndex + 1; + + for (let index = openIndex; index < source.length; index++) { + const scanned = scanAt(source, index); + + if (scanned === "unterminated") return null; + + if (scanned === "code") { + const character = source[index] ?? ""; + + if (character === "(") depth++; + if (character === ")") depth--; + + // The scan starts on the call's own `(`, so depth is 1 from the first + // character on and only the matching `)` can bring it back to 0. + if (depth === 0) return { closeIndex: index, endOfArguments }; + + if (!/\s/.test(character)) endOfArguments = index + 1; + } else { + // A string is part of an argument; a comment is not. + if (scanned.isLiteral) endOfArguments = scanned.end + 1; + index = scanned.end; + } + } + + return null; +}; + +/** + * Appends an argument to a call, leaving the closing parenthesis where it is. + * + * Position is not cosmetic. Kotlin evaluates arguments in the order they are + * written, and `CodePush.getJSBundleFile()` throws until a `CodePush` instance + * exists, which only happens once `PackageList(this).packages` has been built. + * Appending is the only placement that holds regardless of how many arguments + * the template passes or how they are laid out. + * + * @param source The file contents. + * @param callee The call to extend, without its parenthesis, e.g. + * `ExpoReactHostFactory.getDefaultReactHost`. + * @param argument The argument to append, e.g. `jsBundleFilePath = null`. + * @returns The source with the argument appended, or `null` if the call is not + * there or its parentheses never balance. + */ +export function insertLastArgument( + source: string, + callee: string, + argument: string, +) { + const calleeIndex = source.indexOf(`${callee}(`); + if (calleeIndex === -1) return null; + + const openIndex = calleeIndex + callee.length; + const bounds = findCallBounds(source, openIndex); + if (bounds === null) return null; + + const { closeIndex, endOfArguments } = bounds; + + // An empty argument list has no trailing comma to add and nothing to line the + // argument up with, so write it straight between the parentheses. + if (source.slice(openIndex + 1, closeIndex).trim() === "") { + return `${source.slice(0, openIndex + 1)}${argument}${source.slice(closeIndex)}`; + } + + // Indent one level past the closing parenthesis when it sits on its own line, + // otherwise past the line the call opens on. + const lineStart = source.lastIndexOf("\n", closeIndex - 1) + 1; + const closeIndent = /^[\t ]*/.exec(source.slice(lineStart, closeIndex))?.[0]; + const indent = `${closeIndent ?? ""} `; + + return `${source.slice(0, endOfArguments)},\n${indent}${argument}${source.slice(endOfArguments)}`; +}