diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index 9eed2b5e70a..56854ad7089 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -22,6 +22,7 @@ const { generateSynthPackageSwift, hasMixedLanguageSources, hasPodspec, + isMixedLanguageSourceList, linkHeaderTree, main, reactDescriptor, @@ -1151,6 +1152,31 @@ describe('MissingManifestError + reportMissingManifests', () => { }); }); +describe('isMixedLanguageSourceList', () => { + it.each(['m', 'mm', 'c', 'cc', 'cpp', 'cxx'])( + 'recognizes Swift mixed with .%s sources, ignoring case', + extension => { + expect( + isMixedLanguageSourceList([ + 'ios/Foo.SWIFT', + `ios/Bar.${extension.toUpperCase()}`, + ]), + ).toBe(true); + }, + ); + + it.each([ + ['empty', []], + ['Swift only', ['Foo.swift']], + ['C-family only', ['Foo.m', 'Bar.cpp']], + ['headers with Swift', ['Foo.h', 'Bar.hpp', 'Foo.swift']], + ['non-source suffixes', ['Foo.swift.bak', 'Bar.m']], + ['non-C-family suffixes', ['Foo.swift', 'Bar.cpp.bak']], + ])('rejects %s source lists', (_name, files) => { + expect(isMixedLanguageSourceList(files)).toBe(false); + }); +}); + describe('hasMixedLanguageSources', () => { let root; @@ -1207,6 +1233,158 @@ function useTempApps() { return {created, spies}; } +describe('main() — missing-manifest podspec sources', () => { + const {created} = useTempApps(); + let isolated; + + beforeEach(() => { + jest.isolateModules(() => { + jest.doMock('../read-podspec', () => { + const actual = jest.requireActual('../read-podspec'); + return { + ...actual, + // Exercise the real podspec parser without spawning CocoaPods. + readPodspecCached: podspecPath => + actual.flattenSubspecs(actual.regexPodspec(podspecPath)), + }; + }); + isolated = require('../generate-spm-autolinking'); + }); + }); + + afterEach(() => { + jest.dontMock('../read-podspec'); + }); + + function missingManifest({ + sourceFiles, + excludeFiles = '', + swift = false, + podspecDir = '', + decoyPodspec = false, + iosConfig = (_depRoot, _podspecPath) => ({}), + }) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pod-sources-')); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const depRoot = path.join(appRoot, 'node_modules', 'react-native-foo'); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.mkdirSync(path.join(depRoot, 'expo', 'ios'), {recursive: true}); + for (const file of ['ios/Foo.h', 'ios/Foo.m', 'expo/ios/X.swift']) { + fs.writeFileSync(path.join(depRoot, file), ''); + } + if (swift) fs.writeFileSync(path.join(depRoot, 'ios/Bar.swift'), ''); + const podspecPath = path.join(depRoot, podspecDir, 'Foo.podspec'); + fs.writeFileSync( + podspecPath, + 'Pod::Spec.new do |s|\n' + + ' s.name = "Foo"\n' + + ` s.source_files = "${sourceFiles}"\n` + + ` s.exclude_files = "${excludeFiles}"\n` + + 'end\n', + ); + if (decoyPodspec) { + fs.writeFileSync( + path.join(depRoot, 'A.podspec'), + 'Pod::Spec.new do |s|\n' + + ' s.name = "A"\n' + + ' s.source_files = "ios/**/*.{h,m}"\n' + + 'end\n', + ); + } + const autolinkDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: { + 'react-native-foo': { + root: depRoot, + platforms: {ios: iosConfig(depRoot, podspecPath)}, + }, + }, + }), + ); + let error; + try { + isolated.main(['--app-root', appRoot, '--react-native-root', rnRoot]); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(isolated.MissingManifestError); + expect(error.missingManifests).toHaveLength(1); + return error.missingManifests[0]; + } + + it('ignores stray Swift outside podspec sources and recommends scaffolding', () => { + const entry = missingManifest({sourceFiles: 'ios/**/*.{h,m,mm}'}); + expect(entry.mixed).toBe(false); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('npx react-native spm scaffold'), + ); + expect(console.error).not.toHaveBeenCalledWith( + expect.stringContaining('mixed Swift'), + ); + }); + + it('reports mixed languages when the podspec includes Swift and Objective-C', () => { + const entry = missingManifest({ + sourceFiles: 'ios/**/*.{h,m,swift}', + swift: true, + }); + expect(entry.mixed).toBe(true); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('mixed Swift'), + ); + expect(console.error).not.toHaveBeenCalledWith( + expect.stringContaining('npx react-native spm scaffold'), + ); + }); + + it('judges the configured podspecPath, not the first podspec found', () => { + const entry = missingManifest({ + sourceFiles: 'ios/**/*.{h,m,swift}', + swift: true, + decoyPodspec: true, + iosConfig: (_depRoot, podspecPath) => ({podspecPath}), + }); + expect(entry.mixed).toBe(true); + }); + + it('expands podspec globs from the package root when sourceDir is nested', () => { + const entry = missingManifest({ + sourceFiles: 'ios/**/*.{h,m,swift}', + swift: true, + podspecDir: 'ios', + iosConfig: (depRoot, podspecPath) => ({ + podspecPath, + sourceDir: path.join(depRoot, 'ios'), + }), + }); + expect(entry.mixed).toBe(true); + }); + + it('recommends scaffolding when exclude_files removes the only included Swift file', () => { + const entry = missingManifest({ + sourceFiles: 'ios/**/*.{h,m,swift}', + excludeFiles: 'ios/Bar.swift', + swift: true, + }); + expect(entry.mixed).toBe(false); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('npx react-native spm scaffold'), + ); + expect(console.error).not.toHaveBeenCalledWith( + expect.stringContaining('mixed Swift'), + ); + }); +}); + // --------------------------------------------------------------------------- // main() — autolinking plugin host exemption // diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index 4801581d568..1c03fa7c1c7 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -21,6 +21,7 @@ import type { PluginPackageDep, PluginProductDep, PluginScriptPhase, + PodspecModel, ReactDescriptor, RawAutolinkingJson, SpmModuleConfig, @@ -71,7 +72,7 @@ const { MIN_IOS_VERSION_SUPPORTED, sanitizeIosDeploymentTarget, } = require('./ios-deployment-target'); -const {findPodspecs} = require('./read-podspec'); +const {findPodspecs, readPodspecCached} = require('./read-podspec'); const { AUTOLINKED_PACKAGE_NAME, CXX_API_DEFINES, @@ -500,14 +501,24 @@ function hasPodspec(absSource /*: string */) /*: boolean */ { } /** - * True when a dep has BOTH Swift and C-family (.m/.mm/.c/.cpp) sources. SPM - * cannot compile mixed-language sources in a single target, and RN libs that - * mix them are typically bidirectionally coupled (ObjC↔Swift) — which can't be - * split into two targets either (it would be a circular dependency). So such a - * dep is unsupportable by the scaffolder; we surface a clear, distinct error - * instead of emitting a manifest that fails with a cryptic SPM resolve error. - * Heuristic filesystem scan (bounded depth; skips examples/tests/build noise). + * True when a source list has BOTH Swift and C-family (.m/.mm/.c/.cpp) files. + * SPM cannot compile mixed-language sources in a single target, and RN libs + * that mix them are typically bidirectionally coupled (ObjC↔Swift) — which + * can't be split into two targets either (it would be a circular dependency). + * So such a dep is unsupportable by the scaffolder; we surface a clear, + * distinct error instead of emitting a manifest that fails with a cryptic SPM + * resolve error. */ +function isMixedLanguageSourceList( + files /*: ReadonlyArray */, +) /*: boolean */ { + return ( + files.some(f => /\.swift$/i.test(f)) && + files.some(f => /\.(mm?|c|cc|cpp|cxx)$/i.test(f)) + ); +} + +// Heuristic filesystem scan (bounded depth; skips examples/tests/build noise). function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ { const SKIP /*: Set */ = new Set([ 'node_modules', @@ -519,10 +530,9 @@ function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ { 'Example', 'examples', ]); - let hasSwift = false; - let hasClang = false; + const files /*: Array */ = []; const walk = (dir /*: string */, depth /*: number */) => { - if (depth > 6 || (hasSwift && hasClang)) return; + if (depth > 6) return; let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; try { // $FlowFixMe[incompatible-type] Dirent typing @@ -537,16 +547,70 @@ function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ { if (!name.startsWith('.') && !SKIP.has(name)) { walk(path.join(dir, name), depth + 1); } - } else if (/\.swift$/i.test(name)) { - hasSwift = true; - } else if (/\.(mm?|c|cc|cpp|cxx)$/i.test(name)) { - hasClang = true; + } else { + files.push(name); } - if (hasSwift && hasClang) return; } }; walk(absSource, 0); - return hasSwift && hasClang; + return isMixedLanguageSourceList(files); +} + +// Judges the sources the podspec compiles, like the scaffolder does, so a stray +// Swift file the pod never builds can't flip the verdict. Only for the +// missing-manifest error path: reading a podspec may spawn `pod ipc spec`. +// Podspec choice and glob root match scaffoldPackageSwiftForDep. Where the +// scaffolder would give up (podspec missing or unreadable), the filesystem +// heuristic still yields a verdict for the error message. +function isMixedLanguageDep( + absSource /*: string */, + root /*: string */, + configuredPodspecPath /*: ?string */, +) /*: boolean */ { + const podspecPath = configuredPodspecPath ?? findPodspecs(root)[0]; + if (podspecPath != null) { + try { + const model = readPodspecCached(podspecPath); + const {files} = podspecSourceFiles(root, model); + return isMixedLanguageSourceList(files); + } catch { + // An unreadable podspec leaves only the filesystem heuristic available. + } + } + return hasMixedLanguageSources(absSource); +} + +function podspecSourceFiles( + root /*: string */, + model /*: PodspecModel */, +) /*: {files: Array, warnings: Array} */ { + const warnings /*: Array */ = []; + // SPM's `sources:` field does NOT accept CocoaPods-style globs — it wants + // a list of explicit file paths (relative to the target's `path:`). + // Expand the podspec globs against the actual filesystem so the emitted + // Package.swift parses cleanly. + let expandedSources /*: Array */ = []; + try { + expandedSources = expandSpmSourceGlobs(root, model.sourceFiles); + } catch (e) { + warnings.push( + `Source glob expansion failed (${e.message}); emitting raw globs — SPM will likely reject them.`, + ); + expandedSources = model.sourceFiles; + } + if (model.excludeFiles.length > 0) { + try { + // $FlowFixMe[untyped-import] micromatch has no types + const micromatch = require('micromatch'); + const isExcluded = micromatch.matcher(model.excludeFiles); + expandedSources = expandedSources.filter(f => !isExcluded(f)); + } catch (e) { + warnings.push( + `exclude_files filtering failed (${e.message}); keeping all sources.`, + ); + } + } + return {files: expandedSources, warnings}; } /** @@ -1377,6 +1441,7 @@ function main(argv /*:: ?: Array */) /*: void */ { origin: 'npm', npmName: dep.name, root: dep.root, + podspecPath: dep.platforms.ios.podspecPath, }); log(`Found npm native module: ${target.name} → ${target.path}`); } @@ -1533,7 +1598,11 @@ function main(argv /*:: ?: Array */) /*: void */ { name: target.name, npmName: entry.npmName ?? target.name, hasPodspec: hasPodspec(absSource), - mixed: hasMixedLanguageSources(absSource), + mixed: isMixedLanguageDep( + absSource, + entry.root ?? absSource, + entry.podspecPath, + ), }); // Drop any stale wrapper from a previous synth-mode run so SPM doesn't // resolve against it. @@ -1995,6 +2064,8 @@ module.exports = { findSelfManagedPackageDir, hasPodspec, hasMixedLanguageSources, + isMixedLanguageSourceList, + podspecSourceFiles, readDenyPluginsFromConfig, MissingManifestError, reportMissingManifests, diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index 87eea549364..f874e96226f 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -43,7 +43,11 @@ const { expandSpmDependencies, resolveSwiftName, } = require('./expand-spm-dependencies'); -const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); +const { + expandSpmSourceGlobs, + isMixedLanguageSourceList, + podspecSourceFiles, +} = require('./generate-spm-autolinking'); const {MIN_IOS_VERSION_SUPPORTED} = require('./ios-deployment-target'); const {findPodspecs, readPodspecCached} = require('./read-podspec'); const { @@ -393,32 +397,9 @@ function translatePodspecToSpmTarget( coreReactNative = true; } - // SPM's `sources:` field does NOT accept CocoaPods-style globs — it wants - // a list of explicit file paths (relative to the target's `path:`). - // Expand the podspec globs against the actual filesystem so the emitted - // Package.swift parses cleanly. - let expandedSources /*: Array */ = []; - try { - expandedSources = expandSpmSourceGlobs(dep.root, model.sourceFiles); - } catch (e) { - warnings.push( - `Source glob expansion failed (${e.message}); emitting raw globs — SPM will likely reject them.`, - ); - expandedSources = model.sourceFiles; - } - // Strip files matching exclude_files (podspec's negation list). - if (model.excludeFiles.length > 0) { - try { - // $FlowFixMe[untyped-import] micromatch has no types - const micromatch = require('micromatch'); - const isExcluded = micromatch.matcher(model.excludeFiles); - expandedSources = expandedSources.filter(f => !isExcluded(f)); - } catch (e) { - warnings.push( - `exclude_files filtering failed (${e.message}); keeping all sources.`, - ); - } - } + const sources = podspecSourceFiles(dep.root, model); + warnings.push(...sources.warnings); + let expandedSources = sources.files; // Never self-ingest scaffold artifacts: a re-scaffold runs with the // previous run's output on disk, and root-level podspec globs sweep it // into sources — `*.{h,...,swift}` matches Package.swift itself, whose @@ -1023,9 +1004,7 @@ function scaffoldPackageSwiftForDep( // language source files" resolve error — skip with a clear reason (and remove // any stale scaffolded manifest so the autolinker reports it distinctly and // points the user at opting it out / a binary distribution). - const hasSwift = spec.sources.some(f => /\.swift$/i.test(f)); - const hasClang = spec.sources.some(f => /\.(mm?|c|cc|cpp|cxx)$/i.test(f)); - if (hasSwift && hasClang) { + if (isMixedLanguageSourceList(spec.sources)) { if (!ctx.dryRun && fs.existsSync(pkgSwiftPath)) { const existing = fs.readFileSync(pkgSwiftPath, 'utf8'); if (existing.includes(SCAFFOLDER_MARKER)) { diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index d9af84f3965..5fcf9e127ed 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -106,6 +106,9 @@ export type TargetEntry = { // the autolinking model (`dep.root`) so the watch file can flag manifest edits // without walking up from the (possibly nested) target source dir. root?: string, + // The podspec autolinking.json names (origin 'npm' only). The mixed-language + // check must read the same podspec the scaffolder would. + podspecPath?: ?string, // Filled in for npm-origin entries during the mirror step; consumed by the // synth-package emission step further down. mirrorReady?: ?{