blob: be79c23b7c324f01714f650b1b9e39f696dc59c9 [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
11const fs = require('fs');
12const path = require('path');
13const {promisify} = require('util');
14const writeFileAsync = promisify(fs.writeFile);
15const localizationUtils = require('./localization_utils');
16const escodegen = localizationUtils.escodegen;
17const esprimaTypes = localizationUtils.esprimaTypes;
18const esprima = localizationUtils.esprima;
19const DEVTOOLS_FRONTEND_PATH = path.resolve(__dirname, '..', '..', 'front_end');
20const extensionStringKeys = ['category', 'destination', 'title', 'title-mac'];
21
22// Format of frontendStrings
23// { IDS_md5-hash => {
24// string: string,
25// code: string,
26// filepath: string,
27// location: {
28// start: {
29// line: number, (1-based)
30// column: number (0-based)
31// },
32// end: {
33// line: number,
34// column: number
35// }
36// },
37// arguments: string[]
38// }
39// }
40const frontendStrings = new Map();
41
42// Format
43// {
44// IDS_KEY => {
45// filepath: string,
46// location: {
47// start: {
48// line: number
49// },
50// end: {
51// line: number
52// }
53// }
54// }
55// }
56const IDSkeys = new Map();
57
58const devtoolsFrontendPath = path.resolve(__dirname, '..', '..', 'front_end');
59
60async function parseLocalizableResourceMaps(isDebug) {
61 const devtoolsFiles = [];
62 await localizationUtils.getFilesFromDirectory(devtoolsFrontendPath, devtoolsFiles, ['.js', 'module.json']);
63
64 const promises = [parseLocalizableStrings(devtoolsFiles, isDebug), parseIDSKeys(localizationUtils.GRD_PATH, isDebug)];
65 return Promise.all(promises);
66}
67
68/**
69 * The following functions parse localizable strings (wrapped in
70 * Common.UIString, UI.formatLocalized or ls``) from devtools frontend files.
71 */
72
73async function parseLocalizableStrings(devtoolsFiles, isDebug) {
74 const promises = devtoolsFiles.map(filePath => parseLocalizableStringsFromFile(filePath));
75 await Promise.all(promises);
76 if (isDebug)
77 await writeFileAsync(path.resolve(__dirname, 'localizable_strings.json'), JSON.stringify(frontendStrings));
78 return frontendStrings;
79}
80
81async function parseLocalizableStringsFromFile(filePath) {
82 const fileContent = await localizationUtils.parseFileContent(filePath);
83 if (path.basename(filePath) === 'module.json')
84 return parseLocalizableStringFromModuleJson(fileContent, filePath);
85
86 const ast = esprima.parse(fileContent, {loc: true});
87 for (const node of ast.body)
88 parseLocalizableStringFromNode(node, filePath);
89}
90
91function parseLocalizableStringFromModuleJson(fileContent, filePath) {
92 const fileJSON = JSON.parse(fileContent);
93 if (!fileJSON.extensions)
94 return;
95
96 for (const extension of fileJSON.extensions) {
97 for (const key in extension) {
98 if (extensionStringKeys.includes(key)) {
99 addString(extension[key], extension[key], filePath);
100 } else if (key === 'device') {
101 addString(extension.device.title, extension.device.title, filePath);
102 } else if (key === 'options') {
103 for (const option of extension.options) {
104 addString(option.title, option.title, filePath);
105 if (option.text !== undefined)
106 addString(option.text, option.text, filePath);
107 }
108 }
109 }
110 }
111}
112
113function parseLocalizableStringFromNode(node, filePath) {
114 if (!node)
115 return;
116
117 if (Array.isArray(node)) {
118 for (const child of node)
119 parseLocalizableStringFromNode(child, filePath);
120
121 return;
122 }
123
124 const keys = Object.keys(node);
125 const objKeys = keys.filter(key => key !== 'loc' && typeof node[key] === 'object');
126 if (objKeys.length === 0) {
127 // base case: all values are non-objects -> node is a leaf
128 return;
129 }
130
131 const locCase = localizationUtils.getLocalizationCase(node);
132 switch (locCase) {
133 case 'Common.UIString':
134 handleCommonUIString(node, filePath);
135 break;
136 case 'UI.formatLocalized':
137 if (node.arguments !== undefined && node.arguments[1] !== undefined && node.arguments[1].elements !== undefined)
138 handleCommonUIString(node, filePath, node.arguments[1].elements);
139 break;
140 case 'Tagged Template':
141 handleTemplateLiteral(node.quasi, escodegen.generate(node), filePath);
142 break;
143 case null:
144 break;
145 default:
146 throw new Error(
147 `${filePath}${localizationUtils.getLocationMessage(node.loc)}: unexpected localization case for node: ${
148 escodegen.generate(node)}`);
149 }
150
151 for (const key of objKeys) {
152 // recursively parse all the child nodes
153 parseLocalizableStringFromNode(node[key], filePath);
154 }
155}
156
157function handleCommonUIString(node, filePath, argumentNodes) {
158 if (argumentNodes === undefined)
159 argumentNodes = node.arguments.slice(1);
160 const firstArgType = node.arguments[0].type;
161 switch (firstArgType) {
162 case esprimaTypes.LITERAL:
163 const message = node.arguments[0].value;
164 addString(message, escodegen.generate(node), filePath, node.loc, argumentNodes);
165 break;
166 case esprimaTypes.TEMP_LITERAL:
167 handleTemplateLiteral(node.arguments[0], escodegen.generate(node), filePath, argumentNodes);
168 break;
169 default:
170 break;
171 }
172}
173
174function handleTemplateLiteral(node, code, filePath, argumentNodes) {
175 if (node.expressions.length === 0) {
176 // template literal does not contain any variables, parse the value
177 addString(node.quasis[0].value.cooked, code, filePath, node.loc, argumentNodes);
178 return;
179 }
180
181 argumentNodes = node.expressions;
182 let processedMsg = '';
183 for (let i = 0; i < node.quasis.length; i++) {
184 processedMsg += node.quasis[i].value.cooked;
185 if (i < node.expressions.length) {
186 // add placeholder for variable so that
187 // the ph tag gets generated
188 processedMsg += '%s';
189 }
190 }
191 addString(processedMsg, code, filePath, node.loc, argumentNodes);
192}
193
194function addString(str, code, filePath, location, argumentNodes) {
195 const currentString = {
196 string: str,
197 code: code,
198 filepath: filePath,
199 };
200 if (location)
201 currentString.location = location;
202 if (argumentNodes && argumentNodes.length > 0)
203 currentString.arguments = argumentNodes.map(argNode => escodegen.generate(argNode));
204
205 // In the case of duplicates, to enforce that entries are added to
206 // a consistent GRDP file, we use the file path that sorts lowest as
207 // the winning entry into frontendStrings.
208 const ids = localizationUtils.getIDSKey(str);
209 if (frontendStrings.has(ids) && frontendStrings.get(ids).filepath <= filePath)
210 return;
211 frontendStrings.set(ids, currentString);
212}
213
214/**
215 * The following functions parse <message>s and their IDS keys from
216 * devtools frontend grdp files.
217 */
218
219async function parseIDSKeys(grdFilePath, isDebug) {
220 // NOTE: this function assumes that no <message> tags are present in the parent
221 const grdpFilePaths = await parseGRDFile(grdFilePath);
222 await parseGRDPFiles(grdpFilePaths);
223 if (isDebug)
224 await writeFileAsync(path.resolve(__dirname, 'IDS_Keys.json'), JSON.stringify(IDSkeys));
225 return IDSkeys;
226}
227
228async function parseGRDFile(grdFilePath) {
229 const fileContent = await localizationUtils.parseFileContent(grdFilePath);
230 const grdFileDir = path.dirname(grdFilePath);
231 const partFileRegex = /<part file="(.*?)"/g;
232
233 let match;
234 const grdpFilePaths = new Set();
235 while ((match = partFileRegex.exec(fileContent)) !== null) {
236 if (match.index === partFileRegex.lastIndex)
237 partFileRegex.lastIndex++;
238 // match[0]: full match
239 // match[1]: part file path
240 grdpFilePaths.add(path.resolve(grdFileDir, match[1]));
241 }
242 return grdpFilePaths;
243}
244
245function parseGRDPFiles(grdpFilePaths) {
246 const promises = Array.from(grdpFilePaths, grdpFilePath => parseGRDPFile(grdpFilePath));
247 return Promise.all(promises);
248}
249
250function trimGrdpPlaceholder(placeholder) {
251 const exampleRegex = new RegExp('<ex>.*?<\/ex>', 'gms');
252 // $1s<ex>my example</ex> -> $1s
253 return placeholder.replace(exampleRegex, '').trim();
254}
255
256function convertToFrontendPlaceholders(message) {
257 // <ph name="phname">$1s<ex>my example</ex></ph> and <ph name="phname2">$2.3f</ph>
258 // match[0]: <ph name="phname1">$1s</ph>
259 // match[1]: $1s<ex>my example</ex>
260 let placeholderRegex = new RegExp('<ph[^>]*>(.*?)<\/ph>', 'gms');
261 let match;
262 while ((match = placeholderRegex.exec(message)) !== null) {
263 const placeholder = match[0];
264 const placeholderValue = trimGrdpPlaceholder(match[1]);
265 const newPlaceholderValue = placeholderValue.replace(/\$[1-9]/, '%');
266 message =
267 message.substring(0, match.index) + newPlaceholderValue + message.substring(match.index + placeholder.length);
268 // Modified the message, so search from the beginning of the string again.
269 placeholderRegex.lastIndex = 0;
270 }
271 return message;
272}
273
274function trimGrdpMessage(message) {
275 // ' Message text \n ' trims to ' Message text '.
276 const fixedLeadingWhitespace = 4; // GRDP encoding uses 4 leading spaces.
277 const trimmedMessage = message.substring(4);
278 return trimmedMessage.substring(0, trimmedMessage.lastIndexOf('\n'));
279}
280
281async function parseGRDPFile(filePath) {
282 const fileContent = await localizationUtils.parseFileContent(filePath);
283
284 function lineNumberOfIndex(str, index) {
285 const stringToIndex = str.substr(0, index);
286 return stringToIndex.split('\n').length;
287 }
288
289 // Example:
290 // <message name="IDS_*" desc="*">
291 // Message text here with optional placeholders <ph name="phname">$1s</ph>
292 // </message>
293 // match[0]: the entire '<message>...</message>' block.
294 // match[1]: ' Message text here with optional placeholders <ph name="phname">$1s</ph>\n '
295 const messageRegex = new RegExp('<message[^>]*>\s*\n(.*?)<\/message>', 'gms');
296 let match;
297 while ((match = messageRegex.exec(fileContent)) !== null) {
298 const line = lineNumberOfIndex(fileContent, match.index);
299
300 let message = match[1];
301 message = trimGrdpMessage(message);
302 message = convertToFrontendPlaceholders(message);
303 message = localizationUtils.sanitizeStringIntoFrontendFormat(message);
304
305 const ids = localizationUtils.getIDSKey(message);
306 IDSkeys.set(ids, {filepath: filePath, location: {start: {line}, end: {line}}});
307 }
308}
309
310/**
311 * The following functions compare frontend localizable strings
312 * with grdp <message>s and report error of resources to add or
313 * remove.
314 */
315async function getAndReportResourcesToAdd(frontendStrings, IDSkeys) {
316 const keysToAddToGRD = getDifference(IDSkeys, frontendStrings);
317 if (keysToAddToGRD.size === 0)
318 return;
319
320 let errorStr = 'The following frontend string(s) need to be added to GRD/GRDP file(s).\n';
321 errorStr += 'Please refer to auto-generated message(s) below and modify as needed.\n\n';
322
323 const frontendDirs = await localizationUtils.getChildDirectoriesFromDirectory(DEVTOOLS_FRONTEND_PATH);
324 const fileToGRDPMap = new Map();
325
326 // Example error message:
327 // third_party/blink/renderer/devtools/front_end/network/NetworkDataGridNode.js Line 973: ls`(disk cache)`
328 // Add a new message tag for this string to third_party\blink\renderer\devtools\front_end\network\network_strings.grdp
329 // <message name="IDS_DEVTOOLS_ad86890fb40822a3b12627efaca4ecd7" desc="Fill in the description.">
330 // (disk cache)
331 // </message>
332 for (const [key, stringObj] of keysToAddToGRD) {
333 let relativeGRDPFilePath = '';
334 if (fileToGRDPMap.has(stringObj.filepath)) {
335 relativeGRDPFilePath = fileToGRDPMap.get(stringObj.filepath);
336 } else {
337 relativeGRDPFilePath = localizationUtils.getRelativeFilePathFromSrc(
338 localizationUtils.getGRDPFilePath(stringObj.filepath, frontendDirs));
339 fileToGRDPMap.set(stringObj.filepath, relativeGRDPFilePath);
340 }
341 errorStr += `${localizationUtils.getRelativeFilePathFromSrc(stringObj.filepath)}${
342 localizationUtils.getLocationMessage(stringObj.location)}: ${stringObj.code}\n`;
343 errorStr += `Add a new message tag for this string to ${
344 localizationUtils.getRelativeFilePathFromSrc(
345 localizationUtils.getGRDPFilePath(stringObj.filepath, frontendDirs))}\n\n`;
346 errorStr += localizationUtils.createGrdpMessage(key, stringObj);
347 }
348 return errorStr;
349}
350
351function getAndReportResourcesToRemove(frontendStrings, IDSkeys) {
352 const keysToRemoveFromGRD = getDifference(frontendStrings, IDSkeys);
353 if (keysToRemoveFromGRD.size === 0)
354 return;
355
356 let errorStr =
357 '\nThe message(s) associated with the following IDS key(s) should be removed from its GRD/GRDP file(s):\n';
358 // Example error message:
359 // third_party/blink/renderer/devtools/front_end/help/help_strings.grdp Line 18: IDS_DEVTOOLS_7d0ee6fed10d3d4e5c9ee496729ab519
360 for (const [key, keyObj] of keysToRemoveFromGRD) {
361 errorStr += `${localizationUtils.getRelativeFilePathFromSrc(keyObj.filepath)}${
362 localizationUtils.getLocationMessage(keyObj.location)}: ${key}\n\n`;
363 }
364 return errorStr;
365}
366
367/**
368 * Output a Map containing entries that are in @comparison but not @reference in sorted order.
369 */
370function getDifference(reference, comparison) {
371 const difference = [];
372 for (const [key, value] of comparison) {
373 if (!reference.has(key))
374 difference.push([key, value]);
375 }
376 return new Map(difference.sort());
377}
378
379module.exports = {
380 parseLocalizableResourceMaps,
381 getAndReportResourcesToAdd,
382 getAndReportResourcesToRemove,
383 getDifference
384};