1/**
2 * Copyright (c) 650 Industries.
3 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 *
5 * This source code is licensed under the MIT license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9import UTFSequence from 'react-native/Libraries/UTFSequence';
10
11import parseErrorStack from '../modules/parseErrorStack';
12import stringifySafe from '../modules/stringifySafe';
13import type { LogBoxLogData } from './LogBoxLog';
14type ExceptionData = any;
15
16const BABEL_TRANSFORM_ERROR_FORMAT =
17  /^(?:TransformError )?(?:SyntaxError: |ReferenceError: )(.*): (.*) \((\d+):(\d+)\)\n\n([\s\S]+)/;
18const BABEL_CODE_FRAME_ERROR_FORMAT =
19  /^(?:TransformError )?(?:.*):? (?:.*?)(\/.*): ([\s\S]+?)\n([ >]{2}[\d\s]+ \|[\s\S]+|\u{001b}[\s\S]+)/u;
20const METRO_ERROR_FORMAT =
21  /^(?:InternalError Metro has encountered an error:) (.*): (.*) \((\d+):(\d+)\)\n\n([\s\S]+)/u;
22
23export type ExtendedExceptionData = ExceptionData & {
24  isComponentError: boolean;
25  [key: string]: any;
26};
27export type Category = string;
28export type CodeFrame = {
29  content: string;
30  location?: {
31    row: number;
32    column: number;
33    [key: string]: any;
34  } | null;
35  fileName: string;
36
37  // TODO: When React switched to using call stack frames,
38  // we gained the ability to use the collapse flag, but
39  // it is not integrated into the LogBox UI.
40  collapse?: boolean;
41};
42
43export type Message = {
44  content: string;
45  substitutions: {
46    length: number;
47    offset: number;
48  }[];
49};
50
51export type ComponentStack = CodeFrame[];
52
53const SUBSTITUTION = UTFSequence.BOM + '%s';
54
55export function parseInterpolation(args: readonly any[]): {
56  category: Category;
57  message: Message;
58} {
59  const categoryParts: string[] = [];
60  const contentParts: string[] = [];
61  const substitutionOffsets: { length: number; offset: number }[] = [];
62
63  const remaining = [...args];
64  if (typeof remaining[0] === 'string') {
65    const formatString = String(remaining.shift());
66    const formatStringParts = formatString.split('%s');
67    const substitutionCount = formatStringParts.length - 1;
68    const substitutions = remaining.splice(0, substitutionCount);
69
70    let categoryString = '';
71    let contentString = '';
72
73    let substitutionIndex = 0;
74    for (const formatStringPart of formatStringParts) {
75      categoryString += formatStringPart;
76      contentString += formatStringPart;
77
78      if (substitutionIndex < substitutionCount) {
79        if (substitutionIndex < substitutions.length) {
80          // Don't stringify a string type.
81          // It adds quotation mark wrappers around the string,
82          // which causes the LogBox to look odd.
83          const substitution =
84            typeof substitutions[substitutionIndex] === 'string'
85              ? substitutions[substitutionIndex]
86              : stringifySafe(substitutions[substitutionIndex]);
87          substitutionOffsets.push({
88            length: substitution.length,
89            offset: contentString.length,
90          });
91
92          categoryString += SUBSTITUTION;
93          contentString += substitution;
94        } else {
95          substitutionOffsets.push({
96            length: 2,
97            offset: contentString.length,
98          });
99
100          categoryString += '%s';
101          contentString += '%s';
102        }
103
104        substitutionIndex++;
105      }
106    }
107
108    categoryParts.push(categoryString);
109    contentParts.push(contentString);
110  }
111
112  const remainingArgs = remaining.map((arg) => {
113    // Don't stringify a string type.
114    // It adds quotation mark wrappers around the string,
115    // which causes the LogBox to look odd.
116    return typeof arg === 'string' ? arg : stringifySafe(arg);
117  });
118  categoryParts.push(...remainingArgs);
119  contentParts.push(...remainingArgs);
120
121  return {
122    category: categoryParts.join(' '),
123    message: {
124      content: contentParts.join(' '),
125      substitutions: substitutionOffsets,
126    },
127  };
128}
129
130function isComponentStack(consoleArgument: string) {
131  const isOldComponentStackFormat = / {4}in/.test(consoleArgument);
132  const isNewComponentStackFormat = / {4}at/.test(consoleArgument);
133  const isNewJSCComponentStackFormat = /@.*\n/.test(consoleArgument);
134
135  return isOldComponentStackFormat || isNewComponentStackFormat || isNewJSCComponentStackFormat;
136}
137
138export function parseComponentStack(message: string): ComponentStack {
139  // In newer versions of React, the component stack is formatted as a call stack frame.
140  // First try to parse the component stack as a call stack frame, and if that doesn't
141  // work then we'll fallback to the old custom component stack format parsing.
142  const stack = parseErrorStack(message);
143  if (stack && stack.length > 0) {
144    return stack.map((frame) => ({
145      content: frame.methodName,
146      collapse: frame.collapse || false,
147      fileName: frame.file == null ? 'unknown' : frame.file,
148      location: {
149        column: frame.column == null ? -1 : frame.column,
150        row: frame.lineNumber == null ? -1 : frame.lineNumber,
151      },
152    }));
153  }
154
155  return message
156    .split(/\n {4}in /g)
157    .map((s) => {
158      if (!s) {
159        return null;
160      }
161      const match = s.match(/(.*) \(at (.*\.js):([\d]+)\)/);
162      if (!match) {
163        return null;
164      }
165
166      const [content, fileName, row] = match.slice(1);
167      return {
168        content,
169        fileName,
170        location: { column: -1, row: parseInt(row, 10) },
171      };
172    })
173    .filter(Boolean) as ComponentStack;
174}
175
176export function parseLogBoxException(error: ExtendedExceptionData): LogBoxLogData {
177  const message = error.originalMessage != null ? error.originalMessage : 'Unknown';
178
179  const metroInternalError = message.match(METRO_ERROR_FORMAT);
180  if (metroInternalError) {
181    const [content, fileName, row, column, codeFrame] = metroInternalError.slice(1);
182
183    return {
184      level: 'fatal',
185      type: 'Metro Error',
186      stack: [],
187      isComponentError: false,
188      componentStack: [],
189      codeFrame: {
190        fileName,
191        location: {
192          row: parseInt(row, 10),
193          column: parseInt(column, 10),
194        },
195        content: codeFrame,
196      },
197      message: {
198        content,
199        substitutions: [],
200      },
201      category: `${fileName}-${row}-${column}`,
202    };
203  }
204
205  const babelTransformError = message.match(BABEL_TRANSFORM_ERROR_FORMAT);
206  if (babelTransformError) {
207    // Transform errors are thrown from inside the Babel transformer.
208    const [fileName, content, row, column, codeFrame] = babelTransformError.slice(1);
209
210    return {
211      level: 'syntax',
212      stack: [],
213      isComponentError: false,
214      componentStack: [],
215      codeFrame: {
216        fileName,
217        location: {
218          row: parseInt(row, 10),
219          column: parseInt(column, 10),
220        },
221        content: codeFrame,
222      },
223      message: {
224        content,
225        substitutions: [],
226      },
227      category: `${fileName}-${row}-${column}`,
228    };
229  }
230
231  const babelCodeFrameError = message.match(BABEL_CODE_FRAME_ERROR_FORMAT);
232
233  if (babelCodeFrameError) {
234    // Codeframe errors are thrown from any use of buildCodeFrameError.
235    const [fileName, content, codeFrame] = babelCodeFrameError.slice(1);
236    return {
237      level: 'syntax',
238      stack: [],
239      isComponentError: false,
240      componentStack: [],
241      codeFrame: {
242        fileName,
243        location: null, // We are not given the location.
244        content: codeFrame,
245      },
246      message: {
247        content,
248        substitutions: [],
249      },
250      category: `${fileName}-${1}-${1}`,
251    };
252  }
253
254  if (message.match(/^TransformError /)) {
255    return {
256      level: 'syntax',
257      stack: error.stack,
258      isComponentError: error.isComponentError,
259      componentStack: [],
260      message: {
261        content: message,
262        substitutions: [],
263      },
264      category: message,
265    };
266  }
267
268  const componentStack = error.componentStack;
269  if (error.isFatal || error.isComponentError) {
270    return {
271      level: 'fatal',
272      stack: error.stack,
273      isComponentError: error.isComponentError,
274      componentStack: componentStack != null ? parseComponentStack(componentStack) : [],
275      ...parseInterpolation([message]),
276    };
277  }
278
279  if (componentStack != null) {
280    // It is possible that console errors have a componentStack.
281    return {
282      level: 'error',
283      stack: error.stack,
284      isComponentError: error.isComponentError,
285      componentStack: parseComponentStack(componentStack),
286      ...parseInterpolation([message]),
287    };
288  }
289
290  // Most `console.error` calls won't have a componentStack. We parse them like
291  // regular logs which have the component stack burried in the message.
292  return {
293    level: 'error',
294    stack: error.stack,
295    isComponentError: error.isComponentError,
296    ...parseLogBoxLog([message]),
297  };
298}
299
300export function parseLogBoxLog(args: readonly any[]): {
301  componentStack: ComponentStack;
302  category: Category;
303  message: Message;
304} {
305  const message = args[0];
306  let argsWithoutComponentStack: any[] = [];
307  let componentStack: ComponentStack = [];
308
309  // Extract component stack from warnings like "Some warning%s".
310  if (typeof message === 'string' && message.slice(-2) === '%s' && args.length > 0) {
311    const lastArg = args[args.length - 1];
312    if (typeof lastArg === 'string' && isComponentStack(lastArg)) {
313      argsWithoutComponentStack = args.slice(0, -1);
314      argsWithoutComponentStack[0] = message.slice(0, -2);
315      componentStack = parseComponentStack(lastArg);
316    }
317  }
318
319  if (componentStack.length === 0) {
320    // Try finding the component stack elsewhere.
321    for (const arg of args) {
322      if (typeof arg === 'string' && isComponentStack(arg)) {
323        // Strip out any messages before the component stack.
324        let messageEndIndex = arg.search(/\n {4}(in|at) /);
325        if (messageEndIndex < 0) {
326          // Handle JSC component stacks.
327          messageEndIndex = arg.search(/\n/);
328        }
329        if (messageEndIndex > 0) {
330          argsWithoutComponentStack.push(arg.slice(0, messageEndIndex));
331        }
332
333        componentStack = parseComponentStack(arg);
334      } else {
335        argsWithoutComponentStack.push(arg);
336      }
337    }
338  }
339
340  return {
341    ...parseInterpolation(argsWithoutComponentStack),
342    componentStack,
343  };
344}
345