blob: 98e695c30515d02ee581199ec7ffcd07a8e392ce [file] [log] [blame]
Mandy Chen465b4f72019-03-21 22:52:54 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4'use strict';
5
6// Description: Scans for localizability violations in the DevTools front-end.
Mandy Chend97200b2019-07-29 21:13:39 +00007// Checks all .grdp files and reports messages without descriptions and placeholder examples.
Mandy Chen465b4f72019-03-21 22:52:54 +00008// Audits all Common.UIString(), UI.formatLocalized(), and ls`` calls and
9// checks for misuses of concatenation and conditionals. It also looks for
10// specific arguments to functions that are expected to be a localized string.
11// Since the check scans for common error patterns, it might misidentify something.
12// In this case, add it to the excluded errors at the top of the script.
13
14const path = require('path');
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000015const localizationUtils = require('./localization_utils/localization_utils');
16const esprimaTypes = localizationUtils.esprimaTypes;
17const escodegen = localizationUtils.escodegen;
18const esprima = localizationUtils.esprima;
Mandy Chen465b4f72019-03-21 22:52:54 +000019
Mandy Chen465b4f72019-03-21 22:52:54 +000020// Exclude known errors
21const excludeErrors = [
22 'Common.UIString(view.title())', 'Common.UIString(setting.title() || \'\')', 'Common.UIString(option.text)',
23 'Common.UIString(experiment.title)', 'Common.UIString(phase.message)',
24 'Common.UIString(Help.latestReleaseNote().header)', 'Common.UIString(conditions.title)',
25 'Common.UIString(extension.title())', 'Common.UIString(this._currentValueLabel, value)'
26];
27
Mandy Chen465b4f72019-03-21 22:52:54 +000028const usage = `Usage: node ${path.basename(process.argv[0])} [-a | <.js file path>*]
29
30-a: If present, check all devtools frontend .js files
31<.js file path>*: List of .js files with absolute paths separated by a space
32`;
33
34async function main() {
35 if (process.argv.length < 3 || process.argv[2] === '--help') {
36 console.log(usage);
37 process.exit(0);
38 }
39
40 const errors = [];
41
42 try {
43 let filePaths = [];
Mandy Chend97200b2019-07-29 21:13:39 +000044 const frontendPath = path.resolve(__dirname, '..', 'front_end');
45 let filePathPromises = [localizationUtils.getFilesFromDirectory(frontendPath, filePaths, ['.grdp'])];
46 if (process.argv[2] === '-a')
47 filePathPromises.push(localizationUtils.getFilesFromDirectory(frontendPath, filePaths, ['.js']));
48 else
Mandy Chen465b4f72019-03-21 22:52:54 +000049 filePaths = process.argv.slice(2);
Mandy Chend97200b2019-07-29 21:13:39 +000050 await Promise.all(filePathPromises);
Mandy Chen465b4f72019-03-21 22:52:54 +000051
Mandy Chen1e9d87b2019-09-18 17:18:15 +000052 filePaths.push(localizationUtils.SHARED_STRINGS_PATH);
Mandy Chend97200b2019-07-29 21:13:39 +000053 const auditFilePromises = filePaths.map(filePath => auditFileForLocalizability(filePath, errors));
54 await Promise.all(auditFilePromises);
Mandy Chen465b4f72019-03-21 22:52:54 +000055 } catch (err) {
56 console.log(err);
57 process.exit(1);
58 }
59
60 if (errors.length > 0) {
61 console.log(`DevTools localization checker detected errors!\n${errors.join('\n')}`);
62 process.exit(1);
63 }
64 console.log('DevTools localization checker passed');
65}
66
67main();
68
Mandy Chen465b4f72019-03-21 22:52:54 +000069function includesConditionalExpression(listOfElements) {
70 return listOfElements.filter(ele => ele !== undefined && ele.type === esprimaTypes.COND_EXPR).length > 0;
71}
72
Mandy Chen465b4f72019-03-21 22:52:54 +000073function addError(error, errors) {
74 if (!errors.includes(error))
75 errors.push(error);
76}
77
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +000078function buildConcatenatedNodesList(node, nodes) {
79 if (!node)
80 return;
81 if (node.left === undefined && node.right === undefined) {
82 nodes.push(node);
83 return;
84 }
85 buildConcatenatedNodesList(node.left, nodes);
86 buildConcatenatedNodesList(node.right, nodes);
87}
88
Mandy Chen465b4f72019-03-21 22:52:54 +000089/**
90 * Recursively check if there is concatenation to localization call.
Mandy Chen31930c42019-06-05 00:47:08 +000091 * Concatenation is allowed between localized strings and strings that
92 * don't contain letters.
93 * Example (allowed): ls`Status code: ${statusCode}`
94 * Example (allowed): ls`Status code` + ': '
95 * Example (disallowed): ls`Status code: ` + statusCode
96 * Example (disallowed): ls`Status ` + 'code'
Mandy Chen465b4f72019-03-21 22:52:54 +000097 */
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +000098function checkConcatenation(parentNode, node, filePath, errors) {
Mandy Chen31930c42019-06-05 00:47:08 +000099 function isConcatenationDisallowed(node) {
100 if (node.type !== esprimaTypes.LITERAL && node.type !== esprimaTypes.TEMP_LITERAL)
101 return true;
102
103 let value;
104 if (node.type === esprimaTypes.LITERAL)
105 value = node.value;
106 else if (node.type === esprimaTypes.TEMP_LITERAL && node.expressions.length === 0)
107 value = node.quasis[0].value.cooked;
108
109 if (!value || typeof value !== 'string')
110 return true;
111
112 return value.match(/[a-z]/i) !== null;
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000113 }
Mandy Chen31930c42019-06-05 00:47:08 +0000114
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000115 function isConcatenation(node) {
116 return (node !== undefined && node.type === esprimaTypes.BI_EXPR && node.operator === '+');
117 }
118
119 if (isConcatenation(parentNode))
120 return;
121
122 if (isConcatenation(node)) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000123 const concatenatedNodes = [];
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000124 buildConcatenatedNodesList(node, concatenatedNodes);
Mandy Chen31930c42019-06-05 00:47:08 +0000125 const nonLocalizationCalls = concatenatedNodes.filter(node => !localizationUtils.isLocalizationCall(node));
126 const hasLocalizationCall = nonLocalizationCalls.length !== concatenatedNodes.length;
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000127 if (hasLocalizationCall) {
Mandy Chen31930c42019-06-05 00:47:08 +0000128 // concatenation with localization call
129 const hasConcatenationViolation = nonLocalizationCalls.some(isConcatenationDisallowed);
130 if (hasConcatenationViolation) {
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000131 const code = escodegen.generate(node);
132 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000133 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000134 localizationUtils.getLocationMessage(
135 node.loc)}: string concatenation should be changed to variable substitution with ls: ${code}`,
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000136 errors);
137 }
Mandy Chen465b4f72019-03-21 22:52:54 +0000138 }
139 }
140}
141
142/**
Mandy Chen465b4f72019-03-21 22:52:54 +0000143 * Check if an argument of a function is localized.
144 */
145function checkFunctionArgument(functionName, argumentIndex, node, filePath, errors) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000146 if (node !== undefined && node.type === esprimaTypes.CALL_EXPR &&
147 localizationUtils.verifyFunctionCallee(node.callee, functionName) && node.arguments !== undefined &&
148 node.arguments.length > argumentIndex) {
Mandy Chen465b4f72019-03-21 22:52:54 +0000149 const arg = node.arguments[argumentIndex];
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000150 // No need to localize empty strings.
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000151 if (arg.type === esprimaTypes.LITERAL && arg.value === '')
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000152 return;
153
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000154 if (!localizationUtils.isLocalizationCall(arg)) {
Mandy Chen465b4f72019-03-21 22:52:54 +0000155 let order = '';
156 switch (argumentIndex) {
157 case 0:
158 order = 'first';
159 break;
160 case 1:
161 order = 'second';
162 break;
163 case 2:
164 order = 'third';
165 break;
166 default:
167 order = `${argumentIndex + 1}th`;
168 }
169 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000170 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
171 localizationUtils.getLocationMessage(
172 node.loc)}: ${order} argument to ${functionName}() should be localized: ${escodegen.generate(node)}`,
Mandy Chen465b4f72019-03-21 22:52:54 +0000173 errors);
174 }
175 }
176}
177
178/**
179 * Check esprima node object that represents the AST of code
180 * to see if there is any localization error.
181 */
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000182function analyzeNode(parentNode, node, filePath, errors) {
Mandy Chen465b4f72019-03-21 22:52:54 +0000183 if (node === undefined || node === null)
184 return;
185
186 if (node instanceof Array) {
187 for (const child of node)
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000188 analyzeNode(node, child, filePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000189
190 return;
191 }
192
193 const keys = Object.keys(node);
194 const objKeys = keys.filter(key => {
195 return typeof node[key] === 'object' && key !== 'loc';
196 });
197 if (objKeys.length === 0) {
198 // base case: all values are non-objects -> node is a leaf
199 return;
200 }
201
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000202 const locCase = localizationUtils.getLocalizationCase(node);
Mandy Chen465b4f72019-03-21 22:52:54 +0000203 const code = escodegen.generate(node);
204 switch (locCase) {
205 case 'Common.UIString':
206 case 'UI.formatLocalized':
207 const firstArgType = node.arguments[0].type;
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000208 if (firstArgType !== esprimaTypes.LITERAL && firstArgType !== esprimaTypes.TEMP_LITERAL &&
209 firstArgType !== esprimaTypes.IDENTIFIER && !excludeErrors.includes(code)) {
210 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000211 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
212 localizationUtils.getLocationMessage(node.loc)}: first argument to call should be a string: ${code}`,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000213 errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000214 }
215 if (includesConditionalExpression(node.arguments.slice(1))) {
216 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000217 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
218 localizationUtils.getLocationMessage(node.loc)}: conditional(s) found in ${
Mandy Chen465b4f72019-03-21 22:52:54 +0000219 code}. Please extract conditional(s) out of the localization call.`,
220 errors);
221 }
222 break;
223 case 'Tagged Template':
224 if (includesConditionalExpression(node.quasi.expressions)) {
225 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000226 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
227 localizationUtils.getLocationMessage(node.loc)}: conditional(s) found in ${
Mandy Chen465b4f72019-03-21 22:52:54 +0000228 code}. Please extract conditional(s) out of the localization call.`,
229 errors);
230 }
231 break;
232 default:
233 // String concatenation to localization call(s) should be changed
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000234 checkConcatenation(parentNode, node, filePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000235 break;
236 }
237
238 for (const key of objKeys) {
239 // recursively parse all the child nodes
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000240 analyzeNode(node, node[key], filePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000241 }
242}
243
Mandy Chend97200b2019-07-29 21:13:39 +0000244function auditGrdpFile(filePath, fileContent, errors) {
245 function reportMissingPlaceholderExample(messageContent, lineNumber) {
246 const phRegex = /<ph[^>]*name="([^"]*)">\$\d(s|d|\.\df)(?!<ex>)<\/ph>/gms;
247 let match;
248 // ph tag that contains $1.2f format placeholder without <ex>
249 // match[0]: full match
250 // match[1]: ph name
251 while ((match = phRegex.exec(messageContent)) !== null) {
252 addError(
253 `${localizationUtils.getRelativeFilePathFromSrc(filePath)} Line ${
254 lineNumber +
255 localizationUtils.lineNumberOfIndex(
256 messageContent, match.index)}: missing <ex> in <ph> tag with the name "${match[1]}"`,
257 errors);
258 }
259 }
260
261 function reportMissingDescriptionAndPlaceholderExample() {
262 const messageRegex = /<message[^>]*name="([^"]*)"[^>]*desc="([^"]*)"[^>]*>\s*\n(.*?)<\/message>/gms;
263 let match;
264 // match[0]: full match
265 // match[1]: message IDS_ key
266 // match[2]: description
267 // match[3]: message content
268 while ((match = messageRegex.exec(fileContent)) !== null) {
269 const lineNumber = localizationUtils.lineNumberOfIndex(fileContent, match.index);
270 if (match[2].trim() === '') {
271 addError(
272 `${localizationUtils.getRelativeFilePathFromSrc(filePath)} Line ${
273 lineNumber}: missing description for message with the name "${match[1]}"`,
274 errors);
275 }
276 reportMissingPlaceholderExample(match[3], lineNumber);
277 }
278 }
279
280 reportMissingDescriptionAndPlaceholderExample();
281}
282
Krishna Govind897c8672019-05-23 20:33:45 +0000283async function auditFileForLocalizability(filePath, errors) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000284 const fileContent = await localizationUtils.parseFileContent(filePath);
Mandy Chend97200b2019-07-29 21:13:39 +0000285 if (path.extname(filePath) === '.grdp')
286 return auditGrdpFile(filePath, fileContent, errors);
287
Tim van der Lippe63a4d3d2019-09-18 15:31:38 +0000288 const ast = esprima.parseModule(fileContent, {loc: true});
Krishna Govind897c8672019-05-23 20:33:45 +0000289
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000290 const relativeFilePath = localizationUtils.getRelativeFilePathFromSrc(filePath);
Mandy Chen465b4f72019-03-21 22:52:54 +0000291 for (const node of ast.body)
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000292 analyzeNode(undefined, node, relativeFilePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000293}