com.google.javascript.jscomp.JsMessageVisitor Maven / Gradle / Ivy
/*
* Copyright 2008 The Closure Compiler Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.javascript.jscomp;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import com.google.common.annotations.GwtIncompatible;
import com.google.common.base.CaseFormat;
import com.google.debugging.sourcemap.proto.Mapping.OriginalMapping;
import com.google.javascript.jscomp.JsMessage.Builder;
import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback;
import com.google.javascript.jscomp.parsing.parser.util.format.SimpleFormat;
import com.google.javascript.rhino.JSDocInfo;
import com.google.javascript.rhino.Node;
import com.google.javascript.rhino.Token;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
/**
* Traverses across parsed tree and finds I18N messages. Then it passes it to {@link
* JsMessageVisitor#processJsMessage(JsMessage, JsMessageDefinition)}.
*/
@GwtIncompatible("JsMessage, java.util.regex")
public abstract class JsMessageVisitor extends AbstractPostOrderCallback implements CompilerPass {
private static final String MSG_FUNCTION_NAME = "goog.getMsg";
private static final String MSG_FALLBACK_FUNCTION_NAME = "goog.getMsgWithFallback";
static final DiagnosticType MESSAGE_HAS_NO_DESCRIPTION =
DiagnosticType.warning(
"JSC_MSG_HAS_NO_DESCRIPTION", "Message {0} has no description. Add @desc JsDoc tag.");
static final DiagnosticType MESSAGE_HAS_NO_TEXT =
DiagnosticType.warning(
"JSC_MSG_HAS_NO_TEXT",
"Message value of {0} is just an empty string. " + "Empty messages are forbidden.");
static final DiagnosticType MESSAGE_TREE_MALFORMED =
DiagnosticType.error("JSC_MSG_TREE_MALFORMED", "Message parse tree malformed. {0}");
static final DiagnosticType MESSAGE_HAS_NO_VALUE =
DiagnosticType.error("JSC_MSG_HAS_NO_VALUE", "message node {0} has no value");
static final DiagnosticType MESSAGE_DUPLICATE_KEY =
DiagnosticType.error(
"JSC_MSG_KEY_DUPLICATED",
"duplicate message variable name found for {0}, " + "initial definition {1}:{2}");
static final DiagnosticType MESSAGE_NODE_IS_ORPHANED =
DiagnosticType.warning(
"JSC_MSG_ORPHANED_NODE",
MSG_FUNCTION_NAME + "() function could be used only with MSG_* property or variable");
static final DiagnosticType MESSAGE_NOT_INITIALIZED_USING_NEW_SYNTAX =
DiagnosticType.warning(
"JSC_MSG_NOT_INITIALIZED_USING_NEW_SYNTAX",
"message not initialized using " + MSG_FUNCTION_NAME);
static final DiagnosticType BAD_FALLBACK_SYNTAX =
DiagnosticType.error(
"JSC_MSG_BAD_FALLBACK_SYNTAX",
SimpleFormat.format(
"Bad syntax. " + "Expected syntax: %s(MSG_1, MSG_2)", MSG_FALLBACK_FUNCTION_NAME));
static final DiagnosticType FALLBACK_ARG_ERROR =
DiagnosticType.error(
"JSC_MSG_FALLBACK_ARG_ERROR", "Could not find message entry for fallback argument {0}");
private static final String PH_JS_PREFIX = "{$";
private static final String PH_JS_SUFFIX = "}";
static final String MSG_PREFIX = "MSG_";
/**
* Pattern for unnamed messages.
*
* All JS messages in JS code should have unique name but messages in generated code (i.e. from
* soy template) could have duplicated message names. Later we replace the message names with ids
* constructed as a hash of the message content.
*
*
Soy generates messages with names
* MSG_UNNAMED.* . This pattern recognizes such messages.
*/
private static final Pattern MSG_UNNAMED_PATTERN = Pattern.compile("MSG_UNNAMED.*");
private static final Pattern CAMELCASE_PATTERN = Pattern.compile("[a-z][a-zA-Z\\d]*[_\\d]*");
static final String HIDDEN_DESC_PREFIX = "@hidden";
// For old-style JS messages
private static final String DESC_SUFFIX = "_HELP";
private final boolean needToCheckDuplications;
private final JsMessage.Style style;
private final JsMessage.IdGenerator idGenerator;
final AbstractCompiler compiler;
/**
* The names encountered associated with their defining node and source. We use it for tracking
* duplicated message ids in the source code.
*/
private final Map messageNames = new HashMap<>();
private final Map unnamedMessages = new HashMap<>();
/**
* List of found goog.getMsg call nodes.
*
* When we visit goog.getMsg() node we add it, and later when we visit its parent we remove it.
* All nodes that are left at the end of traversing are orphaned nodes. It means have no
* corresponding var or property node.
*/
private final Set googMsgNodes = new HashSet<>();
private final CheckLevel checkLevel;
/**
* Creates JS message visitor.
*
* @param compiler the compiler instance
* @param needToCheckDuplications whether to check duplicated messages in traversed
* @param style style that should be used during parsing
* @param idGenerator generator that used for creating unique ID for the message
*/
protected JsMessageVisitor(
AbstractCompiler compiler,
boolean needToCheckDuplications,
JsMessage.Style style,
JsMessage.IdGenerator idGenerator) {
this.compiler = compiler;
this.needToCheckDuplications = needToCheckDuplications;
this.style = style;
this.idGenerator = idGenerator;
checkLevel = (style == JsMessage.Style.CLOSURE) ? CheckLevel.ERROR : CheckLevel.WARNING;
// TODO(anatol): add flag that decides whether to process UNNAMED messages.
// Some projects would not want such functionality (unnamed) as they don't
// use SOY templates.
}
@Override
public void process(Node externs, Node root) {
NodeTraversal.traverse(compiler, root, this);
for (Node msgNode : googMsgNodes) {
compiler.report(JSError.make(msgNode, checkLevel, MESSAGE_NODE_IS_ORPHANED));
}
}
@Override
public void visit(NodeTraversal traversal, Node node, Node unused) {
collectGetMsgCall(traversal, node);
checkMessageInitialization(traversal, node);
}
/** This method is called for every Node in the sources AST. */
private void checkMessageInitialization(NodeTraversal traversal, Node node) {
final Node parent = node.getParent();
String messageKey;
final String originalMessageKey;
final Node msgNode;
final boolean isVar;
switch (node.getToken()) {
case NAME:
// Case: `var MSG_HELLO = 'Message';`
if (parent == null || !NodeUtil.isNameDeclaration(parent)) {
return;
}
messageKey = node.getString();
originalMessageKey = node.getOriginalName();
msgNode = node.getFirstChild();
isVar = true;
break;
case ASSIGN:
// Case: `somenamespace.someclass.MSG_HELLO = 'Message';`
Node getProp = node.getFirstChild();
if (!getProp.isGetProp()) {
return;
}
messageKey = getProp.getLastChild().getString();
originalMessageKey = getProp.getOriginalName();
msgNode = node.getLastChild();
isVar = false;
break;
case STRING_KEY:
// Case: `var t = {MSG_HELLO: 'Message'}`;
if (node.isQuotedString() || !node.hasChildren() || parent.isObjectPattern()) {
// Don't require goog.getMsg() for quoted keys
// Case: `var msgs = { 'MSG_QUOTED': anything };`
//
// Don't try to require goog.getMsg() for destructuring assignment targets.
// goog.getMsg() needs to be used in a direct assignment to a variable or property
// only.
// Case: `var {MSG_HELLO} = anything;
// Case: `var {something: MSG_HELLO} = anything;
return;
}
checkState(parent.isObjectLit(), parent);
messageKey = node.getString();
originalMessageKey = node.getOriginalName();
msgNode = node.getFirstChild();
isVar = false;
break;
default:
return;
}
if (originalMessageKey != null) {
messageKey = originalMessageKey;
}
// If we've reached this point, then messageKey is the name of a variable or a property that is
// being assigned a value and msgNode is the Node representing the value being assigned.
// However, we haven't actually determined yet that name looks like it should be a translatable
// message or that the value is a call to goog.getMsg().
// Is this a message name?
boolean isNewStyleMessage = msgNode != null && msgNode.isCall();
if (!isMessageName(messageKey, isNewStyleMessage)) {
return;
}
if (msgNode == null) {
compiler.report(JSError.make(node, MESSAGE_HAS_NO_VALUE, messageKey));
return;
}
if (isLegalMessageVarAlias(msgNode)) {
return;
}
// Report a warning if a qualified messageKey that looks like a message (e.g. "a.b.MSG_X")
// doesn't use goog.getMsg().
if (isNewStyleMessage) {
googMsgNodes.remove(msgNode);
} else if (style != JsMessage.Style.LEGACY) {
// TODO(johnlenz): promote this to an error once existing conflicts have been
// cleaned up.
compiler.report(JSError.make(node, MESSAGE_NOT_INITIALIZED_USING_NEW_SYNTAX));
if (style == JsMessage.Style.CLOSURE) {
// Don't extract the message if we aren't accepting LEGACY messages
return;
}
}
boolean isUnnamedMsg = isUnnamedMessageName(messageKey);
JsMessage.Builder builder = new JsMessage.Builder(isUnnamedMsg ? null : messageKey);
OriginalMapping mapping =
compiler.getSourceMapping(
traversal.getSourceName(), traversal.getLineNumber(), traversal.getCharno());
if (mapping != null) {
builder.setSourceName(mapping.getOriginalFile());
} else {
builder.setSourceName(traversal.getSourceName());
}
try {
if (isVar) {
extractMessageFromVariable(builder, node, parent, parent.getParent());
} else {
extractMessageFrom(builder, msgNode, node);
}
} catch (MalformedException ex) {
compiler.report(JSError.make(ex.getNode(), MESSAGE_TREE_MALFORMED, ex.getMessage()));
return;
}
JsMessage extractedMessage = builder.build(idGenerator);
// If asked to check named internal messages.
if (needToCheckDuplications && !isUnnamedMsg && !extractedMessage.isExternal()) {
checkIfMessageDuplicated(messageKey, msgNode);
}
trackMessage(traversal, extractedMessage, messageKey, msgNode, isUnnamedMsg);
if (extractedMessage.isEmpty()) {
// value of the message is an empty string. Translators do not like it.
compiler.report(JSError.make(node, MESSAGE_HAS_NO_TEXT, messageKey));
}
// New-style messages must have descriptions. We don't emit a warning
// for legacy-style messages, because there are thousands of
// them in legacy code that are not worth the effort to fix, since they've
// already been translated anyway.
String desc = extractedMessage.getDesc();
if (isNewStyleMessage
&& (desc == null || desc.trim().isEmpty())
&& !extractedMessage.isExternal()) {
compiler.report(JSError.make(node, MESSAGE_HAS_NO_DESCRIPTION, messageKey));
}
JsMessageDefinition msgDefinition = new JsMessageDefinition(msgNode);
processJsMessage(extractedMessage, msgDefinition);
}
private void collectGetMsgCall(NodeTraversal traversal, Node call) {
if (!call.isCall()) {
return;
}
// goog.getMsg()
if (call.getFirstChild().matchesQualifiedName(MSG_FUNCTION_NAME)) {
googMsgNodes.add(call);
} else if (call.getFirstChild().matchesQualifiedName(MSG_FALLBACK_FUNCTION_NAME)) {
visitFallbackFunctionCall(traversal, call);
}
}
/**
* Track a message for later retrieval.
*
* This is used for tracking duplicates, and for figuring out message fallback. Not all message
* types are trackable, because that would require a more sophisticated analysis. e.g., function
* f(s) { s.MSG_UNNAMED_X = 'Some untrackable message'; }
*/
private void trackMessage(
NodeTraversal t, JsMessage message, String msgName, Node msgNode, boolean isUnnamedMessage) {
if (!isUnnamedMessage) {
MessageLocation location = new MessageLocation(message, msgNode);
messageNames.put(msgName, location);
} else {
Var var = t.getScope().getVar(msgName);
if (var != null) {
unnamedMessages.put(var, message);
}
}
}
/**
* Defines any special cases that are exceptions to what would otherwise be illegal message
* assignments.
*
*
These exceptions are generally due to the pass being designed before new syntax was
* introduced.
*
* @param msgNode Node representing the value assigned to the message variable or property
*/
private static boolean isLegalMessageVarAlias(Node msgNode) {
if (msgNode.isGetProp()
&& msgNode.isQualifiedName()
&& msgNode.getLastChild().getString().startsWith(MSG_PREFIX)) {
// Case: `foo.Thing.MSG_EXAMPLE_ALIAS = bar.OtherThing.MSG_EXAMPLE;`
//
// This kind of construct is created by TypeScript code generation and
// Es6ToEs3ClassSideInheritance. Just ignore it; the message will have already been extracted
// from the base class.
return true;
}
if (!msgNode.isName()) {
return false;
}
String originalName =
(msgNode.getOriginalName() != null) ? msgNode.getOriginalName() : msgNode.getString();
if (originalName.startsWith(MSG_PREFIX)) {
// Creating an alias for a message is also allowed, and sometimes happens in generated code,
// including some of the code generated by this compiler's transpilations.
// e.g.
// `var MSG_EXAMPLE_ALIAS = MSG_EXAMPLE;`
// `var {MSG_HELLO_ALIAS} = MSG_HELLO;
// `var {MSG_HELLO_ALIAS: goog$module$my$module_MSG_HELLO} = x;`).
// `exports = {MSG_FOO}`
// or `exports = {MSG_FOO: MSG_FOO}` when used with declareLegacyNamespace.
return true;
}
return false;
}
/** Get a previously tracked message. */
private JsMessage getTrackedMessage(NodeTraversal t, String msgName) {
boolean isUnnamedMessage = isUnnamedMessageName(msgName);
if (!isUnnamedMessage) {
MessageLocation location = messageNames.get(msgName);
return location == null ? null : location.message;
} else {
Var var = t.getScope().getVar(msgName);
if (var != null) {
return unnamedMessages.get(var);
}
}
return null;
}
/**
* Checks if message already processed. If so - it generates 'message duplicated' compiler error.
*
* @param msgName the name of the message
* @param msgNode the node that represents JS message
*/
private void checkIfMessageDuplicated(String msgName, Node msgNode) {
if (messageNames.containsKey(msgName)) {
MessageLocation location = messageNames.get(msgName);
compiler.report(
JSError.make(
msgNode,
MESSAGE_DUPLICATE_KEY,
msgName,
location.messageNode.getSourceFileName(),
Integer.toString(location.messageNode.getLineno())));
}
}
/**
* Creates a {@link JsMessage} for a JS message defined using a JS variable declaration (e.g
* var MSG_X = ...;
).
*
* @param builder the message builder
* @param nameNode a NAME node for a JS message variable
* @param parentNode a VAR node, parent of {@code nameNode}
* @param grandParentNode the grandparent of {@code nameNode}. This node is only used to get meta
* data about the message that might be surrounding it (e.g. a message description). This
* argument may be null if the meta data is not needed.
* @throws MalformedException if {@code varNode} does not correspond to a valid JS message VAR
* node
*/
private void extractMessageFromVariable(
Builder builder, Node nameNode, Node parentNode, @Nullable Node grandParentNode)
throws MalformedException {
// Determine the message's value
Node valueNode = nameNode.getFirstChild();
switch (valueNode.getToken()) {
case STRING:
case ADD:
maybeInitMetaDataFromJsDocOrHelpVar(builder, parentNode, grandParentNode);
builder.appendStringPart(extractStringFromStringExprNode(valueNode));
break;
case FUNCTION:
maybeInitMetaDataFromJsDocOrHelpVar(builder, parentNode, grandParentNode);
extractFromFunctionNode(builder, valueNode);
break;
case CALL:
maybeInitMetaDataFromJsDoc(builder, parentNode);
extractFromCallNode(builder, valueNode);
break;
default:
throw new MalformedException(
"Cannot parse value of message " + builder.getKey(), valueNode);
}
}
/**
* Creates a {@link JsMessage} for a JS message defined using an assignment to a qualified name
* (e.g a.b.MSG_X = goog.getMsg(...);
).
*
* @param builder the message builder
* @param valueNode a node in a JS message value
* @param docNode the node containing the jsdoc.
* @throws MalformedException if {@code getPropNode} does not correspond to a valid JS message
* node
*/
private void extractMessageFrom(Builder builder, Node valueNode, Node docNode)
throws MalformedException {
maybeInitMetaDataFromJsDoc(builder, docNode);
extractFromCallNode(builder, valueNode);
}
/**
* Initializes the meta data in a JsMessage by examining the nodes just before and after a message
* VAR node.
*
* @param builder the message builder whose meta data will be initialized
* @param varNode the message VAR node
* @param parentOfVarNode {@code varNode}'s parent node
*/
private void maybeInitMetaDataFromJsDocOrHelpVar(
Builder builder, Node varNode, @Nullable Node parentOfVarNode) throws MalformedException {
// First check description in @desc
if (maybeInitMetaDataFromJsDoc(builder, varNode)) {
return;
}
// Check the preceding node for meta data
if ((parentOfVarNode != null) && maybeInitMetaDataFromHelpVar(builder, varNode.getPrevious())) {
return;
}
// Check the subsequent node for meta data
maybeInitMetaDataFromHelpVar(builder, varNode.getNext());
}
/**
* Initializes the meta data in a JsMessage by examining a node just before or after a message VAR
* node.
*
* @param builder the message builder whose meta data will be initialized
* @param sibling a node adjacent to the message VAR node
* @return true iff message has corresponding description variable
*/
private static boolean maybeInitMetaDataFromHelpVar(Builder builder, @Nullable Node sibling)
throws MalformedException {
if ((sibling != null) && (sibling.isVar())) {
Node nameNode = sibling.getFirstChild();
String name = nameNode.getString();
if (name.equals(builder.getKey() + DESC_SUFFIX)) {
Node valueNode = nameNode.getFirstChild();
String desc = extractStringFromStringExprNode(valueNode);
if (desc.startsWith(HIDDEN_DESC_PREFIX)) {
builder.setDesc(desc.substring(HIDDEN_DESC_PREFIX.length()).trim());
builder.setIsHidden(true);
} else {
builder.setDesc(desc);
}
return true;
}
}
return false;
}
/**
* Initializes the meta data in a message builder given a node that may contain JsDoc properties.
*
* @param builder the message builder whose meta data will be initialized
* @param node the node with the message's JSDoc properties
* @return true if message has JsDoc with valid description in @desc annotation
*/
private static boolean maybeInitMetaDataFromJsDoc(Builder builder, Node node) {
boolean messageHasDesc = false;
JSDocInfo info = node.getJSDocInfo();
if (info != null) {
String desc = info.getDescription();
if (desc != null) {
builder.setDesc(desc);
messageHasDesc = true;
}
if (info.isHidden()) {
builder.setIsHidden(true);
}
if (info.getMeaning() != null) {
builder.setMeaning(info.getMeaning());
}
if (info.getAlternateMessageId() != null) {
builder.setAlternateId(info.getAlternateMessageId());
}
}
return messageHasDesc;
}
/**
* Returns the string value associated with a node representing a JS string or several JS strings
* added together (e.g. {@code 'str'} or {@code 's' + 't' + 'r'}).
*
* @param node the node from where we extract the string
* @return String representation of the node
* @throws MalformedException if the parsed message is invalid
*/
private static String extractStringFromStringExprNode(Node node) throws MalformedException {
switch (node.getToken()) {
case STRING:
return node.getString();
case TEMPLATELIT:
if (node.hasOneChild()) {
// Cooked string can be null only for tagged template literals.
// A tagged template literal would hit the default case below.
return checkNotNull(node.getFirstChild().getCookedString());
} else {
throw new MalformedException(
"Template literals with substitutions are not allowed.", node);
}
case ADD:
StringBuilder sb = new StringBuilder();
for (Node child : node.children()) {
sb.append(extractStringFromStringExprNode(child));
}
return sb.toString();
default:
throw new MalformedException(
"STRING or ADD node expected; found: " + node.getToken(), node);
}
}
/**
* Initializes a message builder from a FUNCTION node.
*
*
*
*
* The tree should look something like:
*
* function
* |-- name
* |-- lp
* | |-- name
* | -- name
* -- block
* |
* --return
* |
* --add
* |-- string foo
* -- name
*
*
* @param builder the message builder
* @param node the function node that contains a message
* @throws MalformedException if the parsed message is invalid
*/
private void extractFromFunctionNode(Builder builder, Node node) throws MalformedException {
Set phNames = new HashSet<>();
for (Node fnChild : node.children()) {
switch (fnChild.getToken()) {
case NAME:
// This is okay. The function has a name, but it is empty.
break;
case PARAM_LIST:
// Parse the placeholder names from the function argument list.
for (Node argumentNode : fnChild.children()) {
if (argumentNode.isName()) {
String phName = argumentNode.getString();
if (phNames.contains(phName)) {
throw new MalformedException("Duplicate placeholder name: " + phName, argumentNode);
} else {
phNames.add(phName);
}
}
}
break;
case BLOCK:
// Build the message's value by examining the return statement
Node returnNode = fnChild.getFirstChild();
if (!returnNode.isReturn()) {
throw new MalformedException(
"RETURN node expected; found: " + returnNode.getToken(), returnNode);
}
for (Node child : returnNode.children()) {
extractFromReturnDescendant(builder, child);
}
// Check that all placeholders from the message text have appropriate
// object literal keys
for (String phName : builder.getPlaceholders()) {
if (!phNames.contains(phName)) {
throw new MalformedException(
"Unrecognized message placeholder referenced: " + phName, returnNode);
}
}
break;
default:
throw new MalformedException(
"NAME, PARAM_LIST, or BLOCK node expected; found: " + node, fnChild);
}
}
}
/**
* Appends value parts to the message builder by traversing the descendants of the given RETURN
* node.
*
* @param builder the message builder
* @param node the node from where we extract a message
* @throws MalformedException if the parsed message is invalid
*/
private static void extractFromReturnDescendant(Builder builder, Node node)
throws MalformedException {
switch (node.getToken()) {
case STRING:
builder.appendStringPart(node.getString());
break;
case NAME:
builder.appendPlaceholderReference(node.getString());
break;
case ADD:
for (Node child : node.children()) {
extractFromReturnDescendant(builder, child);
}
break;
default:
throw new MalformedException(
"STRING, NAME, or ADD node expected; found: " + node.getToken(), node);
}
}
/**
* Initializes a message builder from a CALL node.
*
* The tree should look something like:
*
*
* call
* |-- getprop
* | |-- name 'goog'
* | +-- string 'getMsg'
* |
* |-- string 'Hi {$userName}! Welcome to {$product}.'
* +-- objlit
* |-- string_key 'userName'
* | +-- name 'someUserName'
* +-- string_key 'product'
* +-- call
* +-- name 'getProductName'
*
*
* @param builder the message builder
* @param node the call node from where we extract the message
* @throws MalformedException if the parsed message is invalid
*/
private void extractFromCallNode(Builder builder, Node node) throws MalformedException {
// Check the function being called
if (!node.isCall()) {
throw new MalformedException(
"Message must be initialized using " + MSG_FUNCTION_NAME + " function.", node);
}
Node fnNameNode = node.getFirstChild();
if (!fnNameNode.matchesQualifiedName(MSG_FUNCTION_NAME)) {
throw new MalformedException(
"Message initialized using unrecognized function. "
+ "Please use "
+ MSG_FUNCTION_NAME
+ "() instead.",
fnNameNode);
}
// Get the message string
Node stringLiteralNode = fnNameNode.getNext();
if (stringLiteralNode == null) {
throw new MalformedException("Message string literal expected", stringLiteralNode);
}
// Parse the message string and append parts to the builder
parseMessageTextNode(builder, stringLiteralNode);
Node objLitNode = stringLiteralNode.getNext();
Set phNames = new HashSet<>();
if (objLitNode != null) {
// Register the placeholder names
if (!objLitNode.isObjectLit()) {
throw new MalformedException("OBJLIT node expected", objLitNode);
}
for (Node aNode = objLitNode.getFirstChild(); aNode != null; aNode = aNode.getNext()) {
if (!aNode.isStringKey()) {
throw new MalformedException("STRING_KEY node expected as OBJLIT key", aNode);
}
String phName = aNode.getString();
if (!isLowerCamelCaseWithNumericSuffixes(phName)) {
throw new MalformedException("Placeholder name not in lowerCamelCase: " + phName, aNode);
}
if (phNames.contains(phName)) {
throw new MalformedException("Duplicate placeholder name: " + phName, aNode);
}
phNames.add(phName);
}
}
// Check that all placeholders from the message text have appropriate objlit
// values
Set usedPlaceholders = builder.getPlaceholders();
for (String phName : usedPlaceholders) {
if (!phNames.contains(phName)) {
throw new MalformedException(
"Unrecognized message placeholder referenced: " + phName, node);
}
}
// Check that objLiteral have only names that are present in the
// message text
for (String phName : phNames) {
if (!usedPlaceholders.contains(phName)) {
throw new MalformedException("Unused message placeholder: " + phName, node);
}
}
}
/**
* Appends the message parts in a JS message value extracted from the given text node.
*
* @param builder the JS message builder to append parts to
* @param node the node with string literal that contains the message text
* @throws MalformedException if {@code value} contains a reference to an unregistered placeholder
*/
private static void parseMessageTextNode(Builder builder, Node node) throws MalformedException {
String value = extractStringFromStringExprNode(node);
while (true) {
int phBegin = value.indexOf(PH_JS_PREFIX);
if (phBegin < 0) {
// Just a string literal
builder.appendStringPart(value);
return;
} else {
if (phBegin > 0) {
// A string literal followed by a placeholder
builder.appendStringPart(value.substring(0, phBegin));
}
// A placeholder. Find where it ends
int phEnd = value.indexOf(PH_JS_SUFFIX, phBegin);
if (phEnd < 0) {
throw new MalformedException(
"Placeholder incorrectly formatted in: " + builder.getKey(), node);
}
String phName = value.substring(phBegin + PH_JS_PREFIX.length(), phEnd);
builder.appendPlaceholderReference(phName);
int nextPos = phEnd + PH_JS_SUFFIX.length();
if (nextPos < value.length()) {
// Iterate on the rest of the message value
value = value.substring(nextPos);
} else {
// The message is parsed
return;
}
}
}
}
/** Visit a call to goog.getMsgWithFallback. */
private void visitFallbackFunctionCall(NodeTraversal t, Node call) {
// Check to make sure the function call looks like:
// goog.getMsgWithFallback(MSG_1, MSG_2);
// or:
// goog.getMsgWithFallback(some.import.MSG_1, some.import.MSG_2);
if (!call.hasXChildren(3)
|| !isMessageIdentifier(call.getSecondChild())
|| !isMessageIdentifier(call.getLastChild())) {
compiler.report(JSError.make(call, BAD_FALLBACK_SYNTAX));
return;
}
Node firstArg = call.getSecondChild();
String name = getMessageNameFromNode(firstArg);
JsMessage firstMessage = getTrackedMessage(t, name);
if (firstMessage == null) {
compiler.report(JSError.make(firstArg, FALLBACK_ARG_ERROR, name));
return;
}
Node secondArg = firstArg.getNext();
name = getMessageNameFromNode(secondArg);
if (name == null) {
name = secondArg.getString();
}
JsMessage secondMessage = getTrackedMessage(t, name);
if (secondMessage == null) {
compiler.report(JSError.make(secondArg, FALLBACK_ARG_ERROR, name));
return;
}
processMessageFallback(call, firstMessage, secondMessage);
}
/**
* Processes found JS message. Several examples of "standard" processing routines are:
*
*
* - extract all JS messages
*
- replace JS messages with localized versions for some specific language
*
- check that messages have correct syntax and present in localization bundle
*
*
* @param message the found message
* @param definition the definition of the object and usually contains all additional message
* information like message node/parent's node
*/
protected abstract void processJsMessage(JsMessage message, JsMessageDefinition definition);
/**
* Processes the goog.getMsgWithFallback primitive. goog.getMsgWithFallback(MSG_1, MSG_2);
*
* By default, does nothing.
*/
void processMessageFallback(Node callNode, JsMessage message1, JsMessage message2) {}
/** Returns whether the given JS identifier is a valid JS message name. */
boolean isMessageName(String identifier, boolean isNewStyleMessage) {
return identifier.startsWith(MSG_PREFIX)
&& (style == JsMessage.Style.CLOSURE
|| isNewStyleMessage
|| !identifier.endsWith(DESC_SUFFIX));
}
private static boolean isMessageIdentifier(Node node) {
return getMessageNameFromNode(node) != null;
}
/**
* Extracts a message name (e.g. MSG_FOO) from either a NAME node or a GETPROP node. This should
* cover all of the following cases:
*
*
* - a NAME node (e.g. MSG_FOO)
*
- a NAME node which is the product of renaming (e.g. $module$contents$MSG_FOO)
*
- a GETPROP node (e.g. some.import.MSG_FOO)
*
*/
private static String getMessageNameFromNode(Node node) {
String messageName = node.getQualifiedName();
if (messageName == null) {
return null;
}
if (messageName.contains(MSG_PREFIX)) {
return messageName.substring(messageName.indexOf(MSG_PREFIX));
}
return null;
}
/** Returns whether the given message name is in the unnamed namespace. */
private static boolean isUnnamedMessageName(String identifier) {
return MSG_UNNAMED_PATTERN.matcher(identifier).matches();
}
/**
* Returns whether a string is nonempty, begins with a lowercase letter, and contains only digits
* and underscores after the first underscore.
*/
static boolean isLowerCamelCaseWithNumericSuffixes(String input) {
return CAMELCASE_PATTERN.matcher(input).matches();
}
/**
* Converts the given string from upper-underscore case to lower-camel case, preserving numeric
* suffixes. For example: "NAME" -> "name" "A4_LETTER" -> "a4Letter" "START_SPAN_1_23" ->
* "startSpan_1_23".
*/
static String toLowerCamelCaseWithNumericSuffixes(String input) {
// Determine where the numeric suffixes begin
int suffixStart = input.length();
while (suffixStart > 0) {
char ch = '\0';
int numberStart = suffixStart;
while (numberStart > 0) {
ch = input.charAt(numberStart - 1);
if (Character.isDigit(ch)) {
numberStart--;
} else {
break;
}
}
if ((numberStart > 0) && (numberStart < suffixStart) && (ch == '_')) {
suffixStart = numberStart - 1;
} else {
break;
}
}
if (suffixStart == input.length()) {
return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, input);
} else {
return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, input.substring(0, suffixStart))
+ input.substring(suffixStart);
}
}
/**
* Checks a node's type.
*
* @throws MalformedException if the node is null or the wrong type
*/
protected void checkNode(@Nullable Node node, Token type) throws MalformedException {
if (node == null) {
throw new MalformedException("Expected node type " + type + "; found: null", node);
}
if (node.getToken() != type) {
throw new MalformedException(
"Expected node type " + type + "; found: " + node.getToken(), node);
}
}
static class MalformedException extends Exception {
private static final long serialVersionUID = 1L;
private final Node node;
MalformedException(String message, Node node) {
super(message);
this.node = node;
}
Node getNode() {
return node;
}
}
private static class MessageLocation {
private final JsMessage message;
private final Node messageNode;
private MessageLocation(JsMessage message, Node messageNode) {
this.message = message;
this.messageNode = messageNode;
}
}
}