Skip to content
Draft
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 @@ -2,6 +2,7 @@ import {
CLIError,
logger,
printRunDoctorTip,
tokenize,
} from '@react-native-community/cli-tools';
import {Config} from '@react-native-community/cli-types';
import execa from 'execa';
Expand Down Expand Up @@ -115,7 +116,7 @@ export const options = [
{
name: '--extra-params <string>',
description: 'Custom params passed to gradle build command',
parse: (val: string) => val.split(' '),
parse: tokenize,
},
{
name: '-i --interactive',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {tokenize} from '@react-native-community/cli-tools';
import {BuilderCommand} from '../../types';
import {getPlatformInfo} from '../runCommand/getPlatformInfo';

Expand Down Expand Up @@ -49,7 +50,7 @@ export const getBuildOptions = ({platformName}: BuilderCommand) => {
{
name: '--extra-params <string>',
description: 'Custom params that will be passed to xcodebuild command.',
parse: (val: string) => val.split(' '),
parse: tokenize,
},
{
name: '--target <string>',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,13 @@ export function buildProject(
}

const loader = getLoader();
// Wrap arguments containing whitespace in quotes so the logged command stays
// copy-pasteable (e.g. a `-destination` value like `iOS Simulator`).
const printableArgs = xcodebuildArgs
.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg))
.join(' ');
logger.info(
`Building ${pico.dim(
`(using "xcodebuild ${xcodebuildArgs.join(' ')}")`,
)}`,
`Building ${pico.dim(`(using "xcodebuild ${printableArgs}")`)}`,
);
let xcodebuildOutputFormatter: ChildProcess | any;
if (!args.verbose) {
Expand Down
96 changes: 96 additions & 0 deletions packages/cli-tools/src/__tests__/tokenize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import tokenize from '../tokenize';

describe('tokenize', () => {
it('splits space-separated xcodebuild arguments', () => {
expect(tokenize('-scheme MyApp -configuration Release')).toEqual([
'-scheme',
'MyApp',
'-configuration',
'Release',
]);
});

it('splits space-separated gradle properties', () => {
expect(
tokenize('-PnewArchEnabled=true -PreactNativeArchitectures=arm64-v8a'),
).toEqual([
'-PnewArchEnabled=true',
'-PreactNativeArchitectures=arm64-v8a',
]);
});

it('keeps a double-quoted xcodebuild destination with spaces in one token', () => {
expect(tokenize('-destination "generic/platform=iOS Simulator"')).toEqual([
'-destination',
'generic/platform=iOS Simulator',
]);
});

it('keeps a single-quoted destination specifier with spaces in one token', () => {
expect(
tokenize("-destination 'platform=iOS Simulator,name=iPhone 15'"),
).toEqual(['-destination', 'platform=iOS Simulator,name=iPhone 15']);
});

it('strips the surrounding quotes from a quoted path containing a space', () => {
expect(tokenize('-project "My App.xcodeproj"')).toEqual([
'-project',
'My App.xcodeproj',
]);
});

it('joins an unquoted build-setting name with its quoted, space-containing value', () => {
expect(tokenize('EXCLUDED_ARCHS="arm64 i386"')).toEqual([
'EXCLUDED_ARCHS=arm64 i386',
]);
});

it('collapses extra whitespace (spaces and tabs) between arguments', () => {
expect(tokenize('-scheme MyApp \t -quiet')).toEqual([
'-scheme',
'MyApp',
'-quiet',
]);
});

it('passes xcodebuild build-setting syntax such as $(inherited) through verbatim', () => {
expect(
tokenize(
'OTHER_LDFLAGS=$(inherited) GCC_PREPROCESSOR_DEFINITIONS=DEBUG=1',
),
).toEqual([
'OTHER_LDFLAGS=$(inherited)',
'GCC_PREPROCESSOR_DEFINITIONS=DEBUG=1',
]);
});

it('keeps backslashes literal so Windows gradle paths survive', () => {
expect(
tokenize('-Pandroid.injected.signing.store.file=C:\\keys\\app.jks'),
).toEqual(['-Pandroid.injected.signing.store.file=C:\\keys\\app.jks']);
});

it('returns an empty array for empty input', () => {
expect(tokenize('')).toEqual([]);
});

it('returns an empty array for whitespace-only input', () => {
expect(tokenize(' \t ')).toEqual([]);
});

it('drops a stray empty quoted argument', () => {
expect(tokenize('-quiet ""')).toEqual(['-quiet']);
});

it('throws on an unterminated double quote', () => {
expect(() => tokenize('-destination "platform=iOS')).toThrowError(
'Unterminated " quote in: -destination "platform=iOS',
);
});

it('throws on an unterminated single quote', () => {
expect(() => tokenize("-destination 'platform=iOS")).toThrowError(
"Unterminated ' quote in: -destination 'platform=iOS",
);
});
});
1 change: 1 addition & 0 deletions packages/cli-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export * from './port';
export {default as cacheManager} from './cacheManager';
export {default as runSudo} from './runSudo';
export {default as unixifyPaths} from './unixifyPaths';
export {default as tokenize} from './tokenize';

export * from './errors';
70 changes: 70 additions & 0 deletions packages/cli-tools/src/tokenize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {CLIError} from './errors';

/**
* Splits a command-line string into arguments, honoring single and double quotes
* so values containing spaces stay in a single token.
*
* It only understands quoting — nothing else is interpreted. Environment
* variables (`$FOO`), command substitution (`$(...)`), globs (`*`), operators
* (`;`, `|`) and backslashes are all passed through verbatim, so values like
* `OTHER_LDFLAGS=$(inherited)` or the Windows path `-Pdir=C:\Users\foo` reach the
* underlying tool unchanged.
*
* - Unquoted whitespace separates tokens.
* - `"` and `'` quote; the quote characters are stripped from the result.
* - Adjacent quoted and unquoted text join into one token, e.g.
* `OTHER_SWIFT_FLAGS="-D DEBUG"` -> `OTHER_SWIFT_FLAGS=-D DEBUG`.
* - Empty or whitespace-only input returns `[]`.
* - An unterminated quote throws a {@link CLIError}.
*/
export default function tokenize(input: string) {
const tokens: string[] = [];
// `null` means we're between tokens; a string (even '') means a token is open.
let token: string | null = null;
let openQuote: string | null = null;

for (const char of input) {
// Inside quotes every character is literal until the matching quote closes.
if (openQuote) {
if (char === openQuote) {
openQuote = null;
} else {
token += char;
}

continue;
}

// A quote opens a quoted section, starting a token if one isn't open yet.
if (char === '"' || char === "'") {
openQuote = char;
token = token ?? '';

continue;
}

// Unquoted whitespace ends the current token, if there is one.
if (/\s/.test(char)) {
if (token) {
tokens.push(token);
}

token = null;

continue;
}

// Any other character extends the current token.
token = (token ?? '') + char;
}

if (openQuote) {
throw new CLIError(`Unterminated ${openQuote} quote in: ${input}`);
}

if (token) {
tokens.push(token);
}

return tokens;
}
Loading