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 @@ -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
166 changes: 166 additions & 0 deletions packages/cli-tools/src/__tests__/tokenize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import tokenize from '../tokenize';

const originalPlatform = process.platform;

function mockPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
}

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('preserves backslashes inside single quotes 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('throws on a lone unquoted apostrophe', () => {
expect(() => tokenize("-Pmessage=don't")).toThrowError(
"Unterminated ' quote in: -Pmessage=don't",
);
});

it('parses a quoted Gradle property value containing an apostrophe', () => {
expect(tokenize(`-Pmessage="It's ready"`)).toEqual([
"-Pmessage=It's ready",
]);
});

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('keeps an explicit 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",
);
});

describe('with POSIX backslash escaping (non-Windows)', () => {
beforeEach(() => mockPlatform('linux'));
afterEach(() => mockPlatform(originalPlatform));

it('treats an unquoted backslash as an escape that preserves the next character', () => {
expect(tokenize('-project My\\ App.xcodeproj')).toEqual([
'-project',
'My App.xcodeproj',
]);
});

it('escapes an apostrophe with a backslash outside quotes', () => {
expect(tokenize("-Pmessage=don\\'t")).toEqual(["-Pmessage=don't"]);
});

it('unescapes quotes inside double-quoted JSON values', () => {
expect(tokenize(String.raw`-Pconfig="{\"name\":\"My App\"}"`)).toEqual([
'-Pconfig={"name":"My App"}',
]);
});

it('keeps backslashes literal inside double-quoted values', () => {
expect(tokenize(String.raw`-Pdir="C:\Program Files\App"`)).toEqual([
String.raw`-Pdir=C:\Program Files\App`,
]);
});
});

describe('on Windows', () => {
beforeEach(() => mockPlatform('win32'));
afterEach(() => mockPlatform(originalPlatform));

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

it('keeps backslashes literal inside double-quoted values', () => {
expect(tokenize(String.raw`-Pdir="C:\Program Files\App"`)).toEqual([
String.raw`-Pdir=C:\Program Files\App`,
]);
});
});
});
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';
109 changes: 109 additions & 0 deletions packages/cli-tools/src/tokenize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {CLIError} from './errors';

// Inside double quotes a backslash keeps its escaping power only before one of
// these characters. Before anything else the backslash is a literal character.
const DOUBLE_QUOTE_ESCAPABLE = new Set(['"', '\\', '$', '`', '\n']);

/**
* Splits a command-line string into arguments the way a POSIX shell would when
* building the `argv` it hands to a program. Only quoting, backslash escaping
* and whitespace splitting are applied — there is no expansion phase, so
* variables (`$FOO`), command substitution (`$(...)`), globs (`*`) and operators
* (`;`, `|`) are passed through verbatim for xcodebuild/gradle to interpret.
*
* - Unquoted whitespace separates arguments; adjacent quoted and unquoted
* segments join into one argument (`a"b"c` -> `abc`).
* - Single quotes are fully literal (backslashes included).
* - Double quotes are literal except a backslash escaping `"`, `\`, `$`, `` ` ``
* or a newline; any other backslash is kept.
* - Outside quotes a backslash escapes the next character (`My\ App`); before a
* newline it is a line continuation.
* - `""`/`''` produce an empty argument; empty input produces `[]`.
* - An unterminated quote throws a {@link CLIError}.
*
* On Windows the backslash is a path separator, so escaping is disabled there
* and paths such as `C:\keys\app.jks` survive unquoted; quoting and splitting
* still apply.
*/
export default function tokenize(input: string): string[] {
const backslashIsEscape = process.platform !== 'win32';

const args: string[] = [];
let arg = '';
// Distinguishes "no argument yet" from "an argument that is empty" (e.g. the
// `""` in `--flag ""`), which must be preserved.
let hasArg = false;
let quote: '"' | "'" | undefined;

const append = (char: string) => {
arg += char;
hasArg = true;
};

for (let index = 0; index < input.length; index++) {
const char = input[index];
const next = input[index + 1];

// Single quotes: literal until the closing quote.
if (quote === "'") {
if (char === "'") {
quote = undefined;
} else {
append(char);
}

continue;
}

// Double quotes: literal, plus a limited set of backslash escapes.
if (quote === '"') {
if (char === '"') {
quote = undefined;
} else if (
char === '\\' &&
backslashIsEscape &&
DOUBLE_QUOTE_ESCAPABLE.has(next)
) {
if (next !== '\n') {
append(next);
} // "\<newline>" is a line continuation
index++;
} else {
append(char);
}

continue;
}

// Outside any quotes.
if (char === '"' || char === "'") {
quote = char;
hasArg = true; // an opening quote starts an argument, even an empty one
} else if (char === '\\' && backslashIsEscape && next !== undefined) {
if (next !== '\n') {
append(next);
} // "\<newline>" is a line continuation

index++;
} else if (/\s/.test(char)) {
if (hasArg) {
args.push(arg);
}

arg = '';
hasArg = false;
} else {
append(char);
}
}

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

if (hasArg) {
args.push(arg);
}

return args;
}
Loading