blob: bd3c898d45878b21053822d580eefe63e81ac5bc [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;
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000019const extensionStringKeys = ['category', 'destination', 'title', 'title-mac'];
20
21// Format of frontendStrings
22// { IDS_md5-hash => {
23// string: string,
24// code: string,
25// filepath: string,
Mandy Chenc94d52a2019-06-11 22:51:53 +000026// grdpPath: string,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000027// 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 => {
Mandy Chenc94d52a2019-06-11 22:51:53 +000045// description: string,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000046// filepath: string,
47// location: {
48// start: {
49// line: number
50// },
51// end: {
52// line: number
53// }
54// }
55// }
56// }
57const IDSkeys = new Map();
Mandy Chenc94d52a2019-06-11 22:51:53 +000058const fileToGRDPMap = new Map();
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000059
60const devtoolsFrontendPath = path.resolve(__dirname, '..', '..', 'front_end');
61
62async function parseLocalizableResourceMaps(isDebug) {
Mandy Chenc94d52a2019-06-11 22:51:53 +000063 const grdpToFiles = new Map();
64 const dirs = await localizationUtils.getChildDirectoriesFromDirectory(devtoolsFrontendPath);
65 const grdpToFilesPromises = dirs.map(dir => {
66 const files = [];
67 grdpToFiles.set(path.resolve(dir, `${path.basename(dir)}_strings.grdp`), files);
68 return localizationUtils.getFilesFromDirectory(dir, files, ['.js', 'module.json']);
69 });
70 await Promise.all(grdpToFilesPromises);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000071
Mandy Chenc94d52a2019-06-11 22:51:53 +000072 const promises = [parseIDSKeys(localizationUtils.GRD_PATH, isDebug)];
73 for (const [grdpPath, files] of grdpToFiles) {
74 files.forEach(file => fileToGRDPMap.set(file, grdpPath));
75 promises.push(parseLocalizableStrings(files, isDebug));
76 }
77 await Promise.all(promises);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +000078}
79
80/**
81 * The following functions parse localizable strings (wrapped in
82 * Common.UIString, UI.formatLocalized or ls``) from devtools frontend files.
83 */
84
85async function parseLocalizableStrings(devtoolsFiles, isDebug) {
86 const promises = devtoolsFiles.map(filePath => parseLocalizableStringsFromFile(filePath));
87 await Promise.all(promises);
88 if (isDebug)
89 await writeFileAsync(path.resolve(__dirname, 'localizable_strings.json'), JSON.stringify(frontendStrings));
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
97 const ast = esprima.parse(fileContent, {loc: true});
98 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 }
119 }
120 }
121 }
122}
123
124function parseLocalizableStringFromNode(node, filePath) {
125 if (!node)
126 return;
127
128 if (Array.isArray(node)) {
129 for (const child of node)
130 parseLocalizableStringFromNode(child, filePath);
131
132 return;
133 }
134
135 const keys = Object.keys(node);
136 const objKeys = keys.filter(key => key !== 'loc' && typeof node[key] === 'object');
137 if (objKeys.length === 0) {
138 // base case: all values are non-objects -> node is a leaf
139 return;
140 }
141
142 const locCase = localizationUtils.getLocalizationCase(node);
143 switch (locCase) {
144 case 'Common.UIString':
145 handleCommonUIString(node, filePath);
146 break;
147 case 'UI.formatLocalized':
148 if (node.arguments !== undefined && node.arguments[1] !== undefined && node.arguments[1].elements !== undefined)
149 handleCommonUIString(node, filePath, node.arguments[1].elements);
150 break;
151 case 'Tagged Template':
152 handleTemplateLiteral(node.quasi, escodegen.generate(node), filePath);
153 break;
154 case null:
155 break;
156 default:
157 throw new Error(
158 `${filePath}${localizationUtils.getLocationMessage(node.loc)}: unexpected localization case for node: ${
159 escodegen.generate(node)}`);
160 }
161
162 for (const key of objKeys) {
163 // recursively parse all the child nodes
164 parseLocalizableStringFromNode(node[key], filePath);
165 }
166}
167
168function handleCommonUIString(node, filePath, argumentNodes) {
169 if (argumentNodes === undefined)
170 argumentNodes = node.arguments.slice(1);
171 const firstArgType = node.arguments[0].type;
172 switch (firstArgType) {
173 case esprimaTypes.LITERAL:
174 const message = node.arguments[0].value;
175 addString(message, escodegen.generate(node), filePath, node.loc, argumentNodes);
176 break;
177 case esprimaTypes.TEMP_LITERAL:
178 handleTemplateLiteral(node.arguments[0], escodegen.generate(node), filePath, argumentNodes);
179 break;
180 default:
181 break;
182 }
183}
184
185function handleTemplateLiteral(node, code, filePath, argumentNodes) {
186 if (node.expressions.length === 0) {
187 // template literal does not contain any variables, parse the value
188 addString(node.quasis[0].value.cooked, code, filePath, node.loc, argumentNodes);
189 return;
190 }
191
192 argumentNodes = node.expressions;
193 let processedMsg = '';
194 for (let i = 0; i < node.quasis.length; i++) {
195 processedMsg += node.quasis[i].value.cooked;
196 if (i < node.expressions.length) {
197 // add placeholder for variable so that
198 // the ph tag gets generated
199 processedMsg += '%s';
200 }
201 }
202 addString(processedMsg, code, filePath, node.loc, argumentNodes);
203}
204
205function addString(str, code, filePath, location, argumentNodes) {
Mandy Chenc94d52a2019-06-11 22:51:53 +0000206 const currentString = {string: str, code: code, filepath: filePath, grdpPath: fileToGRDPMap.get(filePath)};
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000207 if (location)
208 currentString.location = location;
209 if (argumentNodes && argumentNodes.length > 0)
210 currentString.arguments = argumentNodes.map(argNode => escodegen.generate(argNode));
211
212 // In the case of duplicates, to enforce that entries are added to
213 // a consistent GRDP file, we use the file path that sorts lowest as
214 // the winning entry into frontendStrings.
215 const ids = localizationUtils.getIDSKey(str);
216 if (frontendStrings.has(ids) && frontendStrings.get(ids).filepath <= filePath)
217 return;
218 frontendStrings.set(ids, currentString);
219}
220
221/**
222 * The following functions parse <message>s and their IDS keys from
223 * devtools frontend grdp files.
224 */
225
226async function parseIDSKeys(grdFilePath, isDebug) {
227 // NOTE: this function assumes that no <message> tags are present in the parent
228 const grdpFilePaths = await parseGRDFile(grdFilePath);
229 await parseGRDPFiles(grdpFilePaths);
230 if (isDebug)
231 await writeFileAsync(path.resolve(__dirname, 'IDS_Keys.json'), JSON.stringify(IDSkeys));
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000232}
233
234async function parseGRDFile(grdFilePath) {
235 const fileContent = await localizationUtils.parseFileContent(grdFilePath);
236 const grdFileDir = path.dirname(grdFilePath);
237 const partFileRegex = /<part file="(.*?)"/g;
238
239 let match;
240 const grdpFilePaths = new Set();
241 while ((match = partFileRegex.exec(fileContent)) !== null) {
242 if (match.index === partFileRegex.lastIndex)
243 partFileRegex.lastIndex++;
244 // match[0]: full match
245 // match[1]: part file path
246 grdpFilePaths.add(path.resolve(grdFileDir, match[1]));
247 }
248 return grdpFilePaths;
249}
250
251function parseGRDPFiles(grdpFilePaths) {
252 const promises = Array.from(grdpFilePaths, grdpFilePath => parseGRDPFile(grdpFilePath));
253 return Promise.all(promises);
254}
255
256function trimGrdpPlaceholder(placeholder) {
257 const exampleRegex = new RegExp('<ex>.*?<\/ex>', 'gms');
258 // $1s<ex>my example</ex> -> $1s
259 return placeholder.replace(exampleRegex, '').trim();
260}
261
262function convertToFrontendPlaceholders(message) {
263 // <ph name="phname">$1s<ex>my example</ex></ph> and <ph name="phname2">$2.3f</ph>
264 // match[0]: <ph name="phname1">$1s</ph>
265 // match[1]: $1s<ex>my example</ex>
266 let placeholderRegex = new RegExp('<ph[^>]*>(.*?)<\/ph>', 'gms');
267 let match;
268 while ((match = placeholderRegex.exec(message)) !== null) {
269 const placeholder = match[0];
270 const placeholderValue = trimGrdpPlaceholder(match[1]);
271 const newPlaceholderValue = placeholderValue.replace(/\$[1-9]/, '%');
272 message =
273 message.substring(0, match.index) + newPlaceholderValue + message.substring(match.index + placeholder.length);
274 // Modified the message, so search from the beginning of the string again.
275 placeholderRegex.lastIndex = 0;
276 }
277 return message;
278}
279
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000280async function parseGRDPFile(filePath) {
281 const fileContent = await localizationUtils.parseFileContent(filePath);
282
283 function lineNumberOfIndex(str, index) {
284 const stringToIndex = str.substr(0, index);
285 return stringToIndex.split('\n').length;
286 }
287
Mandy Chen78552632019-06-12 00:55:43 +0000288 function stripWhitespacePadding(message) {
289 let match = message.match(/^'''/);
290 if (match)
291 message = message.substring(3);
292 match = message.match(/(.*?)'''$/);
293 if (match)
294 message = match[1];
295 return message;
296 }
297
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000298 // Example:
Mandy Chenc94d52a2019-06-11 22:51:53 +0000299 // <message name="IDS_*" desc="Description of this message">
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000300 // Message text here with optional placeholders <ph name="phname">$1s</ph>
301 // </message>
302 // match[0]: the entire '<message>...</message>' block.
Mandy Chenc94d52a2019-06-11 22:51:53 +0000303 // match[1]: 'Description of this message'
304 // match[2]: ' Message text here with optional placeholders <ph name="phname">$1s</ph>\n '
305 const messageRegex = new RegExp('<message[^>]*desc="([^"]*)"[^>]*>\s*\n(.*?)<\/message>', 'gms');
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000306 let match;
307 while ((match = messageRegex.exec(fileContent)) !== null) {
308 const line = lineNumberOfIndex(fileContent, match.index);
Mandy Chenc94d52a2019-06-11 22:51:53 +0000309 const description = match[1];
310 let message = match[2];
Mandy Chen78552632019-06-12 00:55:43 +0000311 message = convertToFrontendPlaceholders(message.trim());
312 message = stripWhitespacePadding(message);
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000313 message = localizationUtils.sanitizeStringIntoFrontendFormat(message);
314
315 const ids = localizationUtils.getIDSKey(message);
Mandy Chenc94d52a2019-06-11 22:51:53 +0000316 IDSkeys.set(ids, {grdpPath: filePath, location: {start: {line}, end: {line}}, description});
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000317 }
318}
319
320/**
321 * The following functions compare frontend localizable strings
322 * with grdp <message>s and report error of resources to add or
323 * remove.
324 */
325async function getAndReportResourcesToAdd(frontendStrings, IDSkeys) {
326 const keysToAddToGRD = getDifference(IDSkeys, frontendStrings);
327 if (keysToAddToGRD.size === 0)
328 return;
329
330 let errorStr = 'The following frontend string(s) need to be added to GRD/GRDP file(s).\n';
331 errorStr += 'Please refer to auto-generated message(s) below and modify as needed.\n\n';
332
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000333 // Example error message:
334 // third_party/blink/renderer/devtools/front_end/network/NetworkDataGridNode.js Line 973: ls`(disk cache)`
335 // Add a new message tag for this string to third_party\blink\renderer\devtools\front_end\network\network_strings.grdp
336 // <message name="IDS_DEVTOOLS_ad86890fb40822a3b12627efaca4ecd7" desc="Fill in the description.">
337 // (disk cache)
338 // </message>
339 for (const [key, stringObj] of keysToAddToGRD) {
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000340 errorStr += `${localizationUtils.getRelativeFilePathFromSrc(stringObj.filepath)}${
341 localizationUtils.getLocationMessage(stringObj.location)}: ${stringObj.code}\n`;
342 errorStr += `Add a new message tag for this string to ${
Mandy Chenc94d52a2019-06-11 22:51:53 +0000343 localizationUtils.getRelativeFilePathFromSrc(fileToGRDPMap.get(stringObj.filepath))}\n\n`;
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000344 errorStr += localizationUtils.createGrdpMessage(key, stringObj);
345 }
346 return errorStr;
347}
348
349function getAndReportResourcesToRemove(frontendStrings, IDSkeys) {
350 const keysToRemoveFromGRD = getDifference(frontendStrings, IDSkeys);
351 if (keysToRemoveFromGRD.size === 0)
352 return;
353
354 let errorStr =
355 '\nThe message(s) associated with the following IDS key(s) should be removed from its GRD/GRDP file(s):\n';
356 // Example error message:
357 // third_party/blink/renderer/devtools/front_end/help/help_strings.grdp Line 18: IDS_DEVTOOLS_7d0ee6fed10d3d4e5c9ee496729ab519
358 for (const [key, keyObj] of keysToRemoveFromGRD) {
Mandy Chenc94d52a2019-06-11 22:51:53 +0000359 errorStr += `${localizationUtils.getRelativeFilePathFromSrc(keyObj.grdpPath)}${
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000360 localizationUtils.getLocationMessage(keyObj.location)}: ${key}\n\n`;
361 }
362 return errorStr;
363}
364
365/**
Mandy Chenc94d52a2019-06-11 22:51:53 +0000366 * Output a Map containing sorted entries that are in @comparison but not @reference,
367 * or entries that are in both but belong to different grdp files.
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000368 */
369function getDifference(reference, comparison) {
370 const difference = [];
371 for (const [key, value] of comparison) {
Mandy Chenc94d52a2019-06-11 22:51:53 +0000372 if (!reference.has(key) || reference.get(key).grdpPath !== value.grdpPath)
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000373 difference.push([key, value]);
374 }
375 return new Map(difference.sort());
376}
377
378module.exports = {
Mandy Chenc94d52a2019-06-11 22:51:53 +0000379 frontendStrings,
380 IDSkeys,
Lorne Mitchellc56ff2d2019-05-28 23:35:03 +0000381 parseLocalizableResourceMaps,
382 getAndReportResourcesToAdd,
383 getAndReportResourcesToRemove,
384 getDifference
385};