blob: 306b5d48505c3ff230bd10e1ec61e46aad88a7db [file] [log] [blame]
// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// This is for use in extracting messages from a Dart program
/// using the Intl.message() mechanism and writing them to a file for
/// translation. This provides only the stub of a mechanism, because it
/// doesn't define how the file should be written. It provides an
/// [IntlMessage] class that holds the extracted data and [parseString]
/// and [parseFile] methods which
/// can extract messages that conform to the expected pattern:
/// (parameters) => Intl.message("Message $parameters", desc: ...);
/// It uses the analyzer package to do the parsing, so may
/// break if there are changes to the API that it provides.
/// An example can be found in test/message_extraction/extract_to_json.dart
///
/// Note that this does not understand how to follow part directives, so it
/// has to explicitly be given all the files that it needs. A typical use case
/// is to run it on all .dart files in a directory.
library extract_messages;
import 'dart:io';
import 'package:analyzer/analyzer.dart';
import 'package:intl/src/intl_message.dart';
import 'package:intl/src/intl_helpers.dart';
/// A function that takes a message and does something useful with it.
typedef void OnMessage(String message);
/// A particular message extraction run.
///
/// This encapsulates all the state required for message extraction so that
/// it can be run inside a persistent process.
class MessageExtraction {
/// What to do when a message is encountered, defaults to [print].
OnMessage onMessage = print;
/// If this is true, print warnings for skipped messages. Otherwise, warnings
/// are suppressed.
bool suppressWarnings = false;
/// If this is true, then treat all warnings as errors.
bool warningsAreErrors = false;
/// This accumulates a list of all warnings/errors we have found. These are
/// saved as strings right now, so all that can really be done is print and
/// count them.
List<String> warnings = [];
/// Were there any warnings or errors in extracting messages.
bool get hasWarnings => warnings.isNotEmpty;
/// Are plural and gender expressions required to be at the top level
/// of an expression, or are they allowed to be embedded in string literals.
///
/// For example, the following expression
/// 'There are ${Intl.plural(...)} items'.
/// is legal if [allowEmbeddedPluralsAndGenders] is true, but illegal
/// if [allowEmbeddedPluralsAndGenders] is false.
bool allowEmbeddedPluralsAndGenders = true;
/// Parse the source of the Dart program file [file] and return a Map from
/// message names to [IntlMessage] instances.
///
/// If [transformer] is true, assume the transformer will supply any "name"
/// and "args" parameters required in Intl.message calls.
Map<String, MainMessage> parseFile(File file, [transformer = false]) {
// Optimization to avoid parsing files we're sure don't contain any messages.
String contents = file.readAsStringSync();
origin = file.path;
if (contents.contains("Intl.")) {
root = _parseCompilationUnit(contents, origin);
} else {
return {};
}
var visitor = new MessageFindingVisitor(this);
visitor.generateNameAndArgs = transformer;
root.accept(visitor);
return visitor.messages;
}
CompilationUnit _parseCompilationUnit(String contents, String origin) {
var parsed;
try {
parsed = parseCompilationUnit(contents);
} on AnalyzerErrorGroup catch (e) {
print("Error in parsing $origin, no messages extracted.");
print(" $e");
}
return parsed;
}
/// The root of the compilation unit, and the first node we visit. We hold
/// on to this for error reporting, as it can give us line numbers of other
/// nodes.
CompilationUnit root;
/// An arbitrary string describing where the source code came from. Most
/// obviously, this could be a file path. We use this when reporting
/// invalid messages.
String origin;
String _reportErrorLocation(AstNode node) {
var result = new StringBuffer();
if (origin != null) result.write(" from $origin");
var info = root.lineInfo;
if (info != null) {
var line = info.getLocation(node.offset);
result
.write(" line: ${line.lineNumber}, column: ${line.columnNumber}");
}
return result.toString();
}
}
/// This visits the program source nodes looking for Intl.message uses
/// that conform to its pattern and then creating the corresponding
/// IntlMessage objects. We have to find both the enclosing function, and
/// the Intl.message invocation.
class MessageFindingVisitor extends GeneralizingAstVisitor {
MessageFindingVisitor(this.extraction);
/// The message extraction in which we are running.
final MessageExtraction extraction;
/// Accumulates the messages we have found, keyed by name.
final Map<String, MainMessage> messages = new Map<String, MainMessage>();
/// Should we generate the name and arguments from the function definition,
/// meaning we're running in the transformer.
bool generateNameAndArgs = false;
/// We keep track of the data from the last MethodDeclaration,
/// FunctionDeclaration or FunctionExpression that we saw on the way down,
/// as that will be the nearest parent of the Intl.message invocation.
FormalParameterList parameters;
String name;
/// Return true if [node] matches the pattern we expect for Intl.message()
bool looksLikeIntlMessage(MethodInvocation node) {
const validNames = const ["message", "plural", "gender", "select"];
if (!validNames.contains(node.methodName.name)) return false;
if (!(node.target is SimpleIdentifier)) return false;
SimpleIdentifier target = node.target;
return target.token.toString() == "Intl";
}
Message _expectedInstance(String type) {
switch (type) {
case 'message':
return new MainMessage();
case 'plural':
return new Plural();
case 'gender':
return new Gender();
case 'select':
return new Select();
default:
return null;
}
}
/// Returns a String describing why the node is invalid, or null if no
/// reason is found, so it's presumed valid.
String checkValidity(MethodInvocation node) {
// The containing function cannot have named parameters.
if (parameters.parameters.any((each) => each.kind == ParameterKind.NAMED)) {
return "Named parameters on message functions are not supported.";
}
var arguments = node.argumentList.arguments;
var instance = _expectedInstance(node.methodName.name);
return instance.checkValidity(node, arguments, name, parameters,
nameAndArgsGenerated: generateNameAndArgs);
}
/// Record the parameters of the function or method declaration we last
/// encountered before seeing the Intl.message call.
void visitMethodDeclaration(MethodDeclaration node) {
parameters = node.parameters;
if (parameters == null) {
parameters = new FormalParameterList(null, [], null, null, null);
}
name = node.name.name;
super.visitMethodDeclaration(node);
}
/// Record the parameters of the function or method declaration we last
/// encountered before seeing the Intl.message call.
void visitFunctionDeclaration(FunctionDeclaration node) {
parameters = node.functionExpression.parameters;
if (parameters == null) {
parameters = new FormalParameterList(null, [], null, null, null);
}
name = node.name.name;
super.visitFunctionDeclaration(node);
}
/// Examine method invocations to see if they look like calls to Intl.message.
/// If we've found one, stop recursing. This is important because we can have
/// Intl.message(...Intl.plural...) and we don't want to treat the inner
/// plural as if it was an outermost message.
void visitMethodInvocation(MethodInvocation node) {
if (!addIntlMessage(node)) {
super.visitMethodInvocation(node);
}
}
/// Check that the node looks like an Intl.message invocation, and create
/// the [IntlMessage] object from it and store it in [messages]. Return true
/// if we successfully extracted a message and should stop looking. Return
/// false if we didn't, so should continue recursing.
bool addIntlMessage(MethodInvocation node) {
if (!looksLikeIntlMessage(node)) return false;
var reason = checkValidity(node);
if (reason != null) {
if (!extraction.suppressWarnings) {
var err = new StringBuffer()
..write("Skipping invalid Intl.message invocation\n <$node>\n")
..writeAll(
[" reason: $reason\n", extraction._reportErrorLocation(node)]);
var errString = err.toString();
extraction.warnings.add(errString);
extraction.onMessage(errString);
}
// We found one, but it's not valid. Stop recursing.
return true;
}
var message;
if (node.methodName.name == "message") {
message = messageFromIntlMessageCall(node);
} else {
message = messageFromDirectPluralOrGenderCall(node);
}
if (message != null) messages[message.name] = message;
return true;
}
/// Create a MainMessage from [node] using the name and
/// parameters of the last function/method declaration we encountered,
/// and the values we get by calling [extract]. We set those values
/// by calling [setAttribute]. This is the common parts between
/// [messageFromIntlMessageCall] and [messageFromDirectPluralOrGenderCall].
MainMessage _messageFromNode(
MethodInvocation node,
MainMessage extract(MainMessage message, List<AstNode> arguments),
void setAttribute(
MainMessage message, String fieldName, Object fieldValue)) {
var message = new MainMessage();
message.sourcePosition = node.offset;
message.endPosition = node.end;
message.arguments =
parameters.parameters.map((x) => x.identifier.name).toList();
var arguments = node.argumentList.arguments;
var extractionResult = extract(message, arguments);
if (extractionResult == null) return null;
for (NamedExpression namedArgument
in arguments.where((x) => x is NamedExpression)) {
var name = namedArgument.name.label.name;
var exp = namedArgument.expression;
var evaluator = new ConstantEvaluator();
var basicValue = exp.accept(evaluator);
var value = basicValue == ConstantEvaluator.NOT_A_CONSTANT
? exp.toString()
: basicValue;
setAttribute(message, name, value);
}
// We only rewrite messages with parameters, otherwise we use the literal
// string as the name and no arguments are necessary.
if (!message.hasName) {
if (generateNameAndArgs && message.arguments.isNotEmpty) {
// Always try for class_method if this is a class method and
// generating names/args.
message.name = Message.classPlusMethodName(node, name) ?? name;
} else if (arguments.first is SimpleStringLiteral ||
arguments.first is AdjacentStrings) {
// If there's no name, and the message text is a simple string, compute
// a name based on that plus meaning, if present.
var simpleName = (arguments.first as StringLiteral).stringValue;
message.name = computeMessageName(message.name, simpleName, message.meaning);
}
}
return message;
}
/// Find the message pieces from a Dart interpolated string.
List _extractFromIntlCallWithInterpolation(
MainMessage message, List<AstNode> arguments) {
var interpolation = new InterpolationVisitor(message, extraction);
arguments.first.accept(interpolation);
if (interpolation.pieces.any((x) => x is Plural || x is Gender) &&
!extraction.allowEmbeddedPluralsAndGenders) {
if (interpolation.pieces.any((x) => x is String && x.isNotEmpty)) {
throw new IntlMessageExtractionException(
"Plural and gender expressions must be at the top level, "
"they cannot be embedded in larger string literals.\n");
}
}
return interpolation.pieces;
}
/// Create a MainMessage from [node] using the name and
/// parameters of the last function/method declaration we encountered
/// and the parameters to the Intl.message call.
MainMessage messageFromIntlMessageCall(MethodInvocation node) {
MainMessage extractFromIntlCall(
MainMessage message, List<AstNode> arguments) {
try {
// The pieces of the message, either literal strings, or integers
// representing the index of the argument to be substituted.
List extracted;
extracted = _extractFromIntlCallWithInterpolation(message, arguments);
message.addPieces(extracted);
} on IntlMessageExtractionException catch (e) {
message = null;
var err = new StringBuffer()
..writeAll(["Error ", e, "\nProcessing <", node, ">\n"])
..write(extraction._reportErrorLocation(node));
var errString = err.toString();
extraction.onMessage(errString);
extraction.warnings.add(errString);
}
return message;
}
void setValue(MainMessage message, String fieldName, Object fieldValue) {
message[fieldName] = fieldValue;
}
return _messageFromNode(node, extractFromIntlCall, setValue);
}
/// Create a MainMessage from [node] using the name and
/// parameters of the last function/method declaration we encountered
/// and the parameters to the Intl.plural or Intl.gender call.
MainMessage messageFromDirectPluralOrGenderCall(MethodInvocation node) {
MainMessage extractFromPluralOrGender(MainMessage message, _) {
var visitor = new PluralAndGenderVisitor(
message.messagePieces, message, extraction);
node.accept(visitor);
return message;
}
void setAttribute(MainMessage msg, String fieldName, fieldValue) {
if (msg.attributeNames.contains(fieldName)) {
msg[fieldName] = fieldValue;
}
}
return _messageFromNode(node, extractFromPluralOrGender, setAttribute);
}
}
/// Given an interpolation, find all of its chunks, validate that they are only
/// simple variable substitutions or else Intl.plural/gender calls,
/// and keep track of the pieces of text so that other parts
/// of the program can deal with the simple string sections and the generated
/// parts separately. Note that this is a SimpleAstVisitor, so it only
/// traverses one level of children rather than automatically recursing. If we
/// find a plural or gender, which requires recursion, we do it with a separate
/// special-purpose visitor.
class InterpolationVisitor extends SimpleAstVisitor {
final Message message;
/// The message extraction in which we are running.
final MessageExtraction extraction;
InterpolationVisitor(this.message, this.extraction);
List pieces = [];
String get extractedMessage => pieces.join();
void visitAdjacentStrings(AdjacentStrings node) {
node.visitChildren(this);
super.visitAdjacentStrings(node);
}
void visitStringInterpolation(StringInterpolation node) {
node.visitChildren(this);
super.visitStringInterpolation(node);
}
void visitSimpleStringLiteral(SimpleStringLiteral node) {
pieces.add(node.value);
super.visitSimpleStringLiteral(node);
}
void visitInterpolationString(InterpolationString node) {
pieces.add(node.value);
super.visitInterpolationString(node);
}
void visitInterpolationExpression(InterpolationExpression node) {
if (node.expression is SimpleIdentifier) {
return handleSimpleInterpolation(node);
} else {
return lookForPluralOrGender(node);
}
// Note that we never end up calling super.
}
lookForPluralOrGender(InterpolationExpression node) {
var visitor = new PluralAndGenderVisitor(pieces, message, extraction);
node.accept(visitor);
if (!visitor.foundPluralOrGender) {
throw new IntlMessageExtractionException(
"Only simple identifiers and Intl.plural/gender/select expressions "
"are allowed in message "
"interpolation expressions.\nError at $node");
}
}
void handleSimpleInterpolation(InterpolationExpression node) {
var index = arguments.indexOf(node.expression.toString());
if (index == -1) {
throw new IntlMessageExtractionException(
"Cannot find argument ${node.expression}");
}
pieces.add(index);
}
List get arguments => message.arguments;
}
/// A visitor to extract information from Intl.plural/gender sends. Note that
/// this is a SimpleAstVisitor, so it doesn't automatically recurse. So this
/// needs to be called where we expect a plural or gender immediately below.
class PluralAndGenderVisitor extends SimpleAstVisitor {
/// The message extraction in which we are running.
final MessageExtraction extraction;
/// A plural or gender always exists in the context of a parent message,
/// which could in turn also be a plural or gender.
final ComplexMessage parent;
/// The pieces of the message. We are given an initial version of this
/// from our parent and we add to it as we find additional information.
List pieces;
/// This will be set to true if we find a plural or gender.
bool foundPluralOrGender = false;
PluralAndGenderVisitor(this.pieces, this.parent, this.extraction) : super();
visitInterpolationExpression(InterpolationExpression node) {
// TODO(alanknight): Provide better errors for malformed expressions.
if (!looksLikePluralOrGender(node.expression)) return;
var reason = checkValidity(node.expression);
if (reason != null) throw reason;
var message = messageFromMethodInvocation(node.expression);
foundPluralOrGender = true;
pieces.add(message);
super.visitInterpolationExpression(node);
}
visitMethodInvocation(MethodInvocation node) {
pieces.add(messageFromMethodInvocation(node));
super.visitMethodInvocation(node);
}
/// Return true if [node] matches the pattern for plural or gender message.
bool looksLikePluralOrGender(MethodInvocation node) {
if (!["plural", "gender", "select"].contains(node.methodName.name)) {
return false;
}
if (!(node.target is SimpleIdentifier)) return false;
SimpleIdentifier target = node.target;
return target.token.toString() == "Intl";
}
/// Returns a String describing why the node is invalid, or null if no
/// reason is found, so it's presumed valid.
String checkValidity(MethodInvocation node) {
// TODO(alanknight): Add reasonable validity checks.
return null;
}
/// Create a MainMessage from [node] using the name and
/// parameters of the last function/method declaration we encountered
/// and the parameters to the Intl.message call.
Message messageFromMethodInvocation(MethodInvocation node) {
var message;
switch (node.methodName.name) {
case "gender":
message = new Gender();
break;
case "plural":
message = new Plural();
break;
case "select":
message = new Select();
break;
default:
throw new IntlMessageExtractionException(
"Invalid plural/gender/select message ${node.methodName.name} "
"in $node");
}
message.parent = parent;
var arguments = message.argumentsOfInterestFor(node);
arguments.forEach((key, value) {
try {
var interpolation = new InterpolationVisitor(message, extraction);
value.accept(interpolation);
message[key] = interpolation.pieces;
} on IntlMessageExtractionException catch (e) {
message = null;
var err = new StringBuffer()
..writeAll(["Error ", e, "\nProcessing <", node, ">"])
..write(extraction._reportErrorLocation(node));
var errString = err.toString();
extraction.onMessage(errString);
extraction.warnings.add(errString);
}
});
var mainArg = node.argumentList.arguments
.firstWhere((each) => each is! NamedExpression);
if (mainArg is SimpleStringLiteral) {
message.mainArgument = mainArg.toString();
} else if (mainArg is SimpleIdentifier) {
message.mainArgument = mainArg.name;
} else {
var err = new StringBuffer()
..write("Error (Invalid argument to plural/gender/select, "
"must be simple variable reference) "
"\nProcessing <$node>")
..write(extraction._reportErrorLocation(node));
var errString = err.toString();
extraction.onMessage(errString);
extraction.warnings.add(errString);
}
return message;
}
}
/// Exception thrown when we cannot process a message properly.
class IntlMessageExtractionException implements Exception {
/// A message describing the error.
final String message;
/// Creates a new exception with an optional error [message].
const IntlMessageExtractionException([this.message = ""]);
String toString() => "IntlMessageExtractionException: $message";
}