Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const {
generateSynthPackageSwift,
hasMixedLanguageSources,
hasPodspec,
isMixedLanguageSourceList,
linkHeaderTree,
main,
reactDescriptor,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
//
Expand Down
107 changes: 89 additions & 18 deletions packages/react-native/scripts/spm/generate-spm-autolinking.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
PluginPackageDep,
PluginProductDep,
PluginScriptPhase,
PodspecModel,
ReactDescriptor,
RawAutolinkingJson,
SpmModuleConfig,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> */,
) /*: 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<string> */ = new Set([
'node_modules',
Expand All @@ -519,10 +530,9 @@ function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ {
'Example',
'examples',
]);
let hasSwift = false;
let hasClang = false;
const files /*: Array<string> */ = [];
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
Expand All @@ -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<string>, warnings: Array<string>} */ {
const warnings /*: Array<string> */ = [];
// 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<string> */ = [];
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};
}

/**
Expand Down Expand Up @@ -1377,6 +1441,7 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
origin: 'npm',
npmName: dep.name,
root: dep.root,
podspecPath: dep.platforms.ios.podspecPath,
});
log(`Found npm native module: ${target.name} → ${target.path}`);
}
Expand Down Expand Up @@ -1533,7 +1598,11 @@ function main(argv /*:: ?: Array<string> */) /*: 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.
Expand Down Expand Up @@ -1995,6 +2064,8 @@ module.exports = {
findSelfManagedPackageDir,
hasPodspec,
hasMixedLanguageSources,
isMixedLanguageSourceList,
podspecSourceFiles,
readDenyPluginsFromConfig,
MissingManifestError,
reportMissingManifests,
Expand Down
Loading
Loading