blob: be7fe4c17100e1ff1c8c62348d57787d0e875e5b [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.
7// Audits all Common.UIString(), UI.formatLocalized(), and ls`` calls and
8// checks for misuses of concatenation and conditionals. It also looks for
9// specific arguments to functions that are expected to be a localized string.
10// Since the check scans for common error patterns, it might misidentify something.
11// In this case, add it to the excluded errors at the top of the script.
12
13const path = require('path');
14
15// Use modules in third_party/node/node_modules
16const THIRD_PARTY_PATH = path.resolve(__dirname, '..', '..', '..', '..');
17const REPO_NODE_MODULES_PATH = path.resolve(THIRD_PARTY_PATH, 'node', 'node_modules');
18const escodegen = require(path.resolve(REPO_NODE_MODULES_PATH, 'escodegen'));
19const esprima = require(path.resolve(REPO_NODE_MODULES_PATH, 'esprima'));
20
21const fs = require('fs');
22const {promisify} = require('util');
23const readDirAsync = promisify(fs.readdir);
24const readFileAsync = promisify(fs.readFile);
25const statAsync = promisify(fs.stat);
26
27const excludeFiles = ['lighthouse-dt-bundle.js', 'Tests.js'];
28const excludeDirs = ['_test_runner', 'Images', 'node_modules'];
29// Exclude known errors
30const excludeErrors = [
31 'Common.UIString(view.title())', 'Common.UIString(setting.title() || \'\')', 'Common.UIString(option.text)',
32 'Common.UIString(experiment.title)', 'Common.UIString(phase.message)',
33 'Common.UIString(Help.latestReleaseNote().header)', 'Common.UIString(conditions.title)',
34 'Common.UIString(extension.title())', 'Common.UIString(this._currentValueLabel, value)'
35];
36
37const esprimaTypes = {
38 BI_EXPR: 'BinaryExpression',
39 CALL_EXPR: 'CallExpression',
40 COND_EXPR: 'ConditionalExpression',
41 IDENTIFIER: 'Identifier',
42 MEMBER_EXPR: 'MemberExpression',
43 TAGGED_TEMP_EXPR: 'TaggedTemplateExpression',
44 TEMP_LITERAL: 'TemplateLiteral'
45};
46
47const usage = `Usage: node ${path.basename(process.argv[0])} [-a | <.js file path>*]
48
49-a: If present, check all devtools frontend .js files
50<.js file path>*: List of .js files with absolute paths separated by a space
51`;
52
53async function main() {
54 if (process.argv.length < 3 || process.argv[2] === '--help') {
55 console.log(usage);
56 process.exit(0);
57 }
58
59 const errors = [];
60
61 try {
62 let filePaths = [];
63 if (process.argv[2] === '-a') {
64 const frontendPath = path.resolve(__dirname, '..', 'front_end');
65 await getFilesFromDirectory(frontendPath, filePaths);
66 } else {
67 filePaths = process.argv.slice(2);
68 }
69
70 const promises = [];
71 for (const filePath of filePaths)
72 promises.push(auditFileForLocalizability(filePath, errors));
73
74 await Promise.all(promises);
75 } catch (err) {
76 console.log(err);
77 process.exit(1);
78 }
79
80 if (errors.length > 0) {
81 console.log(`DevTools localization checker detected errors!\n${errors.join('\n')}`);
82 process.exit(1);
83 }
84 console.log('DevTools localization checker passed');
85}
86
87main();
88
89function verifyIdentifier(node, name) {
90 return node !== undefined && node.type === esprimaTypes.IDENTIFIER && node.name === name;
91}
92
93/**
94 * Verify callee of objectName.propertyName(), e.g. Common.UIString().
95 */
96function verifyCallExpressionCallee(callee, objectName, propertyName) {
97 return callee !== undefined && callee.type === esprimaTypes.MEMBER_EXPR && callee.computed === false &&
98 verifyIdentifier(callee.object, objectName) && verifyIdentifier(callee.property, propertyName);
99}
100
101function isNodeCallOnObject(node, objectName, propertyName) {
102 return node !== undefined && node.type === esprimaTypes.CALL_EXPR &&
103 verifyCallExpressionCallee(node.callee, objectName, propertyName);
104}
105
106function isNodeCommonUIStringCall(node) {
107 return isNodeCallOnObject(node, 'Common', 'UIString');
108}
109
110function isNodeUIformatLocalized(node) {
111 return isNodeCallOnObject(node, 'UI', 'formatLocalized');
112}
113
114function isNodelsTaggedTemplateExpression(node) {
115 return node !== undefined && node.type === esprimaTypes.TAGGED_TEMP_EXPR && verifyIdentifier(node.tag, 'ls') &&
116 node.quasi !== undefined && node.quasi.type !== undefined && node.quasi.type === esprimaTypes.TEMP_LITERAL;
117}
118
119function includesConditionalExpression(listOfElements) {
120 return listOfElements.filter(ele => ele !== undefined && ele.type === esprimaTypes.COND_EXPR).length > 0;
121}
122
123function getLocalizationCase(node) {
124 if (isNodeCommonUIStringCall(node))
125 return 'Common.UIString';
126 else if (isNodelsTaggedTemplateExpression(node))
127 return 'Tagged Template';
128 else if (isNodeUIformatLocalized(node))
129 return 'UI.formatLocalized';
130 else
131 return null;
132}
133
134function isLocalizationCall(node) {
135 return isNodeCommonUIStringCall(node) || isNodelsTaggedTemplateExpression(node) || isNodeUIformatLocalized(node);
136}
137
138function addError(error, errors) {
139 if (!errors.includes(error))
140 errors.push(error);
141}
142
143function getLocation(node) {
144 if (node !== undefined && node.loc !== undefined && node.loc.start !== undefined && node.loc.end !== undefined &&
145 node.loc.start.line !== undefined && node.loc.end.line !== undefined) {
146 const startLine = node.loc.start.line;
147 const endLine = node.loc.end.line;
148 if (startLine === endLine)
149 return ` Line ${startLine}`;
150 else
151 return ` Line ${node.loc.start.line}-${node.loc.end.line}`;
152 }
153 return '';
154}
155
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000156function buildConcatenatedNodesList(node, nodes) {
157 if (!node)
158 return;
159 if (node.left === undefined && node.right === undefined) {
160 nodes.push(node);
161 return;
162 }
163 buildConcatenatedNodesList(node.left, nodes);
164 buildConcatenatedNodesList(node.right, nodes);
165}
166
Mandy Chen465b4f72019-03-21 22:52:54 +0000167/**
168 * Recursively check if there is concatenation to localization call.
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000169 * Concatenation is allowed between localized strings and non-alphabetic strings.
170 * It is not allowed between a localized string and a word.
171 * Example (allowed): ls`Status Code` + ": "
172 * Example (disallowed): ls`Status` + " Code" + ": "
Mandy Chen465b4f72019-03-21 22:52:54 +0000173 */
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000174function checkConcatenation(parentNode, node, filePath, errors) {
175 function isWord(node) {
176 return (node.type === 'Literal' && !!node.value.match(/[a-z]/i));
177 }
178 function isConcatenation(node) {
179 return (node !== undefined && node.type === esprimaTypes.BI_EXPR && node.operator === '+');
180 }
181
182 if (isConcatenation(parentNode))
183 return;
184
185 if (isConcatenation(node)) {
186 let concatenatedNodes = [];
187 buildConcatenatedNodesList(node, concatenatedNodes);
188 const hasLocalizationCall = !!concatenatedNodes.find(currentNode => isLocalizationCall(currentNode));
189 if (hasLocalizationCall) {
190 const hasAlphabeticLiteral = !!concatenatedNodes.find(currentNode => isWord(currentNode));
191 if (hasAlphabeticLiteral) {
192 const code = escodegen.generate(node);
193 addError(
194 `${filePath}${
195 getLocation(node)}: string concatenation should be changed to variable substitution with ls: ${code}`,
196 errors);
197 }
Mandy Chen465b4f72019-03-21 22:52:54 +0000198 }
199 }
200}
201
202/**
203 * Verify if callee is functionName() or object.functionName().
204 */
205function verifyFunctionCallee(callee, functionName) {
206 return callee !== undefined &&
207 ((callee.type === esprimaTypes.IDENTIFIER && callee.name === functionName) ||
208 (callee.type === esprimaTypes.MEMBER_EXPR && verifyIdentifier(callee.property, functionName)));
209}
210
211/**
212 * Check if an argument of a function is localized.
213 */
214function checkFunctionArgument(functionName, argumentIndex, node, filePath, errors) {
215 if (node !== undefined && node.type === esprimaTypes.CALL_EXPR && verifyFunctionCallee(node.callee, functionName) &&
216 node.arguments !== undefined && node.arguments.length > argumentIndex) {
217 const arg = node.arguments[argumentIndex];
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000218 // No need to localize empty strings.
219 if (arg.type == 'Literal' && arg.value === '')
220 return;
221
Mandy Chen465b4f72019-03-21 22:52:54 +0000222 if (!isLocalizationCall(arg)) {
223 let order = '';
224 switch (argumentIndex) {
225 case 0:
226 order = 'first';
227 break;
228 case 1:
229 order = 'second';
230 break;
231 case 2:
232 order = 'third';
233 break;
234 default:
235 order = `${argumentIndex + 1}th`;
236 }
237 addError(
238 `${filePath}${getLocation(node)}: ${order} argument to ${functionName}() should be localized: ${
239 escodegen.generate(node)}`,
240 errors);
241 }
242 }
243}
244
245/**
246 * Check esprima node object that represents the AST of code
247 * to see if there is any localization error.
248 */
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000249function analyzeNode(parentNode, node, filePath, errors) {
Mandy Chen465b4f72019-03-21 22:52:54 +0000250 if (node === undefined || node === null)
251 return;
252
253 if (node instanceof Array) {
254 for (const child of node)
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000255 analyzeNode(node, child, filePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000256
257 return;
258 }
259
260 const keys = Object.keys(node);
261 const objKeys = keys.filter(key => {
262 return typeof node[key] === 'object' && key !== 'loc';
263 });
264 if (objKeys.length === 0) {
265 // base case: all values are non-objects -> node is a leaf
266 return;
267 }
268
269 const locCase = getLocalizationCase(node);
270 const code = escodegen.generate(node);
271 switch (locCase) {
272 case 'Common.UIString':
273 case 'UI.formatLocalized':
274 const firstArgType = node.arguments[0].type;
275 if (firstArgType !== 'Literal' && firstArgType !== 'TemplateLiteral' && firstArgType !== 'Identifier' &&
276 !excludeErrors.includes(code)) {
277 addError(`${filePath}${getLocation(node)}: first argument to call should be a string: ${code}`, errors);
278 }
279 if (includesConditionalExpression(node.arguments.slice(1))) {
280 addError(
281 `${filePath}${getLocation(node)}: conditional(s) found in ${
282 code}. Please extract conditional(s) out of the localization call.`,
283 errors);
284 }
285 break;
286 case 'Tagged Template':
287 if (includesConditionalExpression(node.quasi.expressions)) {
288 addError(
289 `${filePath}${getLocation(node)}: conditional(s) found in ${
290 code}. Please extract conditional(s) out of the localization call.`,
291 errors);
292 }
293 break;
294 default:
295 // String concatenation to localization call(s) should be changed
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000296 checkConcatenation(parentNode, node, filePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000297 // 3rd argument to createInput() should be localized
298 checkFunctionArgument('createInput', 2, node, filePath, errors);
299 break;
300 }
301
302 for (const key of objKeys) {
303 // recursively parse all the child nodes
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000304 analyzeNode(node, node[key], filePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000305 }
306}
307
308function getRelativeFilePathFromSrc(fullFilePath) {
309 return path.relative(path.resolve(THIRD_PARTY_PATH, '..'), fullFilePath);
310}
311
312async function auditFileForLocalizability(filePath, errors) {
313 const fileContent = await readFileAsync(filePath);
314 const ast = esprima.parse(fileContent.toString(), {loc: true});
315
316 const relativeFilePath = getRelativeFilePathFromSrc(filePath);
317 for (const node of ast.body)
Lorne Mitchell7aa2c6c2019-04-03 03:50:10 +0000318 analyzeNode(undefined, node, relativeFilePath, errors);
Mandy Chen465b4f72019-03-21 22:52:54 +0000319}
320
321function shouldParseDirectory(directoryName) {
322 return !excludeDirs.reduce((result, dir) => result || directoryName.indexOf(dir) !== -1, false);
323}
324
325function shouldParseFile(filePath) {
326 return (path.extname(filePath) === '.js' && !excludeFiles.includes(path.basename(filePath)));
327}
328
329async function getFilesFromItem(itemPath, filePaths) {
330 const stat = await statAsync(itemPath);
331 if (stat.isDirectory() && shouldParseDirectory(itemPath))
332 return await getFilesFromDirectory(itemPath, filePaths);
333
334 if (shouldParseFile(itemPath))
335 filePaths.push(itemPath);
336}
337
338async function getFilesFromDirectory(directoryPath, filePaths) {
339 const itemNames = await readDirAsync(directoryPath);
340 const promises = [];
341 for (const itemName of itemNames) {
342 const itemPath = path.resolve(directoryPath, itemName);
343 promises.push(getFilesFromItem(itemPath, filePaths));
344 }
345 await Promise.all(promises);
346}