blob: 3cbe973bfdfd0cec17154f962ca618c318cf37c3 [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
156/**
157 * Recursively check if there is concatenation to localization call.
158 */
159function checkConcatenation(node, filePath, errors) {
160 if (node !== undefined && node.type === esprimaTypes.BI_EXPR && node.operator === '+') {
161 const code = escodegen.generate(node);
162 if (isLocalizationCall(node.left) || isLocalizationCall(node.right)) {
163 addError(
164 `${filePath}${getLocation(node)}: string concatenation should be changed to variable substitution with ls: ${
165 code}`,
166 errors);
167 } else {
168 [node.left, node.right].forEach(node => checkConcatenation(node, filePath, errors));
169 }
170 }
171}
172
173/**
174 * Verify if callee is functionName() or object.functionName().
175 */
176function verifyFunctionCallee(callee, functionName) {
177 return callee !== undefined &&
178 ((callee.type === esprimaTypes.IDENTIFIER && callee.name === functionName) ||
179 (callee.type === esprimaTypes.MEMBER_EXPR && verifyIdentifier(callee.property, functionName)));
180}
181
182/**
183 * Check if an argument of a function is localized.
184 */
185function checkFunctionArgument(functionName, argumentIndex, node, filePath, errors) {
186 if (node !== undefined && node.type === esprimaTypes.CALL_EXPR && verifyFunctionCallee(node.callee, functionName) &&
187 node.arguments !== undefined && node.arguments.length > argumentIndex) {
188 const arg = node.arguments[argumentIndex];
189 if (!isLocalizationCall(arg)) {
190 let order = '';
191 switch (argumentIndex) {
192 case 0:
193 order = 'first';
194 break;
195 case 1:
196 order = 'second';
197 break;
198 case 2:
199 order = 'third';
200 break;
201 default:
202 order = `${argumentIndex + 1}th`;
203 }
204 addError(
205 `${filePath}${getLocation(node)}: ${order} argument to ${functionName}() should be localized: ${
206 escodegen.generate(node)}`,
207 errors);
208 }
209 }
210}
211
212/**
213 * Check esprima node object that represents the AST of code
214 * to see if there is any localization error.
215 */
216function analyzeNode(node, filePath, errors) {
217 if (node === undefined || node === null)
218 return;
219
220 if (node instanceof Array) {
221 for (const child of node)
222 analyzeNode(child, filePath, errors);
223
224 return;
225 }
226
227 const keys = Object.keys(node);
228 const objKeys = keys.filter(key => {
229 return typeof node[key] === 'object' && key !== 'loc';
230 });
231 if (objKeys.length === 0) {
232 // base case: all values are non-objects -> node is a leaf
233 return;
234 }
235
236 const locCase = getLocalizationCase(node);
237 const code = escodegen.generate(node);
238 switch (locCase) {
239 case 'Common.UIString':
240 case 'UI.formatLocalized':
241 const firstArgType = node.arguments[0].type;
242 if (firstArgType !== 'Literal' && firstArgType !== 'TemplateLiteral' && firstArgType !== 'Identifier' &&
243 !excludeErrors.includes(code)) {
244 addError(`${filePath}${getLocation(node)}: first argument to call should be a string: ${code}`, errors);
245 }
246 if (includesConditionalExpression(node.arguments.slice(1))) {
247 addError(
248 `${filePath}${getLocation(node)}: conditional(s) found in ${
249 code}. Please extract conditional(s) out of the localization call.`,
250 errors);
251 }
252 break;
253 case 'Tagged Template':
254 if (includesConditionalExpression(node.quasi.expressions)) {
255 addError(
256 `${filePath}${getLocation(node)}: conditional(s) found in ${
257 code}. Please extract conditional(s) out of the localization call.`,
258 errors);
259 }
260 break;
261 default:
262 // String concatenation to localization call(s) should be changed
263 checkConcatenation(node, filePath, errors);
264 // 3rd argument to createInput() should be localized
265 checkFunctionArgument('createInput', 2, node, filePath, errors);
266 break;
267 }
268
269 for (const key of objKeys) {
270 // recursively parse all the child nodes
271 analyzeNode(node[key], filePath, errors);
272 }
273}
274
275function getRelativeFilePathFromSrc(fullFilePath) {
276 return path.relative(path.resolve(THIRD_PARTY_PATH, '..'), fullFilePath);
277}
278
279async function auditFileForLocalizability(filePath, errors) {
280 const fileContent = await readFileAsync(filePath);
281 const ast = esprima.parse(fileContent.toString(), {loc: true});
282
283 const relativeFilePath = getRelativeFilePathFromSrc(filePath);
284 for (const node of ast.body)
285 analyzeNode(node, relativeFilePath, errors);
286}
287
288function shouldParseDirectory(directoryName) {
289 return !excludeDirs.reduce((result, dir) => result || directoryName.indexOf(dir) !== -1, false);
290}
291
292function shouldParseFile(filePath) {
293 return (path.extname(filePath) === '.js' && !excludeFiles.includes(path.basename(filePath)));
294}
295
296async function getFilesFromItem(itemPath, filePaths) {
297 const stat = await statAsync(itemPath);
298 if (stat.isDirectory() && shouldParseDirectory(itemPath))
299 return await getFilesFromDirectory(itemPath, filePaths);
300
301 if (shouldParseFile(itemPath))
302 filePaths.push(itemPath);
303}
304
305async function getFilesFromDirectory(directoryPath, filePaths) {
306 const itemNames = await readDirAsync(directoryPath);
307 const promises = [];
308 for (const itemName of itemNames) {
309 const itemPath = path.resolve(directoryPath, itemName);
310 promises.push(getFilesFromItem(itemPath, filePaths));
311 }
312 await Promise.all(promises);
313}