blob: 577915e34c8340c56d3c4a7e1363bd2210fde0b0 [file] [log] [blame]
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +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
5/**
6 * Functions in this script parse DevTools frontend .js and module.json files,
7 * collect localizable strings, check if frontend strings are in .grd/.grdp
8 * files and report error if present.
9 */
10
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000011const path = require('path');
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000012const localizationUtils = require('./localization_utils');
13const escodegen = localizationUtils.escodegen;
14const esprimaTypes = localizationUtils.esprimaTypes;
15const esprima = localizationUtils.esprima;
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000016const extensionStringKeys = ['category', 'destination', 'title', 'title-mac'];
17
18// Format of frontendStrings
19// { IDS_md5-hash => {
20// string: string,
21// code: string,
Mandy Chen1e9d87b2019-09-18 17:18:15 +000022// isShared: boolean,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000023// filepath: string,
Mandy Chenc94d52a2019-06-11 22:51:53 +000024// grdpPath: string,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000025// location: {
26// start: {
27// line: number, (1-based)
28// column: number (0-based)
29// },
30// end: {
31// line: number,
32// column: number
33// }
34// },
35// arguments: string[]
36// }
37// }
38const frontendStrings = new Map();
39
40// Format
41// {
Mandy Chen4a7ad052019-07-16 16:09:29 +000042// IDS_KEY => a list of {
Mandy Chen81d4fc42019-07-11 23:12:02 +000043// actualIDSKey: string, // the IDS key in the message tag
Mandy Chenc94d52a2019-06-11 22:51:53 +000044// description: string,
Mandy Chen4a7ad052019-07-16 16:09:29 +000045// grdpPath: string,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000046// location: {
47// start: {
48// line: number
49// },
50// end: {
51// line: number
52// }
53// }
54// }
55// }
56const IDSkeys = new Map();
Mandy Chenc94d52a2019-06-11 22:51:53 +000057const fileToGRDPMap = new Map();
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000058
59const devtoolsFrontendPath = path.resolve(__dirname, '..', '..', 'front_end');
60
Mandy Chen4a7ad052019-07-16 16:09:29 +000061async function parseLocalizableResourceMaps() {
Mandy Chenc94d52a2019-06-11 22:51:53 +000062 const grdpToFiles = new Map();
63 const dirs = await localizationUtils.getChildDirectoriesFromDirectory(devtoolsFrontendPath);
64 const grdpToFilesPromises = dirs.map(dir => {
65 const files = [];
66 grdpToFiles.set(path.resolve(dir, `${path.basename(dir)}_strings.grdp`), files);
67 return localizationUtils.getFilesFromDirectory(dir, files, ['.js', 'module.json']);
68 });
69 await Promise.all(grdpToFilesPromises);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000070
Mandy Chen4a7ad052019-07-16 16:09:29 +000071 const promises = [];
Mandy Chenc94d52a2019-06-11 22:51:53 +000072 for (const [grdpPath, files] of grdpToFiles) {
73 files.forEach(file => fileToGRDPMap.set(file, grdpPath));
Mandy Chen4a7ad052019-07-16 16:09:29 +000074 promises.push(parseLocalizableStrings(files));
Mandy Chenc94d52a2019-06-11 22:51:53 +000075 }
76 await Promise.all(promises);
Mandy Chen4a7ad052019-07-16 16:09:29 +000077 // Parse grd(p) files after frontend strings are processed so we know
78 // what to add or remove based on frontend strings
79 await parseIDSKeys(localizationUtils.GRD_PATH);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000080}
81
82/**
Mandy Chen7a8829b2019-06-25 22:13:07 +000083 * The following functions parse localizable strings (wrapped in Common.UIString,
84 * Common.UIStringFormat, UI.formatLocalized or ls``) from devtools frontend files.
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000085 */
86
Mandy Chen4a7ad052019-07-16 16:09:29 +000087async function parseLocalizableStrings(devtoolsFiles) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000088 const promises = devtoolsFiles.map(filePath => parseLocalizableStringsFromFile(filePath));
89 await Promise.all(promises);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000090}
91
92async function parseLocalizableStringsFromFile(filePath) {
93 const fileContent = await localizationUtils.parseFileContent(filePath);
94 if (path.basename(filePath) === 'module.json')
95 return parseLocalizableStringFromModuleJson(fileContent, filePath);
96
Tim van der Lippe63a4d3d2019-09-18 15:31:38 +000097 const ast = esprima.parseModule(fileContent, {loc: true});
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000098 for (const node of ast.body)
99 parseLocalizableStringFromNode(node, filePath);
100}
101
102function parseLocalizableStringFromModuleJson(fileContent, filePath) {
103 const fileJSON = JSON.parse(fileContent);
104 if (!fileJSON.extensions)
105 return;
106
107 for (const extension of fileJSON.extensions) {
108 for (const key in extension) {
109 if (extensionStringKeys.includes(key)) {
110 addString(extension[key], extension[key], filePath);
111 } else if (key === 'device') {
112 addString(extension.device.title, extension.device.title, filePath);
113 } else if (key === 'options') {
114 for (const option of extension.options) {
115 addString(option.title, option.title, filePath);
116 if (option.text !== undefined)
117 addString(option.text, option.text, filePath);
118 }
Mandy Chen609679b2019-09-10 16:04:08 +0000119 } else if (key === 'defaultValue' && Array.isArray(extension[key])) {
120 for (const defaultVal of extension[key]) {
121 if (defaultVal.title)
122 addString(defaultVal.title, defaultVal.title, filePath);
123 }
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000124 }
125 }
126 }
127}
128
129function parseLocalizableStringFromNode(node, filePath) {
130 if (!node)
131 return;
132
133 if (Array.isArray(node)) {
134 for (const child of node)
135 parseLocalizableStringFromNode(child, filePath);
136
137 return;
138 }
139
140 const keys = Object.keys(node);
141 const objKeys = keys.filter(key => key !== 'loc' && typeof node[key] === 'object');
142 if (objKeys.length === 0) {
143 // base case: all values are non-objects -> node is a leaf
144 return;
145 }
146
147 const locCase = localizationUtils.getLocalizationCase(node);
148 switch (locCase) {
149 case 'Common.UIString':
Mandy Chen7a8829b2019-06-25 22:13:07 +0000150 case 'Common.UIStringFormat':
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000151 handleCommonUIString(node, filePath);
152 break;
153 case 'UI.formatLocalized':
154 if (node.arguments !== undefined && node.arguments[1] !== undefined && node.arguments[1].elements !== undefined)
155 handleCommonUIString(node, filePath, node.arguments[1].elements);
156 break;
157 case 'Tagged Template':
158 handleTemplateLiteral(node.quasi, escodegen.generate(node), filePath);
159 break;
160 case null:
161 break;
162 default:
163 throw new Error(
164 `${filePath}${localizationUtils.getLocationMessage(node.loc)}: unexpected localization case for node: ${
165 escodegen.generate(node)}`);
166 }
167
168 for (const key of objKeys) {
169 // recursively parse all the child nodes
170 parseLocalizableStringFromNode(node[key], filePath);
171 }
172}
173
174function handleCommonUIString(node, filePath, argumentNodes) {
175 if (argumentNodes === undefined)
176 argumentNodes = node.arguments.slice(1);
177 const firstArgType = node.arguments[0].type;
178 switch (firstArgType) {
179 case esprimaTypes.LITERAL:
180 const message = node.arguments[0].value;
181 addString(message, escodegen.generate(node), filePath, node.loc, argumentNodes);
182 break;
183 case esprimaTypes.TEMP_LITERAL:
184 handleTemplateLiteral(node.arguments[0], escodegen.generate(node), filePath, argumentNodes);
185 break;
186 default:
187 break;
188 }
189}
190
191function handleTemplateLiteral(node, code, filePath, argumentNodes) {
192 if (node.expressions.length === 0) {
193 // template literal does not contain any variables, parse the value
194 addString(node.quasis[0].value.cooked, code, filePath, node.loc, argumentNodes);
195 return;
196 }
197
198 argumentNodes = node.expressions;
199 let processedMsg = '';
200 for (let i = 0; i < node.quasis.length; i++) {
201 processedMsg += node.quasis[i].value.cooked;
202 if (i < node.expressions.length) {
203 // add placeholder for variable so that
204 // the ph tag gets generated
205 processedMsg += '%s';
206 }
207 }
208 addString(processedMsg, code, filePath, node.loc, argumentNodes);
209}
210
211function addString(str, code, filePath, location, argumentNodes) {
Mandy Chen1e9d87b2019-09-18 17:18:15 +0000212 const ids = localizationUtils.getIDSKey(str);
213
214 // In the case of duplicates, the corresponding grdp message should be added
215 // to the shared strings file only if the duplicate strings span across different
216 // grdp files
217 const existingString = frontendStrings.get(ids);
218 if (existingString) {
219 if (!existingString.isShared && existingString.grdpPath !== fileToGRDPMap.get(filePath)) {
220 existingString.isShared = true;
221 existingString.grdpPath = localizationUtils.SHARED_STRINGS_PATH;
222 }
223 return;
224 }
225
226 const currentString =
227 {string: str, code: code, isShared: false, filepath: filePath, grdpPath: fileToGRDPMap.get(filePath)};
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000228 if (location)
229 currentString.location = location;
230 if (argumentNodes && argumentNodes.length > 0)
231 currentString.arguments = argumentNodes.map(argNode => escodegen.generate(argNode));
232
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000233 frontendStrings.set(ids, currentString);
234}
235
236/**
237 * The following functions parse <message>s and their IDS keys from
238 * devtools frontend grdp files.
239 */
240
Mandy Chen4a7ad052019-07-16 16:09:29 +0000241async function parseIDSKeys(grdFilePath) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000242 // NOTE: this function assumes that no <message> tags are present in the parent
243 const grdpFilePaths = await parseGRDFile(grdFilePath);
244 await parseGRDPFiles(grdpFilePaths);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000245}
246
247async function parseGRDFile(grdFilePath) {
248 const fileContent = await localizationUtils.parseFileContent(grdFilePath);
249 const grdFileDir = path.dirname(grdFilePath);
250 const partFileRegex = /<part file="(.*?)"/g;
251
252 let match;
253 const grdpFilePaths = new Set();
254 while ((match = partFileRegex.exec(fileContent)) !== null) {
255 if (match.index === partFileRegex.lastIndex)
256 partFileRegex.lastIndex++;
257 // match[0]: full match
258 // match[1]: part file path
259 grdpFilePaths.add(path.resolve(grdFileDir, match[1]));
260 }
261 return grdpFilePaths;
262}
263
264function parseGRDPFiles(grdpFilePaths) {
265 const promises = Array.from(grdpFilePaths, grdpFilePath => parseGRDPFile(grdpFilePath));
266 return Promise.all(promises);
267}
268
269function trimGrdpPlaceholder(placeholder) {
270 const exampleRegex = new RegExp('<ex>.*?<\/ex>', 'gms');
271 // $1s<ex>my example</ex> -> $1s
272 return placeholder.replace(exampleRegex, '').trim();
273}
274
275function convertToFrontendPlaceholders(message) {
276 // <ph name="phname">$1s<ex>my example</ex></ph> and <ph name="phname2">$2.3f</ph>
277 // match[0]: <ph name="phname1">$1s</ph>
278 // match[1]: $1s<ex>my example</ex>
279 let placeholderRegex = new RegExp('<ph[^>]*>(.*?)<\/ph>', 'gms');
280 let match;
281 while ((match = placeholderRegex.exec(message)) !== null) {
282 const placeholder = match[0];
283 const placeholderValue = trimGrdpPlaceholder(match[1]);
284 const newPlaceholderValue = placeholderValue.replace(/\$[1-9]/, '%');
285 message =
286 message.substring(0, match.index) + newPlaceholderValue + message.substring(match.index + placeholder.length);
287 // Modified the message, so search from the beginning of the string again.
288 placeholderRegex.lastIndex = 0;
289 }
290 return message;
291}
292
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000293async function parseGRDPFile(filePath) {
294 const fileContent = await localizationUtils.parseFileContent(filePath);
295
Mandy Chen78552632019-06-12 00:55:43 +0000296 function stripWhitespacePadding(message) {
297 let match = message.match(/^'''/);
298 if (match)
299 message = message.substring(3);
300 match = message.match(/(.*?)'''$/);
301 if (match)
302 message = match[1];
303 return message;
304 }
305
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000306 // Example:
Mandy Chen81d4fc42019-07-11 23:12:02 +0000307 // <message name="IDS_DEVTOOLS_md5_hash" desc="Description of this message">
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000308 // Message text here with optional placeholders <ph name="phname">$1s</ph>
309 // </message>
310 // match[0]: the entire '<message>...</message>' block.
Mandy Chen81d4fc42019-07-11 23:12:02 +0000311 // match[1]: 'IDS_DEVTOOLS_md5_hash'
312 // match[2]: 'Description of this message'
313 // match[3]: ' Message text here with optional placeholders <ph name="phname">$1s</ph>\n '
314 const messageRegex = new RegExp('<message[^>]*name="([^"]*)"[^>]*desc="([^"]*)"[^>]*>\s*\n(.*?)<\/message>', 'gms');
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000315 let match;
316 while ((match = messageRegex.exec(fileContent)) !== null) {
Mandy Chend97200b2019-07-29 21:13:39 +0000317 const line = localizationUtils.lineNumberOfIndex(fileContent, match.index);
Mandy Chen81d4fc42019-07-11 23:12:02 +0000318 const actualIDSKey = match[1];
319 const description = match[2];
320 let message = match[3];
Mandy Chen78552632019-06-12 00:55:43 +0000321 message = convertToFrontendPlaceholders(message.trim());
322 message = stripWhitespacePadding(message);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000323 message = localizationUtils.sanitizeStringIntoFrontendFormat(message);
324
325 const ids = localizationUtils.getIDSKey(message);
Mandy Chen4a7ad052019-07-16 16:09:29 +0000326 addMessage(ids, actualIDSKey, filePath, line, description);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000327 }
328}
329
Mandy Chen4a7ad052019-07-16 16:09:29 +0000330function addMessage(expectedIDSKey, actualIDSKey, grdpPath, line, description) {
331 if (!IDSkeys.has(expectedIDSKey))
332 IDSkeys.set(expectedIDSKey, []);
333
334 IDSkeys.get(expectedIDSKey).push({actualIDSKey, grdpPath, location: {start: {line}, end: {line}}, description});
335}
336
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000337/**
338 * The following functions compare frontend localizable strings
Mandy Chen81d4fc42019-07-11 23:12:02 +0000339 * with grdp <message>s and report error of resources to add,
340 * remove or modify.
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000341 */
Mandy Chen4a7ad052019-07-16 16:09:29 +0000342async function getAndReportResourcesToAdd() {
343 const keysToAddToGRD = getMessagesToAdd();
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000344 if (keysToAddToGRD.size === 0)
345 return;
346
347 let errorStr = 'The following frontend string(s) need to be added to GRD/GRDP file(s).\n';
348 errorStr += 'Please refer to auto-generated message(s) below and modify as needed.\n\n';
349
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000350 // Example error message:
351 // third_party/blink/renderer/devtools/front_end/network/NetworkDataGridNode.js Line 973: ls`(disk cache)`
352 // Add a new message tag for this string to third_party\blink\renderer\devtools\front_end\network\network_strings.grdp
353 // <message name="IDS_DEVTOOLS_ad86890fb40822a3b12627efaca4ecd7" desc="Fill in the description.">
354 // (disk cache)
355 // </message>
356 for (const [key, stringObj] of keysToAddToGRD) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000357 errorStr += `${localizationUtils.getRelativeFilePathFromSrc(stringObj.filepath)}${
358 localizationUtils.getLocationMessage(stringObj.location)}: ${stringObj.code}\n`;
359 errorStr += `Add a new message tag for this string to ${
Mandy Chenc94d52a2019-06-11 22:51:53 +0000360 localizationUtils.getRelativeFilePathFromSrc(fileToGRDPMap.get(stringObj.filepath))}\n\n`;
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000361 errorStr += localizationUtils.createGrdpMessage(key, stringObj);
362 }
363 return errorStr;
364}
365
Mandy Chen4a7ad052019-07-16 16:09:29 +0000366function getAndReportResourcesToRemove() {
367 const keysToRemoveFromGRD = getMessagesToRemove();
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000368 if (keysToRemoveFromGRD.size === 0)
369 return;
370
371 let errorStr =
372 '\nThe message(s) associated with the following IDS key(s) should be removed from its GRD/GRDP file(s):\n';
373 // Example error message:
Mandy Chen4a7ad052019-07-16 16:09:29 +0000374 // third_party/blink/renderer/devtools/front_end/accessibility/accessibility_strings.grdp Line 300: IDS_DEVTOOLS_c9bbad3047af039c14d0e7ec957bb867
375 for (const [ids, messages] of keysToRemoveFromGRD) {
376 messages.forEach(
377 message => errorStr += `${localizationUtils.getRelativeFilePathFromSrc(message.grdpPath)}${
378 localizationUtils.getLocationMessage(message.location)}: ${ids}\n\n`);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000379 }
380 return errorStr;
381}
382
Mandy Chen81d4fc42019-07-11 23:12:02 +0000383function getAndReportIDSKeysToModify() {
384 const messagesToModify = getIDSKeysToModify();
385 if (messagesToModify.size === 0)
386 return;
387
388 let errorStr = '\nThe following GRD/GRDP message(s) do not have the correct IDS key.\n';
389 errorStr += 'Please update the key(s) by changing the "name" value.\n\n';
390
Mandy Chen4a7ad052019-07-16 16:09:29 +0000391 for (const [expectedIDSKey, messages] of messagesToModify) {
392 messages.forEach(
393 message => errorStr += `${localizationUtils.getRelativeFilePathFromSrc(message.grdpPath)}${
394 localizationUtils.getLocationMessage(
395 message.location)}:\n${message.actualIDSKey} --> ${expectedIDSKey}\n\n`);
Mandy Chen81d4fc42019-07-11 23:12:02 +0000396 }
397 return errorStr;
398}
399
Mandy Chen4a7ad052019-07-16 16:09:29 +0000400function getMessagesToAdd() {
401 // If a message with ids key exists in grdpPath
402 function messageExists(ids, grdpPath) {
403 const messages = IDSkeys.get(ids);
404 return messages.some(message => message.grdpPath === grdpPath);
405 }
406
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000407 const difference = [];
Mandy Chen4a7ad052019-07-16 16:09:29 +0000408 for (const [ids, frontendString] of frontendStrings) {
409 if (!IDSkeys.has(ids) || !messageExists(ids, frontendString.grdpPath))
410 difference.push([ids, frontendString]);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000411 }
412 return new Map(difference.sort());
413}
414
Mandy Chen4a7ad052019-07-16 16:09:29 +0000415// Return a map from the expected IDS key to a list of messages
416// whose actual IDS keys need to be modified.
Mandy Chen81d4fc42019-07-11 23:12:02 +0000417function getIDSKeysToModify() {
418 const messagesToModify = new Map();
Mandy Chen4a7ad052019-07-16 16:09:29 +0000419 for (const [expectedIDSKey, messages] of IDSkeys) {
420 for (const message of messages) {
421 if (expectedIDSKey !== message.actualIDSKey) {
422 if (messagesToModify.has(expectedIDSKey))
423 messagesToModify.get(expectedIDSKey).push(message);
424 else
425 messagesToModify.set(expectedIDSKey, [message]);
426 }
427 }
Mandy Chen81d4fc42019-07-11 23:12:02 +0000428 }
429 return messagesToModify;
430}
431
Mandy Chen4a7ad052019-07-16 16:09:29 +0000432function getMessagesToRemove() {
433 const difference = new Map();
434 for (const [ids, messages] of IDSkeys) {
435 if (!frontendStrings.has(ids)) {
436 difference.set(ids, messages);
437 continue;
438 }
439
440 const expectedGrdpPath = frontendStrings.get(ids).grdpPath;
441 const messagesInGrdp = [];
442 const messagesToRemove = [];
443 messages.forEach(message => {
444 if (message.grdpPath !== expectedGrdpPath)
445 messagesToRemove.push(message);
446 else
447 messagesInGrdp.push(message);
448 });
449
450 if (messagesToRemove.length === 0 && messagesInGrdp.length === 1)
451 continue;
452
453 if (messagesInGrdp.length > 1) {
454 // If there are more than one messages with ids in the
455 // expected grdp file, keep one with the longest
456 // description and delete all the other messages
457 const longestDescription = getLongestDescription(messagesInGrdp);
458 let foundMessageToKeep = false;
459 for (const message of messagesInGrdp) {
460 if (message.description === longestDescription && !foundMessageToKeep) {
461 foundMessageToKeep = true;
462 continue;
463 }
464 messagesToRemove.push(message);
465 }
466 }
467 difference.set(ids, messagesToRemove);
468 }
469 return difference;
470}
471
472function getLongestDescription(messages) {
473 let longestDescription = '';
474 messages.forEach(message => {
475 if (message.description.length > longestDescription.length)
476 longestDescription = message.description;
477 });
478 return longestDescription;
479}
480
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000481module.exports = {
Mandy Chenc94d52a2019-06-11 22:51:53 +0000482 frontendStrings,
483 IDSkeys,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000484 parseLocalizableResourceMaps,
Mandy Chen81d4fc42019-07-11 23:12:02 +0000485 getAndReportIDSKeysToModify,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000486 getAndReportResourcesToAdd,
487 getAndReportResourcesToRemove,
Mandy Chen4a7ad052019-07-16 16:09:29 +0000488 getIDSKeysToModify,
489 getLongestDescription,
490 getMessagesToAdd,
491 getMessagesToRemove,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000492};