| // 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; |
| |
| /// Are examples required on all messages. |
| bool examplesRequired = false; |
| |
| /// 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, |
| examplesRequired: extraction.examplesRequired); |
| } |
| |
| /// 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"; |
| } |