| OLD | NEW |
| (Empty) | |
| 1 #!/usr/bin/env node |
| 2 |
| 3 // Copyright 2017 The Chromium Authors. All rights reserved. |
| 4 // Use of this source code is governed by a BSD-style license that can be |
| 5 // found in the LICENSE file. |
| 6 |
| 7 'use strict'; |
| 8 |
| 9 const os = require('os'); |
| 10 const fs = require('fs'); |
| 11 const path = require('path'); |
| 12 const glob = require('glob'); |
| 13 const libtidy = require('libtidy'); |
| 14 const cp = require('child_process'); |
| 15 const jsdom = require('jsdom'); |
| 16 const {JSDOM} = jsdom; |
| 17 |
| 18 |
| 19 /** |
| 20 * Options for sub modules. |
| 21 */ |
| 22 const OPTIONS = { |
| 23 |
| 24 HTMLTidy: |
| 25 {'indent': 'yes', 'indent-spaces': '2', 'wrap': '80', 'tidy-mark': 'no'}, |
| 26 |
| 27 ClangFormat: ['-style=Chromium', '-assume-filename=a.js'], |
| 28 |
| 29 // RegExp text swap collection (ordered key-value pair) for post-processing. |
| 30 RegExpSwapCollection: [ |
| 31 // Replace |var| with |let|. |
| 32 {regexp: /(\n\s{2,}|\()var /, replace: '$1let '}, |
| 33 |
| 34 // Move one line up the dangling closing script tags. |
| 35 {regexp: /\>\n\s{2,}\<\/script\>\n/, replace: '></script>\n'}, |
| 36 |
| 37 // Remove all the empty lines in html. |
| 38 {regexp: /\>\n{2,}/, replace: '>\n'} |
| 39 ] |
| 40 }; |
| 41 |
| 42 |
| 43 /** |
| 44 * Basic utilities. |
| 45 */ |
| 46 const Util = { |
| 47 |
| 48 logAndExit: (moduleName, messageString) => { |
| 49 console.error('[layout-test-tidy::' + moduleName + '] ' + messageString); |
| 50 process.exit(1); |
| 51 }, |
| 52 |
| 53 loadFileToStringSync: (filePath) => { |
| 54 return fs.readFileSync(filePath, 'utf8').toString(); |
| 55 }, |
| 56 |
| 57 writeStringToFileSync: (pageString, filePath) => { |
| 58 fs.writeFileSync(filePath, pageString); |
| 59 } |
| 60 |
| 61 }; |
| 62 |
| 63 |
| 64 /** |
| 65 * Wrapper for external modules like HTMLTidy and clang format. |
| 66 * @type {Object} |
| 67 */ |
| 68 const Module = { |
| 69 |
| 70 /** |
| 71 * Perform a batch RegExp string substitution. |
| 72 * @param {String} targetString Target string. |
| 73 * @param {Array} swapCollection Array of key-value pairs. Each item is an |
| 74 * object of { regexp_pattern: replace_string }. |
| 75 * @return {String} |
| 76 */ |
| 77 runRegExpSwapSync: (targetString, regExpSwapCollection) => { |
| 78 let tempString = targetString; |
| 79 regExpSwapCollection.forEach((item) => { |
| 80 let re = new RegExp(item.regexp, 'g'); |
| 81 tempString = tempString.replace(re, item.replace); |
| 82 }); |
| 83 |
| 84 return tempString; |
| 85 }, |
| 86 |
| 87 /** |
| 88 * Run HTMLTidy on input string with options. |
| 89 * @param {String} pageString [description] |
| 90 * @param {Object} options HTMLTidy option as key-value pair. |
| 91 * @param {Task} task Associated Task object. |
| 92 * @return {String} |
| 93 */ |
| 94 runHTMLTidySync: (pageString, options, task) => { |
| 95 let tidyDoc = new libtidy.TidyDoc(); |
| 96 for (let option in options) |
| 97 tidyDoc.optSet(option, options[option]); |
| 98 |
| 99 // This actually process the data inside of |tidyDoc|. |
| 100 let logs = ''; |
| 101 logs += tidyDoc.parseBufferSync(Buffer(pageString)); |
| 102 logs += tidyDoc.cleanAndRepairSync(); |
| 103 logs += tidyDoc.runDiagnosticsSync(); |
| 104 |
| 105 task.addLog('Module.runHTMLTidySync', logs.split('\n')); |
| 106 |
| 107 return tidyDoc.saveBufferSync().toString(); |
| 108 }, |
| 109 |
| 110 /** |
| 111 * Run clang-format and return a promise. |
| 112 * @param {String} codeString JS code to apply clang-format. |
| 113 * @param {Array} clangFormatOption options array for clang-format. |
| 114 * @param {Number} indentLevel Code indentation level. |
| 115 * @param {Task} task Associated Task object. |
| 116 * @return {Promise} Processed code as string. |
| 117 * @resolve {String} clang-formatted JS code as string. |
| 118 * @reject {Error} |
| 119 */ |
| 120 runClangFormat: (codeString, clangFormatOption, indentLevel, task) => { |
| 121 let clangFormatBinary = __dirname + '/node_modules/clang-format/bin/'; |
| 122 clangFormatBinary += (os.platform() === 'win32') ? |
| 123 'win32/clang-format.exe' : |
| 124 os.platform() + '_' + os.arch() + '/clang-format'; |
| 125 |
| 126 if (indentLevel > 0) { |
| 127 codeString = |
| 128 '{'.repeat(indentLevel) + codeString + '}'.repeat(indentLevel); |
| 129 } |
| 130 |
| 131 return new Promise((resolve, reject) => { |
| 132 // Be sure to pipe the result to the child process, not to this process's |
| 133 // stdout. |
| 134 let result = ''; |
| 135 let clangFormat = cp.spawn( |
| 136 clangFormatBinary, clangFormatOption, |
| 137 {stdio: ['pipe', 'pipe', process.stderr]}); |
| 138 |
| 139 // Capture the data when it's arrived at the pipe. |
| 140 clangFormat.stdout.on('data', (data) => { |
| 141 result += data; |
| 142 }); |
| 143 |
| 144 // For debug purpose: |
| 145 // clangFormat.stdout.pipe(process.stdout); |
| 146 |
| 147 clangFormat.stdout.on('close', (exitCode) => { |
| 148 if (exitCode) { |
| 149 Util.logAndExit('Module.runClangFormat', 'exit code = 1'); |
| 150 } else { |
| 151 task.addLog('Module.runClangFormat', 'clang-format was successful.'); |
| 152 |
| 153 // Remove shim braces for indentation hack. |
| 154 if (indentLevel > 0) { |
| 155 let codeStart = 0; |
| 156 let codeEnd = result.length - 1; |
| 157 for (let i = 0; i < indentLevel; ++i) { |
| 158 codeStart = result.indexOf('\n', codeStart + 1); |
| 159 codeEnd = result.lastIndexOf('\n', codeEnd - 1); |
| 160 } |
| 161 result = result.substring(codeStart + 1, codeEnd); |
| 162 } |
| 163 |
| 164 resolve(result); |
| 165 } |
| 166 }); |
| 167 |
| 168 clangFormat.stdin.setEncoding('utf-8'); |
| 169 clangFormat.stdin.write(codeString); |
| 170 clangFormat.stdin.end(); |
| 171 }); |
| 172 }, |
| 173 |
| 174 /** |
| 175 * Detect line overflow and record the line number to the task log. |
| 176 * @param {String} pageOrCodeString HTML page or JS code data in string. |
| 177 * @param {TidyTask} task Associated TidyTask object. |
| 178 */ |
| 179 detectLineOverflow: (pageOrCodeString, task) => { |
| 180 let currentLineNumber = 0; |
| 181 let index0 = 0; |
| 182 let index1 = 0; |
| 183 while (index0 < pageOrCodeString.length - 1) { |
| 184 index1 = pageOrCodeString.indexOf('\n', index0); |
| 185 if (index1 - index0 > 80) { |
| 186 task.addLog( |
| 187 'Module.detectLineOverflow', |
| 188 'Overflow (> 80 cols.) at line ' + currentLineNumber + '.'); |
| 189 } |
| 190 currentLineNumber++; |
| 191 index0 = index1 + 1; |
| 192 } |
| 193 } |
| 194 |
| 195 }; |
| 196 |
| 197 |
| 198 /** |
| 199 * DOM utilities. Process DOM processing after parsing the string by JSDOM. |
| 200 */ |
| 201 const DOMUtil = { |
| 202 |
| 203 /** |
| 204 * Parse string, generate JSDOM object and return |document| element. |
| 205 * @param {String} pageString An HTML page in string. |
| 206 * @return {Document} A |document| object. |
| 207 */ |
| 208 getJSDOMFromStringSync: (pageString) => { |
| 209 return new JSDOM(`${pageString}`); |
| 210 // return jsdom_.window.document; |
| 211 }, |
| 212 |
| 213 /** |
| 214 * In-place tidy up head element. |
| 215 * @param {Document} document A |document| object. |
| 216 * @param {Task} task An associated Task object. |
| 217 * @return {Void} |
| 218 */ |
| 219 tidyHeadElementSync: (document, task) => { |
| 220 try { |
| 221 // If the title is missing, add one from the file name. |
| 222 let titleElement = document.querySelector('title'); |
| 223 if (!titleElement) { |
| 224 titleElement = document.createElement('title'); |
| 225 titleElement.textContent = path.basename(task.targetFilePath_); |
| 226 task.addLog( |
| 227 'DOMUtil.tidyHeadElementSync', |
| 228 'Title element was missing thus a new one was added.'); |
| 229 } |
| 230 |
| 231 // The title element should be the first. |
| 232 let headElement = document.querySelector('head'); |
| 233 headElement.insertBefore(titleElement, headElement.firstChild); |
| 234 |
| 235 // If a script element in body does not have JS code, move to the head |
| 236 // section. |
| 237 let scriptElementsInBody = document.body.querySelectorAll('script'); |
| 238 scriptElementsInBody.forEach((scriptElement) => { |
| 239 if (!scriptElement.textContent) |
| 240 headElement.appendChild(scriptElement); |
| 241 }); |
| 242 } catch (error) { |
| 243 task.addLog('DOMUtil.tidyHeadElementSync', error.toString()); |
| 244 } |
| 245 }, |
| 246 |
| 247 /** |
| 248 * Sanitize and extract |script| element with JS test code. |
| 249 * @param {Document} document A |document| object. |
| 250 * @param {Task} task An associated Task object. |
| 251 * @return {ScriptElement} |
| 252 */ |
| 253 getElementWithTestCodeSync: (document, task) => { |
| 254 let numberOfScriptElementsWithCode = 0; |
| 255 let scriptElementWithTestCode; |
| 256 let scriptElements = document.querySelectorAll('script'); |
| 257 |
| 258 scriptElements.forEach((scriptElement) => { |
| 259 // We don't want type attribute. |
| 260 scriptElement.removeAttribute('type'); |
| 261 |
| 262 if (scriptElement.textContent.length > 0) { |
| 263 ++numberOfScriptElementsWithCode; |
| 264 scriptElementWithTestCode = scriptElement; |
| 265 scriptElement.id = 'layout-test-code'; |
| 266 // If the element belongs to something else other than body, move it to |
| 267 // the body. This fixes script elements that are located in weird |
| 268 // positions. (e.g outside of body or head) |
| 269 if (scriptElement.parentElement !== document.body) |
| 270 document.body.appendChild(scriptElement); |
| 271 } |
| 272 }); |
| 273 |
| 274 if (numberOfScriptElementsWithCode !== 1) { |
| 275 task.addLog( |
| 276 'DOMUtil.getElementWithTestCodeSync', |
| 277 numberOfScriptElementsWithCode + ' <script> element(s) with JS ' + |
| 278 'code were found.'); |
| 279 scriptElementWithTestCode = null; |
| 280 } |
| 281 |
| 282 return scriptElementWithTestCode; |
| 283 } |
| 284 |
| 285 }; |
| 286 |
| 287 |
| 288 /** |
| 289 * @class TidyTask |
| 290 * @description Per-file processing task. This object should be constructed |
| 291 * directly. The task runner creates this when it is necessary. |
| 292 */ |
| 293 class TidyTask { |
| 294 /** |
| 295 * @param {String} targetFilePath A path to file to be processed. |
| 296 * @param {Object} options Task options. |
| 297 * @param {Boolean} options.inplace |true| for in-place processing directly |
| 298 * writing into the target file. By default, |
| 299 * this is |false| and the result is piped |
| 300 * into the stdout. |
| 301 * @param {Boolean} options.verbose Prints out warnings and logs from the |
| 302 * process when |true|. |false| by default. |
| 303 */ |
| 304 constructor(targetFilePath, options) { |
| 305 this.targetFilePath_ = targetFilePath; |
| 306 this.options_ = options; |
| 307 |
| 308 this.fileType_ = path.extname(this.targetFilePath_); |
| 309 this.pageString_ = Util.loadFileToStringSync(this.targetFilePath_); |
| 310 this.jsdom_ = null; |
| 311 this.logs_ = {}; |
| 312 } |
| 313 |
| 314 /** |
| 315 * Run processing sequence. Don't call this directly. |
| 316 * @param {Function} taskDone Task runner callback function. |
| 317 */ |
| 318 run(taskDone) { |
| 319 switch (this.fileType_) { |
| 320 case '.html': |
| 321 this.processHTML_(taskDone); |
| 322 break; |
| 323 case '.js': |
| 324 this.processJS_(taskDone); |
| 325 break; |
| 326 default: |
| 327 Util.logAndExit( |
| 328 'TidyTask.constructor', 'Invalid file type: ' + this.fileType_); |
| 329 break; |
| 330 } |
| 331 } |
| 332 |
| 333 /** |
| 334 * Process HTML file. The processing performs the following in order: |
| 335 * - DOM parsing to sanitize invalid/incorrect markup structure. |
| 336 * - Extract JS code, apply clang-format and inject the code to element. |
| 337 * - Apply HTMLTidy to the markup. |
| 338 * - RegExp substitution. |
| 339 * - Detect any line overflows 80 columns. |
| 340 * @param {Function} taskDone completion callback. |
| 341 */ |
| 342 processHTML_(taskDone) { |
| 343 // Parse page string into JSDOM.element object. |
| 344 this.jsdom_ = DOMUtil.getJSDOMFromStringSync(this.pageString_); |
| 345 |
| 346 // Clean up the head element section. |
| 347 DOMUtil.tidyHeadElementSync(this.jsdom_.window.document, this); |
| 348 |
| 349 let scriptElement = |
| 350 DOMUtil.getElementWithTestCodeSync(this.jsdom_.window.document, this); |
| 351 |
| 352 if (!scriptElement) |
| 353 Util.logAndExit('TidyTask.processHTML_', 'Invalid <script> element.'); |
| 354 |
| 355 // Start with clang-foramt, then HTMLTidy and RegExp substitution. |
| 356 Module.runClangFormat( |
| 357 scriptElement.textContent, OPTIONS.ClangFormat, 3, this) |
| 358 .then((formattedCodeString) => { |
| 359 // Replace the original code with clang-formatted code. |
| 360 scriptElement.textContent = formattedCodeString; |
| 361 |
| 362 // Then tidy the text data from JSDOM. After this point, DOM |
| 363 // manipulation is not possible anymore. |
| 364 let pageString = this.jsdom_.serialize(); |
| 365 pageString = |
| 366 Module.runHTMLTidySync(pageString, OPTIONS.HTMLTidy, this); |
| 367 pageString = Module.runRegExpSwapSync( |
| 368 pageString, OPTIONS.RegExpSwapCollection); |
| 369 |
| 370 // Detect any line goes over column 80. |
| 371 Module.detectLineOverflow(pageString, this); |
| 372 |
| 373 this.finish_(pageString, taskDone); |
| 374 }); |
| 375 } |
| 376 |
| 377 /** |
| 378 * Process JS file. The processing performs the following in order: |
| 379 * - Extract JS code, apply clang-format and inject the code to element. |
| 380 * - RegExp substitution. |
| 381 * - Detect any line overflows 80 columns. |
| 382 * @param {Function} taskDone completion callback. |
| 383 */ |
| 384 processJS_(taskDone) { |
| 385 // The file is a JS code: run clang-format, RegExp substitution and check |
| 386 // for overflowed lines. |
| 387 Module.runClangFormat(this.pageString_, OPTIONS.ClangFormat, 0, this) |
| 388 .then((formattedCodeString) => { |
| 389 formattedCodeString = Module.runRegExpSwapSync( |
| 390 formattedCodeString, [OPTIONS.RegExpSwapCollection[0]]); |
| 391 Module.detectLineOverflow(formattedCodeString, this); |
| 392 this.finish_(formattedCodeString, taskDone); |
| 393 }); |
| 394 } |
| 395 |
| 396 finish_(resultString, taskDone) { |
| 397 if (this.options_.inplace) { |
| 398 Util.writeStringToFileSync(resultString, this.targetFilePath_); |
| 399 } else { |
| 400 process.stdout.write(resultString); |
| 401 } |
| 402 |
| 403 this.printLog(); |
| 404 taskDone(); |
| 405 } |
| 406 |
| 407 /** |
| 408 * Adding log message. |
| 409 * @param {String} location Caller information. |
| 410 * @param {String} message Log message. |
| 411 */ |
| 412 addLog(location, message) { |
| 413 if (!this.logs_.hasOwnProperty(location)) |
| 414 this.logs_[location] = []; |
| 415 this.logs_[location].push(message); |
| 416 } |
| 417 |
| 418 /** |
| 419 * Print log messages at the end of task. |
| 420 */ |
| 421 printLog() { |
| 422 if (!this.options_.verbose) |
| 423 return; |
| 424 |
| 425 console.warn('> Logs from: ' + this.targetFilePath_); |
| 426 for (let location in this.logs_) { |
| 427 console.warn(' [] ' + location); |
| 428 this.logs_[location].forEach((message) => { |
| 429 if (Array.isArray(message)) { |
| 430 message.forEach((subMessage) => { |
| 431 if (subMessage.length > 0) |
| 432 console.warn(' - ' + subMessage); |
| 433 }); |
| 434 } else { |
| 435 console.warn(' - ' + message); |
| 436 } |
| 437 }); |
| 438 } |
| 439 } |
| 440 } |
| 441 |
| 442 |
| 443 /** |
| 444 * @class TidyTaskRunner |
| 445 */ |
| 446 class TidyTaskRunner { |
| 447 /** |
| 448 * @param {Array} files A list of file paths. |
| 449 * @param {Object} options Task options. |
| 450 * @param {Boolean} options.inplace |true| for in-place processing directly |
| 451 * writing into the target file. By default, |
| 452 * this is |false| and the result is piped |
| 453 * into the stdout. |
| 454 * @param {Boolean} options.verbose Prints out warnings and logs from the |
| 455 * process when |true|. |false| by default. |
| 456 * @return {TidyTaskRunner} A task runner object. |
| 457 */ |
| 458 constructor(files, options) { |
| 459 this.targetFiles_ = files; |
| 460 this.options_ = options; |
| 461 this.tasks_ = []; |
| 462 this.currentTask_ = 0; |
| 463 } |
| 464 |
| 465 startProcessing() { |
| 466 this.targetFiles_.forEach((filePath) => { |
| 467 this.tasks_.push(new TidyTask(filePath, this.options_)); |
| 468 }); |
| 469 this.log_('Task runner started: ' + this.targetFiles_.length + ' file(s).'); |
| 470 this.runTask_(); |
| 471 } |
| 472 |
| 473 runTask_() { |
| 474 this.log_( |
| 475 'Running task #' + (this.currentTask_ + 1) + ': ' + |
| 476 this.targetFiles_[this.currentTask_] + |
| 477 (this.options_.inplace ? ' (IN-PLACE)' : '')); |
| 478 this.tasks_[this.currentTask_].run(this.done_.bind(this)); |
| 479 } |
| 480 |
| 481 done_() { |
| 482 this.log_('Task #' + (this.currentTask_ + 1) + ' completed.'); |
| 483 this.currentTask_++; |
| 484 if (this.currentTask_ < this.tasks_.length) { |
| 485 this.runTask_(); |
| 486 } else { |
| 487 this.log_( |
| 488 'Task runner completed: ' + this.targetFiles_.length + |
| 489 ' file(s) processed.'); |
| 490 } |
| 491 } |
| 492 |
| 493 log_(message) { |
| 494 if (this.options_.verbose) |
| 495 console.warn('[layout-test-tidy] ' + message); |
| 496 } |
| 497 } |
| 498 |
| 499 |
| 500 // Entry point. |
| 501 function main() { |
| 502 let args = process.argv.slice(2); |
| 503 |
| 504 // Extract options from the arguments. |
| 505 let optionArgs = args.filter((arg, index) => { |
| 506 if (arg.startsWith('-') || arg.startsWith('--')) { |
| 507 args[index] = null; |
| 508 return true; |
| 509 } |
| 510 }); |
| 511 |
| 512 args = args.filter(arg => arg); |
| 513 |
| 514 // A single target (a file or a directory) is allowed. |
| 515 if (args.length !== 1) { |
| 516 Util.logAndExit('main', 'Please specify a single target. (' + args + ')'); |
| 517 } |
| 518 |
| 519 // Populate options flags. |
| 520 let options = { |
| 521 inPlace: optionArgs.includes('-i') || optionArgs.includes('--in-place'), |
| 522 verbose: optionArgs.includes('-v') || optionArgs.includes('--verbose') |
| 523 }; |
| 524 |
| 525 // Collect target file(s) from the file system. |
| 526 let targetPath = args[0]; |
| 527 let files = []; |
| 528 if (targetPath) { |
| 529 try { |
| 530 let stat = fs.lstatSync(targetPath); |
| 531 if (stat.isFile()) { |
| 532 files.push(targetPath); |
| 533 } else if (stat.isDirectory()) { |
| 534 files = glob.sync( |
| 535 targetPath + '/**/*.{html,js}', {ignore: ['**/node_modules/**/*']}); |
| 536 } |
| 537 } catch (error) { |
| 538 let errorMessage = 'Invalid file path. (' + targetPath + ')\n' + |
| 539 ' > ' + error.toString(); |
| 540 Util.logAndExit('main', errorMessage); |
| 541 } |
| 542 } |
| 543 |
| 544 // Files to be skipped. |
| 545 let filesToBeSkipped = |
| 546 Util.loadFileToStringSync('skip-tidy').toString().split('\n'); |
| 547 filesToBeSkipped.forEach((fileSkipped) => { |
| 548 let index = files.indexOf(fileSkipped); |
| 549 if (index > -1) { |
| 550 files.splice(index, 1); |
| 551 } |
| 552 }); |
| 553 |
| 554 if (files.length > 0) { |
| 555 let taskRunner = new TidyTaskRunner(files, options); |
| 556 taskRunner.startProcessing(); |
| 557 } else { |
| 558 Util.logAndExit('main', 'No files to process.'); |
| 559 } |
| 560 } |
| 561 |
| 562 main(); |
| OLD | NEW |