/*
* Copyright (c) 2014-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.stetho.rhino;
import android.support.annotation.NonNull;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* <p>Formatter that tries to mimic <pre>console.log()</pre>'s format as close as possible.</p>
*
* <p>We have to use a custom formatter because <pre>String.format()</pre> will fail with JavaScript
* numbers. Using the conversion format %d in JavaScript causes a problem with Java's <pre>String.format()</pre>.
* This happens because %d expects an int/Integer but in JavaScript numbers are floats!
* </p>
*
* <p>See <a href="https://developer.chrome.com/devtools/docs/console-api#consolelogobject-object">Console API</a>.</p>
*/
class JsFormat {
/**
* Format specifier pattern.
* <p/>
* <code>%[argument_index$][flags][width][.precision]conversion</code>
*/
private static final Pattern FORMAT_SPECIFIER_PATTERN = Pattern.compile(
"^%"
+ "([0-9]+ [$])?" // Index
+ "([0-9]+)?" // Width
+ "([.] [0-9]+)?" // Precision
+ "([difs])", // Conversion
Pattern.COMMENTS
);
/**
* Simple wrapper around a char[]. New versions of java make a full copy of
* the string when doing substring(). With this class we avoid the copies
* and substring is still O(1) vs O(n).
*/
private static class ArrayCharSequence implements CharSequence {
private final @NonNull char[] array;
private final int start;
private final int end;
private ArrayCharSequence(@NonNull char[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
public int length() {
return end - start;
}
@Override
public char charAt(int index) {
return array[start + index];
}
@Override
public CharSequence subSequence(int start, int end) {
return new ArrayCharSequence(array, this.start + start, this.start + end);
}
private @NonNull CharSequence substring(int start) {
return new ArrayCharSequence(array, this.start + start, this.start + end);
}
@Override
public @NonNull String toString() {
return new String(array, start, end - start);
}
}
/**
* Takes the arguments that console.log() would use, parses them and returns the
* final string to output.
*
* @param args format and arguments
* @return a string with the message to output
*/
static @NonNull String parse(@NonNull Object...args) {
// The params available, we need to know if they where taken or not
boolean[] argsUsed = new boolean[args.length];
// The first argument is the format and is always used
String format = (String) args[0];
argsUsed[0] = true;
int nextArgIndex = 1;
// Scan the format and find all %s patterns
final char[] chars = format.toCharArray();
StringBuilder buffer = new StringBuilder();
ArrayCharSequence sequence = new ArrayCharSequence(chars, 0, chars.length);
for (int i = 0; i < chars.length; ++i) {
char c = chars[i];
if (c != '%') {
// Keep eating chars until we get a '%'
buffer.append(c);
continue;
}
// Found a %, is it a stand alone one?
Matcher matcher = FORMAT_SPECIFIER_PATTERN.matcher(sequence.substring(i));
if (!matcher.find()) {
// Didn't find a valid format specifier, maybe it is a "%%" ?
if (i + 1 < chars.length) {
char peek = chars[i + 1];
if (peek == '%') {
// Found "%%" which at the end maps as a single '%'
++i;
}
}
// A stand alone '%', we will just print it as it is
buffer.append('%');
continue;
}
// Analyze the format. We don't have named captures in android yet so we will inspect
// the groups. They are each optional but we can find out which one is whic easily.
// Remember that we want to parse: %[argument_index$][flags][width][.precision]conversion
//
// - `index` ends with '$'
// - `precision` starts with '.'
// - we don't support flags
// - `width` is just numbers
// - `conversion` is a single letter
int groupCount = matcher.groupCount();
int index = -1;
int width = -1;
int precision = -1;
char conversion = 0;
for (int groupIdx = 1; groupIdx <= groupCount; ++groupIdx) {
String value = matcher.group(groupIdx);
if (value == null || value.equals("")) {
// Empty group, we ignore it
continue;
}
if (value.endsWith("$")) {
// The `index` (it ends with a '$')
value = value.substring(0, value.length() - 1);
index = Integer.parseInt(value);
continue;
}
char first = value.charAt(0);
if (first == '.') {
// The `precision` (it starts with a dot '.')
value = value.substring(1);
precision = Integer.parseInt(value);
} else if (first >= '0' && first <= '9') {
// The `width`
width = Integer.parseInt(value);
} else {
// It has to be the `conversion`
conversion = first;
}
}
// Now we try to see which argument we have to take
String currentFormat = matcher.group();
final Object value;
final boolean found;
if (index > argsUsed.length || (width > -1 && index == -1)) {
// Index out of bounds (%1234$d), print the format as it is and ignore
value = null;
found = false;
} else if (index <= argsUsed.length && index > 0) {
// Index if valid (%3$d)
value = args[index];
argsUsed[index] = true;
nextArgIndex = index + 1;
found = true;
} else {
// No index provided (%d)
if (nextArgIndex < argsUsed.length) {
value = args[nextArgIndex];
argsUsed[nextArgIndex] = true;
++nextArgIndex;
found = true;
} else {
// We have way too many %d, more than we have arguments!
value = null;
found = false;
}
}
if (!found) {
// Just dump the placeholder text as it is and ignore
buffer.append(currentFormat);
i += currentFormat.length() - 1;
continue;
}
// Apply the conversion
switch (conversion) {
case 'd':
case 'i':
Object l;
if (value instanceof String) {
try {
l = Long.parseLong((String) value);
} catch (NumberFormatException e) {
l = "NaN";
}
} else if (value instanceof Number) {
l = ((Number) value).intValue();
} else {
l = 0;
}
buffer.append(l);
break;
case 'f':
Object d;
if (value instanceof String) {
try {
d = Double.parseDouble((String) value);
} catch (NumberFormatException e) {
d = "NaN";
}
} else if (value instanceof Number) {
d = ((Number) value).doubleValue();
} else {
d = 0;
}
if (precision > -1 && d instanceof Number) {
d = String.format(Locale.US, "%." + precision + "f", d);
}
buffer.append(d);
break;
case 's':
default:
buffer.append(value);
break;
}
i += currentFormat.length() - 1;
}
// Concatenate all params that have not been used
for (int j = 0; j < argsUsed.length; j++) {
boolean argUsed = argsUsed[j];
if (!argUsed) {
buffer.append(" ");
buffer.append(args[j]);
}
}
return buffer.toString();
}
}