| #!/usr/bin/env node |
| |
| // Copyright 2017 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| 'use strict'; |
| |
| const os = require('os'); |
| const fs = require('fs'); |
| const path = require('path'); |
| const glob = require('glob'); |
| const libtidy = require('libtidy'); |
| const cp = require('child_process'); |
| const jsdom = require('jsdom'); |
| const {JSDOM} = jsdom; |
| |
| |
| /** |
| * Options for sub modules. |
| */ |
| const OPTIONS = { |
| |
| HTMLTidy: { |
| 'indent': 'yes', |
| 'indent-spaces': '2', |
| 'wrap': '80', |
| 'tidy-mark': 'no', |
| 'doctype': 'html5' |
| }, |
| |
| ClangFormat: ['-style=Chromium', '-assume-filename=a.js'], |
| |
| // RegExp text swap collection (ordered key-value pair) for post-processing. |
| RegExpSwapCollection: [ |
| // Replace |var| with |let|. |
| {regexp: /(\n\s{2,}|\()var /, replace: '$1let '}, |
| |
| // Move one line up the dangling closing script tags. |
| {regexp: /\>\n\s{2,}\<\/script\>\n/, replace: '></script>\n'}, |
| |
| // Remove all the empty lines in html. |
| {regexp: /\>\n{2,}/, replace: '>\n'} |
| ] |
| }; |
| |
| |
| /** |
| * Basic utilities. |
| */ |
| const Util = { |
| |
| logAndExit: (moduleName, messageString) => { |
| console.error('[layout-test-tidy::' + moduleName + '] ' + messageString); |
| process.exit(1); |
| }, |
| |
| loadFileToStringSync: (filePath) => { |
| return fs.readFileSync(filePath, 'utf8').toString(); |
| }, |
| |
| writeStringToFileSync: (pageString, filePath) => { |
| fs.writeFileSync(filePath, pageString); |
| } |
| |
| }; |
| |
| |
| /** |
| * Wrapper for external modules like HTMLTidy and clang format. |
| * @type {Object} |
| */ |
| const Module = { |
| |
| /** |
| * Perform a batch RegExp string substitution. |
| * @param {String} targetString Target string. |
| * @param {Array} swapCollection Array of key-value pairs. Each item is an |
| * object of { regexp_pattern: replace_string }. |
| * @return {String} |
| */ |
| runRegExpSwapSync: (targetString, regExpSwapCollection) => { |
| let tempString = targetString; |
| regExpSwapCollection.forEach((item) => { |
| let re = new RegExp(item.regexp, 'g'); |
| tempString = tempString.replace(re, item.replace); |
| }); |
| |
| return tempString; |
| }, |
| |
| /** |
| * Run HTMLTidy on input string with options. |
| * @param {String} pageString [description] |
| * @param {Object} options HTMLTidy option as key-value pair. |
| * @param {Task} task Associated Task object. |
| * @return {String} |
| */ |
| runHTMLTidySync: (pageString, options, task) => { |
| let tidyDoc = new libtidy.TidyDoc(); |
| for (let option in options) |
| tidyDoc.optSet(option, options[option]); |
| |
| // This actually process the data inside of |tidyDoc|. |
| let logs = ''; |
| logs += tidyDoc.parseBufferSync(Buffer(pageString)); |
| logs += tidyDoc.cleanAndRepairSync(); |
| logs += tidyDoc.runDiagnosticsSync(); |
| |
| task.addLog('Module.runHTMLTidySync', logs.split('\n')); |
| |
| return tidyDoc.saveBufferSync().toString(); |
| }, |
| |
| /** |
| * Run clang-format and return a promise. |
| * @param {String} codeString JS code to apply clang-format. |
| * @param {Array} clangFormatOption options array for clang-format. |
| * @param {Number} indentLevel Code indentation level. |
| * @param {Task} task Associated Task object. |
| * @return {Promise} Processed code as string. |
| * @resolve {String} clang-formatted JS code as string. |
| * @reject {Error} |
| */ |
| runClangFormat: (codeString, clangFormatOption, indentLevel, task) => { |
| let clangFormatBinary = __dirname + '/node_modules/clang-format/bin/'; |
| clangFormatBinary += (os.platform() === 'win32') ? |
| 'win32/clang-format.exe' : |
| os.platform() + '_' + os.arch() + '/clang-format'; |
| |
| if (indentLevel > 0) { |
| codeString = |
| '{'.repeat(indentLevel) + codeString + '}'.repeat(indentLevel); |
| } |
| |
| return new Promise((resolve, reject) => { |
| // Be sure to pipe the result to the child process, not to this process's |
| // stdout. |
| let result = ''; |
| let clangFormat = cp.spawn( |
| clangFormatBinary, clangFormatOption, |
| {stdio: ['pipe', 'pipe', process.stderr]}); |
| |
| // Capture the data when it's arrived at the pipe. |
| clangFormat.stdout.on('data', (data) => { |
| result += data; |
| }); |
| |
| // For debug purpose: |
| // clangFormat.stdout.pipe(process.stdout); |
| |
| clangFormat.stdout.on('close', (exitCode) => { |
| if (exitCode) { |
| Util.logAndExit('Module.runClangFormat', 'exit code = 1'); |
| } else { |
| task.addLog('Module.runClangFormat', 'clang-format was successful.'); |
| |
| // Remove shim braces for indentation hack. |
| if (indentLevel > 0) { |
| let codeStart = 0; |
| let codeEnd = result.length - 1; |
| for (let i = 0; i < indentLevel; ++i) { |
| codeStart = result.indexOf('\n', codeStart + 1); |
| codeEnd = result.lastIndexOf('\n', codeEnd - 1); |
| } |
| result = result.substring(codeStart + 1, codeEnd); |
| } |
| |
| resolve(result); |
| } |
| }); |
| |
| clangFormat.stdin.setEncoding('utf-8'); |
| clangFormat.stdin.write(codeString); |
| clangFormat.stdin.end(); |
| }); |
| }, |
| |
| /** |
| * Detect line overflow and record the line number to the task log. |
| * @param {String} pageOrCodeString HTML page or JS code data in string. |
| * @param {TidyTask} task Associated TidyTask object. |
| */ |
| detectLineOverflow: (pageOrCodeString, task) => { |
| let currentLineNumber = 0; |
| let index0 = 0; |
| let index1 = 0; |
| while (index0 < pageOrCodeString.length - 1) { |
| index1 = pageOrCodeString.indexOf('\n', index0); |
| if (index1 - index0 > 80) { |
| task.addLog( |
| 'Module.detectLineOverflow', |
| 'Overflow (> 80 cols.) at line ' + currentLineNumber + '.'); |
| } |
| currentLineNumber++; |
| index0 = index1 + 1; |
| } |
| } |
| |
| }; |
| |
| |
| /** |
| * DOM utilities. Process DOM processing after parsing the string by JSDOM. |
| */ |
| const DOMUtil = { |
| |
| /** |
| * Parse string, generate JSDOM object and return |document| element. |
| * @param {String} pageString An HTML page in string. |
| * @return {Document} A |document| object. |
| */ |
| getJSDOMFromStringSync: (pageString) => { |
| return new JSDOM(`${pageString}`); |
| // return jsdom_.window.document; |
| }, |
| |
| /** |
| * In-place tidy up head element. |
| * @param {Document} document A |document| object. |
| * @param {Task} task An associated Task object. |
| * @return {Void} |
| */ |
| tidyHeadElementSync: (document, task) => { |
| try { |
| // If the title is missing, add one from the file name. |
| let titleElement = document.querySelector('title'); |
| if (!titleElement) { |
| titleElement = document.createElement('title'); |
| titleElement.textContent = path.basename(task.targetFilePath_); |
| task.addLog( |
| 'DOMUtil.tidyHeadElementSync', |
| 'Title element was missing thus a new one was added.'); |
| } |
| |
| // The title element should be the first. |
| let headElement = document.querySelector('head'); |
| headElement.insertBefore(titleElement, headElement.firstChild); |
| |
| // If a script element in body does not have JS code, move to the head |
| // section. |
| let scriptElementsInBody = document.body.querySelectorAll('script'); |
| scriptElementsInBody.forEach((scriptElement) => { |
| if (!scriptElement.textContent) |
| headElement.appendChild(scriptElement); |
| }); |
| } catch (error) { |
| task.addLog('DOMUtil.tidyHeadElementSync', error.toString()); |
| } |
| }, |
| |
| /** |
| * Sanitize and extract |script| element with JS test code. |
| * @param {Document} document A |document| object. |
| * @param {Task} task An associated Task object. |
| * @return {ScriptElement} |
| */ |
| getElementWithTestCodeSync: (document, task) => { |
| let numberOfScriptElementsWithCode = 0; |
| let scriptElementWithTestCode; |
| let scriptElements = document.querySelectorAll('script'); |
| |
| scriptElements.forEach((scriptElement) => { |
| // We don't want type attribute. |
| scriptElement.removeAttribute('type'); |
| |
| if (scriptElement.textContent.length > 0) { |
| ++numberOfScriptElementsWithCode; |
| scriptElementWithTestCode = scriptElement; |
| scriptElement.id = 'layout-test-code'; |
| // If the element belongs to something else other than body, move it to |
| // the body. This fixes script elements that are located in weird |
| // positions. (e.g outside of body or head) |
| if (scriptElement.parentElement !== document.body) |
| document.body.appendChild(scriptElement); |
| } |
| }); |
| |
| if (numberOfScriptElementsWithCode !== 1) { |
| task.addLog( |
| 'DOMUtil.getElementWithTestCodeSync', |
| numberOfScriptElementsWithCode + ' <script> element(s) with JS ' + |
| 'code were found.'); |
| scriptElementWithTestCode = null; |
| } |
| |
| return scriptElementWithTestCode; |
| } |
| |
| }; |
| |
| |
| /** |
| * @class TidyTask |
| * @description Per-file processing task. This object should be constructed |
| * directly. The task runner creates this when it is necessary. |
| */ |
| class TidyTask { |
| /** |
| * @param {String} targetFilePath A path to file to be processed. |
| * @param {Object} options Task options. |
| * @param {Boolean} options.inplace |true| for in-place processing directly |
| * writing into the target file. By default, |
| * this is |false| and the result is piped |
| * into the stdout. |
| * @param {Boolean} options.verbose Prints out warnings and logs from the |
| * process when |true|. |false| by default. |
| */ |
| constructor(targetFilePath, options) { |
| this.targetFilePath_ = targetFilePath; |
| this.options_ = options; |
| |
| this.fileType_ = path.extname(this.targetFilePath_); |
| this.pageString_ = Util.loadFileToStringSync(this.targetFilePath_); |
| this.jsdom_ = null; |
| this.logs_ = {}; |
| } |
| |
| /** |
| * Run processing sequence. Don't call this directly. |
| * @param {Function} taskDone Task runner callback function. |
| */ |
| run(taskDone) { |
| switch (this.fileType_) { |
| case '.html': |
| this.processHTML_(taskDone); |
| break; |
| case '.js': |
| this.processJS_(taskDone); |
| break; |
| default: |
| Util.logAndExit( |
| 'TidyTask.constructor', 'Invalid file type: ' + this.fileType_); |
| break; |
| } |
| } |
| |
| /** |
| * Process HTML file. The processing performs the following in order: |
| * - DOM parsing to sanitize invalid/incorrect markup structure. |
| * - Extract JS code, apply clang-format and inject the code to element. |
| * - Apply HTMLTidy to the markup. |
| * - RegExp substitution. |
| * - Detect any line overflows 80 columns. |
| * @param {Function} taskDone completion callback. |
| */ |
| processHTML_(taskDone) { |
| // Parse page string into JSDOM.element object. |
| this.jsdom_ = DOMUtil.getJSDOMFromStringSync(this.pageString_); |
| |
| // Clean up the head element section. |
| DOMUtil.tidyHeadElementSync(this.jsdom_.window.document, this); |
| |
| let scriptElement = |
| DOMUtil.getElementWithTestCodeSync(this.jsdom_.window.document, this); |
| |
| if (!scriptElement) |
| Util.logAndExit('TidyTask.processHTML_', 'Invalid <script> element.'); |
| |
| // Start with clang-foramt, then HTMLTidy and RegExp substitution. |
| Module |
| .runClangFormat(scriptElement.textContent, OPTIONS.ClangFormat, 3, this) |
| .then((formattedCodeString) => { |
| // Replace the original code with clang-formatted code. |
| scriptElement.textContent = formattedCodeString; |
| |
| // Then tidy the text data from JSDOM. After this point, DOM |
| // manipulation is not possible anymore. |
| let pageString = this.jsdom_.serialize(); |
| pageString = |
| Module.runHTMLTidySync(pageString, OPTIONS.HTMLTidy, this); |
| pageString = Module.runRegExpSwapSync( |
| pageString, OPTIONS.RegExpSwapCollection); |
| |
| // Detect any line goes over column 80. |
| Module.detectLineOverflow(pageString, this); |
| |
| this.finish_(pageString, taskDone); |
| }); |
| } |
| |
| /** |
| * Process JS file. The processing performs the following in order: |
| * - Extract JS code, apply clang-format and inject the code to element. |
| * - RegExp substitution. |
| * - Detect any line overflows 80 columns. |
| * @param {Function} taskDone completion callback. |
| */ |
| processJS_(taskDone) { |
| // The file is a JS code: run clang-format, RegExp substitution and check |
| // for overflowed lines. |
| Module.runClangFormat(this.pageString_, OPTIONS.ClangFormat, 0, this) |
| .then((formattedCodeString) => { |
| formattedCodeString = Module.runRegExpSwapSync( |
| formattedCodeString, [OPTIONS.RegExpSwapCollection[0]]); |
| Module.detectLineOverflow(formattedCodeString, this); |
| this.finish_(formattedCodeString, taskDone); |
| }); |
| } |
| |
| finish_(resultString, taskDone) { |
| if (this.options_.inplace) { |
| Util.writeStringToFileSync(resultString, this.targetFilePath_); |
| } else { |
| process.stdout.write(resultString); |
| } |
| |
| this.printLog(); |
| taskDone(); |
| } |
| |
| /** |
| * Adding log message. |
| * @param {String} location Caller information. |
| * @param {String} message Log message. |
| */ |
| addLog(location, message) { |
| if (!this.logs_.hasOwnProperty(location)) |
| this.logs_[location] = []; |
| this.logs_[location].push(message); |
| } |
| |
| /** |
| * Print log messages at the end of task. |
| */ |
| printLog() { |
| if (!this.options_.verbose) |
| return; |
| |
| console.warn('> Logs from: ' + this.targetFilePath_); |
| for (let location in this.logs_) { |
| console.warn(' [] ' + location); |
| this.logs_[location].forEach((message) => { |
| if (Array.isArray(message)) { |
| message.forEach((subMessage) => { |
| if (subMessage.length > 0) |
| console.warn(' - ' + subMessage); |
| }); |
| } else { |
| console.warn(' - ' + message); |
| } |
| }); |
| } |
| } |
| } |
| |
| |
| /** |
| * @class TidyTaskRunner |
| */ |
| class TidyTaskRunner { |
| /** |
| * @param {Array} files A list of file paths. |
| * @param {Object} options Task options. |
| * @param {Boolean} options.inplace |true| for in-place processing directly |
| * writing into the target file. By default, |
| * this is |false| and the result is piped |
| * into the stdout. |
| * @param {Boolean} options.verbose Prints out warnings and logs from the |
| * process when |true|. |false| by default. |
| * @return {TidyTaskRunner} A task runner object. |
| */ |
| constructor(files, options) { |
| this.targetFiles_ = files; |
| this.options_ = options; |
| this.tasks_ = []; |
| this.currentTask_ = 0; |
| } |
| |
| startProcessing() { |
| this.targetFiles_.forEach((filePath) => { |
| this.tasks_.push(new TidyTask(filePath, this.options_)); |
| }); |
| this.log_('Task runner started: ' + this.targetFiles_.length + ' file(s).'); |
| this.runTask_(); |
| } |
| |
| runTask_() { |
| this.log_( |
| 'Running task #' + (this.currentTask_ + 1) + ': ' + |
| this.targetFiles_[this.currentTask_] + |
| (this.options_.inplace ? ' (IN-PLACE)' : '')); |
| this.tasks_[this.currentTask_].run(this.done_.bind(this)); |
| } |
| |
| done_() { |
| this.log_('Task #' + (this.currentTask_ + 1) + ' completed.'); |
| this.currentTask_++; |
| if (this.currentTask_ < this.tasks_.length) { |
| this.runTask_(); |
| } else { |
| this.log_( |
| 'Task runner completed: ' + this.targetFiles_.length + |
| ' file(s) processed.'); |
| } |
| } |
| |
| log_(message) { |
| if (this.options_.verbose) |
| console.warn('[layout-test-tidy] ' + message); |
| } |
| } |
| |
| |
| // Entry point. |
| function main() { |
| let args = process.argv.slice(2); |
| |
| // Extract options from the arguments. |
| let optionArgs = args.filter((arg, index) => { |
| if (arg.startsWith('-') || arg.startsWith('--')) { |
| args[index] = null; |
| return true; |
| } |
| }); |
| |
| args = args.filter(arg => arg); |
| |
| // Populate options flags. |
| let options = { |
| inplace: optionArgs.includes('-i') || optionArgs.includes('--inplace'), |
| recursive: optionArgs.includes('-R') || optionArgs.includes('--recursive'), |
| verbose: optionArgs.includes('-v') || optionArgs.includes('--verbose'), |
| }; |
| |
| // Collect target file(s) from the file system. |
| let files = []; |
| args.forEach((targetPath) => { |
| try { |
| let stat = fs.lstatSync(targetPath); |
| if (stat.isFile()) { |
| let fileType = path.extname(targetPath); |
| if (fileType === '.html' || fileType === '.js') { |
| files.push(targetPath); |
| } |
| } else if ( |
| stat.isDirectory() && options.recursive && |
| !targetPath.includes('node_modules')) { |
| files = files.concat(glob.sync(targetPath + '/**/*.{html,js}')); |
| } |
| } catch (error) { |
| let errorMessage = 'Invalid file path. (' + targetPath + ')\n' + |
| ' > ' + error.toString(); |
| Util.logAndExit('main', errorMessage); |
| } |
| }); |
| |
| if (files.length > 0) { |
| let taskRunner = new TidyTaskRunner(files, options); |
| taskRunner.startProcessing(); |
| } else { |
| Util.logAndExit('main', 'No files to process.'); |
| } |
| } |
| |
| main(); |