mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-02 18:11:04 +08:00
This patch adds a localizability check to the presubmit script that reports errors for empty descriptions, after https://crrev.com/c/1688716 adds auto- generated descriptions. Example error messages: third_party/blink/renderer/devtools/front_end/accessibility/accessibility_strings.grdp Line 36: missing description for message with the name "IDS_DEVTOOLS_185551542d4a950d6ed4a90e0875dfde" third_party/blink/renderer/devtools/front_end/animation/animation_strings.grdp Line 10: missing <ex> in <ph> tag with the name "BUTTON_TEXTCONTENT" This patch also improves error reporting by changing full file path to partial path from src/ so that it's more consistent with the presubmit error style. Note: there may be new strings added without a description and/or placeholder example while this patch is on review. In that case I will fix them so the check can be enabled without any problem. Bug: 941561 Change-Id: If609d234b67acb76dfd9c66e2f8880d9875034cc Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1693028 Commit-Queue: Mandy Chen <mandy.chen@microsoft.com> Reviewed-by: Joel Einbinder <einbinder@chromium.org> Cr-Original-Commit-Position: refs/heads/master@{#681975} Cr-Mirrored-From: https://chromium.googlesource.com/chromium/src Cr-Mirrored-Commit: 1404bfcfd225f2963874e37299d890b1f3119e72
293 lines
10 KiB
JavaScript
293 lines
10 KiB
JavaScript
// 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('./localization_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
|
|
filePaths = process.argv.slice(2);
|
|
await Promise.all(filePathPromises);
|
|
|
|
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.parse(fileContent, {loc: true});
|
|
|
|
const relativeFilePath = localizationUtils.getRelativeFilePathFromSrc(filePath);
|
|
for (const node of ast.body)
|
|
analyzeNode(undefined, node, relativeFilePath, errors);
|
|
}
|