| // Copyright 2019 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| 'use strict'; |
| |
| // Description: Scans for localizability violations in the DevTools front-end. |
| // Checks all .grdp files and reports messages without descriptions and placeholder examples. |
| // Audits all Common.UIString(), UI.formatLocalized(), and ls`` calls and |
| // checks for misuses of concatenation and conditionals. It also looks for |
| // specific arguments to functions that are expected to be a localized string. |
| // Since the check scans for common error patterns, it might misidentify something. |
| // In this case, add it to the excluded errors at the top of the script. |
| |
| const path = require('path'); |
| const localizationUtils = require('./utils/localization_utils'); |
| const esprimaTypes = localizationUtils.esprimaTypes; |
| const escodegen = localizationUtils.escodegen; |
| const esprima = localizationUtils.esprima; |
| |
| // Exclude known errors |
| const excludeErrors = [ |
| 'Common.UIString(view.title())', 'Common.UIString(setting.title() || \'\')', 'Common.UIString(option.text)', |
| 'Common.UIString(experiment.title)', 'Common.UIString(phase.message)', |
| 'Common.UIString(Help.latestReleaseNote().header)', 'Common.UIString(conditions.title)', |
| 'Common.UIString(extension.title())', 'Common.UIString(this._currentValueLabel, value)' |
| ]; |
| |
| const usage = `Usage: node ${path.basename(process.argv[0])} [-a | <.js file path>*] |
| |
| -a: If present, check all devtools frontend .js files |
| <.js file path>*: List of .js files with absolute paths separated by a space |
| `; |
| |
| async function main() { |
| if (process.argv.length < 3 || process.argv[2] === '--help') { |
| console.log(usage); |
| process.exit(0); |
| } |
| |
| const errors = []; |
| |
| try { |
| let filePaths = []; |
| const frontendPath = path.resolve(__dirname, '..', '..', 'front_end'); |
| let filePathPromises = [localizationUtils.getFilesFromDirectory(frontendPath, filePaths, ['.grdp'])]; |
| if (process.argv[2] === '-a') { |
| filePathPromises.push(localizationUtils.getFilesFromDirectory(frontendPath, filePaths, ['.js'])); |
| } else { |
| // esprima has a bug parsing a valid JSON format, so exclude them. |
| filePaths = process.argv.slice(2).filter( file => { |
| return (path.extname(file) !== '.json') && localizationUtils.shouldParseDirectory(file); |
| }); |
| } |
| await Promise.all(filePathPromises); |
| |
| filePaths.push(localizationUtils.SHARED_STRINGS_PATH); |
| const auditFilePromises = filePaths.map(filePath => auditFileForLocalizability(filePath, errors)); |
| await Promise.all(auditFilePromises); |
| } catch (err) { |
| console.log(err); |
| process.exit(1); |
| } |
| |
| if (errors.length > 0) { |
| console.log(`DevTools localization checker detected errors!\n${errors.join('\n')}`); |
| process.exit(1); |
| } |
| console.log('DevTools localization checker passed'); |
| } |
| |
| main(); |
| |
| function includesConditionalExpression(listOfElements) { |
| return listOfElements.filter(ele => ele !== undefined && ele.type === esprimaTypes.COND_EXPR).length > 0; |
| } |
| |
| function addError(error, errors) { |
| if (!errors.includes(error)) |
| errors.push(error); |
| } |
| |
| function buildConcatenatedNodesList(node, nodes) { |
| if (!node) |
| return; |
| if (node.left === undefined && node.right === undefined) { |
| nodes.push(node); |
| return; |
| } |
| buildConcatenatedNodesList(node.left, nodes); |
| buildConcatenatedNodesList(node.right, nodes); |
| } |
| |
| /** |
| * Recursively check if there is concatenation to localization call. |
| * Concatenation is allowed between localized strings and strings that |
| * don't contain letters. |
| * Example (allowed): ls`Status code: ${statusCode}` |
| * Example (allowed): ls`Status code` + ': ' |
| * Example (disallowed): ls`Status code: ` + statusCode |
| * Example (disallowed): ls`Status ` + 'code' |
| */ |
| function checkConcatenation(parentNode, node, filePath, errors) { |
| function isConcatenationDisallowed(node) { |
| if (node.type !== esprimaTypes.LITERAL && node.type !== esprimaTypes.TEMP_LITERAL) |
| return true; |
| |
| let value; |
| if (node.type === esprimaTypes.LITERAL) |
| value = node.value; |
| else if (node.type === esprimaTypes.TEMP_LITERAL && node.expressions.length === 0) |
| value = node.quasis[0].value.cooked; |
| |
| if (!value || typeof value !== 'string') |
| return true; |
| |
| return value.match(/[a-z]/i) !== null; |
| } |
| |
| function isConcatenation(node) { |
| return (node !== undefined && node.type === esprimaTypes.BI_EXPR && node.operator === '+'); |
| } |
| |
| if (isConcatenation(parentNode)) |
| return; |
| |
| if (isConcatenation(node)) { |
| const concatenatedNodes = []; |
| buildConcatenatedNodesList(node, concatenatedNodes); |
| const nonLocalizationCalls = concatenatedNodes.filter(node => !localizationUtils.isLocalizationCall(node)); |
| const hasLocalizationCall = nonLocalizationCalls.length !== concatenatedNodes.length; |
| if (hasLocalizationCall) { |
| // concatenation with localization call |
| const hasConcatenationViolation = nonLocalizationCalls.some(isConcatenationDisallowed); |
| if (hasConcatenationViolation) { |
| const code = escodegen.generate(node); |
| addError( |
| `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${ |
| localizationUtils.getLocationMessage( |
| node.loc)}: string concatenation should be changed to variable substitution with ls: ${code}`, |
| errors); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Check if an argument of a function is localized. |
| */ |
| function checkFunctionArgument(functionName, argumentIndex, node, filePath, errors) { |
| if (node !== undefined && node.type === esprimaTypes.CALL_EXPR && |
| localizationUtils.verifyFunctionCallee(node.callee, functionName) && node.arguments !== undefined && |
| node.arguments.length > argumentIndex) { |
| const arg = node.arguments[argumentIndex]; |
| // No need to localize empty strings. |
| if (arg.type === esprimaTypes.LITERAL && arg.value === '') |
| return; |
| |
| if (!localizationUtils.isLocalizationCall(arg)) { |
| let order = ''; |
| switch (argumentIndex) { |
| case 0: |
| order = 'first'; |
| break; |
| case 1: |
| order = 'second'; |
| break; |
| case 2: |
| order = 'third'; |
| break; |
| default: |
| order = `${argumentIndex + 1}th`; |
| } |
| addError( |
| `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${ |
| localizationUtils.getLocationMessage( |
| node.loc)}: ${order} argument to ${functionName}() should be localized: ${escodegen.generate(node)}`, |
| errors); |
| } |
| } |
| } |
| |
| /** |
| * Check esprima node object that represents the AST of code |
| * to see if there is any localization error. |
| */ |
| function analyzeNode(parentNode, node, filePath, errors) { |
| if (node === undefined || node === null) |
| return; |
| |
| if (node instanceof Array) { |
| for (const child of node) |
| analyzeNode(node, child, filePath, errors); |
| |
| return; |
| } |
| |
| const keys = Object.keys(node); |
| const objKeys = keys.filter(key => { |
| return typeof node[key] === 'object' && key !== 'loc'; |
| }); |
| if (objKeys.length === 0) { |
| // base case: all values are non-objects -> node is a leaf |
| return; |
| } |
| |
| const locCase = localizationUtils.getLocalizationCase(node); |
| const code = escodegen.generate(node); |
| switch (locCase) { |
| case 'Common.UIString': |
| case 'UI.formatLocalized': |
| const firstArgType = node.arguments[0].type; |
| if (firstArgType !== esprimaTypes.LITERAL && firstArgType !== esprimaTypes.TEMP_LITERAL && |
| firstArgType !== esprimaTypes.IDENTIFIER && !excludeErrors.includes(code)) { |
| addError( |
| `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${ |
| localizationUtils.getLocationMessage(node.loc)}: first argument to call should be a string: ${code}`, |
| errors); |
| } |
| if (includesConditionalExpression(node.arguments.slice(1))) { |
| addError( |
| `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${ |
| localizationUtils.getLocationMessage(node.loc)}: conditional(s) found in ${ |
| code}. Please extract conditional(s) out of the localization call.`, |
| errors); |
| } |
| break; |
| case 'Tagged Template': |
| if (includesConditionalExpression(node.quasi.expressions)) { |
| addError( |
| `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${ |
| localizationUtils.getLocationMessage(node.loc)}: conditional(s) found in ${ |
| code}. Please extract conditional(s) out of the localization call.`, |
| errors); |
| } |
| break; |
| default: |
| // String concatenation to localization call(s) should be changed |
| checkConcatenation(parentNode, node, filePath, errors); |
| break; |
| } |
| |
| for (const key of objKeys) { |
| // recursively parse all the child nodes |
| analyzeNode(node, node[key], filePath, errors); |
| } |
| } |
| |
| function auditGrdpFile(filePath, fileContent, errors) { |
| function reportMissingPlaceholderExample(messageContent, lineNumber) { |
| const phRegex = /<ph[^>]*name="([^"]*)">\$\d(s|d|\.\df)(?!<ex>)<\/ph>/gms; |
| let match; |
| // ph tag that contains $1.2f format placeholder without <ex> |
| // match[0]: full match |
| // match[1]: ph name |
| while ((match = phRegex.exec(messageContent)) !== null) { |
| addError( |
| `${localizationUtils.getRelativeFilePathFromSrc(filePath)} Line ${ |
| lineNumber + |
| localizationUtils.lineNumberOfIndex( |
| messageContent, match.index)}: missing <ex> in <ph> tag with the name "${match[1]}"`, |
| errors); |
| } |
| } |
| |
| function reportMissingDescriptionAndPlaceholderExample() { |
| const messageRegex = /<message[^>]*name="([^"]*)"[^>]*desc="([^"]*)"[^>]*>\s*\n(.*?)<\/message>/gms; |
| let match; |
| // match[0]: full match |
| // match[1]: message IDS_ key |
| // match[2]: description |
| // match[3]: message content |
| while ((match = messageRegex.exec(fileContent)) !== null) { |
| const lineNumber = localizationUtils.lineNumberOfIndex(fileContent, match.index); |
| if (match[2].trim() === '') { |
| addError( |
| `${localizationUtils.getRelativeFilePathFromSrc(filePath)} Line ${ |
| lineNumber}: missing description for message with the name "${match[1]}"`, |
| errors); |
| } |
| reportMissingPlaceholderExample(match[3], lineNumber); |
| } |
| } |
| |
| reportMissingDescriptionAndPlaceholderExample(); |
| } |
| |
| async function auditFileForLocalizability(filePath, errors) { |
| const fileContent = await localizationUtils.parseFileContent(filePath); |
| if (path.extname(filePath) === '.grdp') |
| return auditGrdpFile(filePath, fileContent, errors); |
| |
| const ast = esprima.parseModule(fileContent, {loc: true}); |
| |
| const relativeFilePath = localizationUtils.getRelativeFilePathFromSrc(filePath); |
| for (const node of ast.body) |
| analyzeNode(undefined, node, relativeFilePath, errors); |
| } |