blob: 000a2947474d355a4f9996100cfaba373d3fe617 [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 Chend97200b2019-07-29 21:13:39 +000052 const auditFilePromises = filePaths.map(filePath => auditFileForLocalizability(filePath, errors));
53 await Promise.all(auditFilePromises);
Mandy Chen465b4f72019-03-21 22:52:54 +000054 } catch (err) {
55 console.log(err);
56 process.exit(1);
57 }
58
59 if (errors.length > 0) {
60 console.log(`DevTools localization checker detected errors!\n${errors.join('\n')}`);
61 process.exit(1);
62 }
63 console.log('DevTools localization checker passed');
64}
65
66main();
67
Mandy Chen465b4f72019-03-21 22:52:54 +000068function includesConditionalExpression(listOfElements) {
69 return listOfElements.filter(ele => ele !== undefined && ele.type === esprimaTypes.COND_EXPR).length > 0;
70}
71
Mandy Chen465b4f72019-03-21 22:52:54 +000072function addError(error, errors) {
73 if (!errors.includes(error))
74 errors.push(error);
75}
76
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +000077function buildConcatenatedNodesList(node, nodes) {
78 if (!node)
79 return;
80 if (node.left === undefined && node.right === undefined) {
81 nodes.push(node);
82 return;
83 }
84 buildConcatenatedNodesList(node.left, nodes);
85 buildConcatenatedNodesList(node.right, nodes);
86}
87
Mandy Chen465b4f72019-03-21 22:52:54 +000088/**
89 * Recursively check if there is concatenation to localization call.
Mandy Chen31930c42019-06-05 00:47:08 +000090 * Concatenation is allowed between localized strings and strings that
91 * don't contain letters.
92 * Example (allowed): ls`Status code: ${statusCode}`
93 * Example (allowed): ls`Status code` + ': '
94 * Example (disallowed): ls`Status code: ` + statusCode
95 * Example (disallowed): ls`Status ` + 'code'
Mandy Chen465b4f72019-03-21 22:52:54 +000096 */
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +000097function checkConcatenation(parentNode, node, filePath, errors) {
Mandy Chen31930c42019-06-05 00:47:08 +000098 function isConcatenationDisallowed(node) {
99 if (node.type !== esprimaTypes.LITERAL && node.type !== esprimaTypes.TEMP_LITERAL)
100 return true;
101
102 let value;
103 if (node.type === esprimaTypes.LITERAL)
104 value = node.value;
105 else if (node.type === esprimaTypes.TEMP_LITERAL && node.expressions.length === 0)
106 value = node.quasis[0].value.cooked;
107
108 if (!value || typeof value !== 'string')
109 return true;
110
111 return value.match(/[a-z]/i) !== null;
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000112 }
Mandy Chen31930c42019-06-05 00:47:08 +0000113
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000114 function isConcatenation(node) {
115 return (node !== undefined && node.type === esprimaTypes.BI_EXPR && node.operator === '+');
116 }
117
118 if (isConcatenation(parentNode))
119 return;
120
121 if (isConcatenation(node)) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000122 const concatenatedNodes = [];
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000123 buildConcatenatedNodesList(node, concatenatedNodes);
Mandy Chen31930c42019-06-05 00:47:08 +0000124 const nonLocalizationCalls = concatenatedNodes.filter(node => !localizationUtils.isLocalizationCall(node));
125 const hasLocalizationCall = nonLocalizationCalls.length !== concatenatedNodes.length;
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000126 if (hasLocalizationCall) {
Mandy Chen31930c42019-06-05 00:47:08 +0000127 // concatenation with localization call
128 const hasConcatenationViolation = nonLocalizationCalls.some(isConcatenationDisallowed);
129 if (hasConcatenationViolation) {
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000130 const code = escodegen.generate(node);
131 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000132 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000133 localizationUtils.getLocationMessage(
134 node.loc)}: string concatenation should be changed to variable substitution with ls: ${code}`,
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000135 errors);
136 }
Mandy Chen465b4f72019-03-21 22:52:54 +0000137 }
138 }
139}
140
141/**
Mandy Chen465b4f72019-03-21 22:52:54 +0000142 * Check if an argument of a function is localized.
143 */
144function checkFunctionArgument(functionName, argumentIndex, node, filePath, errors) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000145 if (node !== undefined && node.type === esprimaTypes.CALL_EXPR &&
146 localizationUtils.verifyFunctionCallee(node.callee, functionName) && node.arguments !== undefined &&
147 node.arguments.length > argumentIndex) {
Mandy Chen465b4f72019-03-21 22:52:54 +0000148 const arg = node.arguments[argumentIndex];
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000149 // No need to localize empty strings.
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000150 if (arg.type === esprimaTypes.LITERAL && arg.value === '')
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000151 return;
152
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000153 if (!localizationUtils.isLocalizationCall(arg)) {
Mandy Chen465b4f72019-03-21 22:52:54 +0000154 let order = '';
155 switch (argumentIndex) {
156 case 0:
157 order = 'first';
158 break;
159 case 1:
160 order = 'second';
161 break;
162 case 2:
163 order = 'third';
164 break;
165 default:
166 order = `${argumentIndex + 1}th`;
167 }
168 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000169 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
170 localizationUtils.getLocationMessage(
171 node.loc)}: ${order} argument to ${functionName}() should be localized: ${escodegen.generate(node)}`,
Mandy Chen465b4f72019-03-21 22:52:54 +0000172 errors);
173 }
174 }
175}
176
177/**
178 * Check esprima node object that represents the AST of code
179 * to see if there is any localization error.
180 */
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000181function analyzeNode(parentNode, node, filePath, errors) {
Mandy Chen465b4f72019-03-21 22:52:54 +0000182 if (node === undefined || node === null)
183 return;
184
185 if (node instanceof Array) {
186 for (const child of node)
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000187 analyzeNode(node, child, filePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000188
189 return;
190 }
191
192 const keys = Object.keys(node);
193 const objKeys = keys.filter(key => {
194 return typeof node[key] === 'object' && key !== 'loc';
195 });
196 if (objKeys.length === 0) {
197 // base case: all values are non-objects -> node is a leaf
198 return;
199 }
200
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000201 const locCase = localizationUtils.getLocalizationCase(node);
Mandy Chen465b4f72019-03-21 22:52:54 +0000202 const code = escodegen.generate(node);
203 switch (locCase) {
204 case 'Common.UIString':
205 case 'UI.formatLocalized':
206 const firstArgType = node.arguments[0].type;
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000207 if (firstArgType !== esprimaTypes.LITERAL && firstArgType !== esprimaTypes.TEMP_LITERAL &&
208 firstArgType !== esprimaTypes.IDENTIFIER && !excludeErrors.includes(code)) {
209 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000210 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
211 localizationUtils.getLocationMessage(node.loc)}: first argument to call should be a string: ${code}`,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000212 errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000213 }
214 if (includesConditionalExpression(node.arguments.slice(1))) {
215 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000216 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
217 localizationUtils.getLocationMessage(node.loc)}: conditional(s) found in ${
Mandy Chen465b4f72019-03-21 22:52:54 +0000218 code}. Please extract conditional(s) out of the localization call.`,
219 errors);
220 }
221 break;
222 case 'Tagged Template':
223 if (includesConditionalExpression(node.quasi.expressions)) {
224 addError(
Mandy Chend97200b2019-07-29 21:13:39 +0000225 `${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
226 localizationUtils.getLocationMessage(node.loc)}: conditional(s) found in ${
Mandy Chen465b4f72019-03-21 22:52:54 +0000227 code}. Please extract conditional(s) out of the localization call.`,
228 errors);
229 }
230 break;
231 default:
232 // String concatenation to localization call(s) should be changed
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000233 checkConcatenation(parentNode, node, filePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000234 break;
235 }
236
237 for (const key of objKeys) {
238 // recursively parse all the child nodes
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000239 analyzeNode(node, node[key], filePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000240 }
241}
242
Mandy Chend97200b2019-07-29 21:13:39 +0000243function auditGrdpFile(filePath, fileContent, errors) {
244 function reportMissingPlaceholderExample(messageContent, lineNumber) {
245 const phRegex = /<ph[^>]*name="([^"]*)">\$\d(s|d|\.\df)(?!<ex>)<\/ph>/gms;
246 let match;
247 // ph tag that contains $1.2f format placeholder without <ex>
248 // match[0]: full match
249 // match[1]: ph name
250 while ((match = phRegex.exec(messageContent)) !== null) {
251 addError(
252 `${localizationUtils.getRelativeFilePathFromSrc(filePath)} Line ${
253 lineNumber +
254 localizationUtils.lineNumberOfIndex(
255 messageContent, match.index)}: missing <ex> in <ph> tag with the name "${match[1]}"`,
256 errors);
257 }
258 }
259
260 function reportMissingDescriptionAndPlaceholderExample() {
261 const messageRegex = /<message[^>]*name="([^"]*)"[^>]*desc="([^"]*)"[^>]*>\s*\n(.*?)<\/message>/gms;
262 let match;
263 // match[0]: full match
264 // match[1]: message IDS_ key
265 // match[2]: description
266 // match[3]: message content
267 while ((match = messageRegex.exec(fileContent)) !== null) {
268 const lineNumber = localizationUtils.lineNumberOfIndex(fileContent, match.index);
269 if (match[2].trim() === '') {
270 addError(
271 `${localizationUtils.getRelativeFilePathFromSrc(filePath)} Line ${
272 lineNumber}: missing description for message with the name "${match[1]}"`,
273 errors);
274 }
275 reportMissingPlaceholderExample(match[3], lineNumber);
276 }
277 }
278
279 reportMissingDescriptionAndPlaceholderExample();
280}
281
Krishna Govind897c8672019-05-23 20:33:45 +0000282async function auditFileForLocalizability(filePath, errors) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000283 const fileContent = await localizationUtils.parseFileContent(filePath);
Mandy Chend97200b2019-07-29 21:13:39 +0000284 if (path.extname(filePath) === '.grdp')
285 return auditGrdpFile(filePath, fileContent, errors);
286
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000287 const ast = esprima.parse(fileContent, {loc: true});
Krishna Govind897c8672019-05-23 20:33:45 +0000288
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000289 const relativeFilePath = localizationUtils.getRelativeFilePathFromSrc(filePath);
Mandy Chen465b4f72019-03-21 22:52:54 +0000290 for (const node of ast.body)
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000291 analyzeNode(undefined, node, relativeFilePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000292}