#!/usr/bin/env node
// Copyright 2017 The Chromium Authors
// 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) => {
// Use 'global(g)' and 'multi-line(m)' options for RegExp processing.
let re = new RegExp(item.regexp, 'gm');
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();