rocky-lint
Version:
Linter for Rocky.js projects
331 lines (264 loc) • 9.03 kB
JavaScript
;
const fs = require('fs');
const os = require('os');
const path = require('path');
const ts = require('typescript');
const util = require('util');
const colors = require('colors/safe');
const yargs = require('yargs')
.usage('Usage: $0 [options] -d rocky.d.ts file.js')
.describe('verbose', 'Increases verbosity of output')
.count('verbose')
.alias('v', 'verbose')
// eslint-disable-next-line max-len
.describe('definition', 'TypeScript definition file (.d.ts) to load \ncan be specified multiple times')
.alias('d', 'definition')
.demand('definition')
.describe('max-warnings', 'Number of warnings to trigger nonzero exit code')
.number('max-warnings')
.boolean('pretty')
.describe('pretty', 'Show line excerpts')
.boolean('no-color') // handled by `colors` implicitly
.describe('no-color', 'No ANSI colors')
.help('help')
.alias('h', 'help')
.demand(1, 'No filename provided.');
const argv = yargs.argv;
const VERBOSE_LEVEL = argv.verbose;
const categoryFormatMap = {};
categoryFormatMap[ts.DiagnosticCategory.Warning] = colors.yellow;
categoryFormatMap[ts.DiagnosticCategory.Error] = colors.red;
categoryFormatMap[ts.DiagnosticCategory.Messsage] = colors.blue;
function makeLogFilter(minLevel) {
return function() {
if (VERBOSE_LEVEL >= minLevel) {
console.error.apply(console, arguments);
}
};
}
const WARN = makeLogFilter(0);
const INFO = makeLogFilter(1);
const DEBUG = makeLogFilter(2);
(function checkNodeVersion() {
const semver = require('semver');
const currentVersion = process.versions.node;
const packageJSON = require('../package.json');
const requiredVersion = packageJSON.engines.node;
if (!semver.satisfies(currentVersion, requiredVersion)) {
WARN(`Node ${requiredVersion} is required, but ${currentVersion} is used.`);
process.exit(253);
}
})();
function repeatCharacter(character, times) {
let s = '';
for (let i = 0; i < times; i++) {
s += character;
}
return s;
}
function isFile(path) {
try {
const stats = fs.statSync(path);
return stats.isFile();
} catch (e) {
DEBUG(e);
return false;
}
}
function copySync(src, dest) {
const data = fs.readFileSync(src);
fs.writeFileSync(dest, data);
}
//
// Allows flow-like inline type comments.
//
// function foo(string: /*: any */) { ... }
function copyAndUncommentSync(src, dest) {
const data = fs.readFileSync(src).toString();
const regexp = /\/\*(\s*:\s+[a-zA-Z0-9._]+)\s+\*\//g;
const uncommentedData = data.replace(regexp, '$1');
fs.writeFileSync(dest, uncommentedData);
}
function extractLine(sourceFile, start) {
if (!sourceFile.lineMap) {
sourceFile.lineMap = ts.computeLineStarts(sourceFile.text);
}
const lineMap = sourceFile.lineMap;
const position = ts.computeLineAndCharacterOfPosition(lineMap, start);
const lineStart = ts.getPositionOfLineAndCharacter(sourceFile, position.line, 0);
const textFromLineStart = sourceFile.text.slice(lineStart);
const lineEnd = textFromLineStart.indexOf('\n');
const line = lineEnd >= 0 ?
textFromLineStart.slice(0, lineEnd) :
textFromLineStart;
// transform from zero-based numbers to commonly used one-based log messages
return {
excerpt: line,
line: position.line + 1,
character: position.character + 1,
};
}
function getArrayParam(paramName) {
const param = argv[paramName];
if (Array.isArray(param)) {
return param;
} else if (param) {
return [param];
} else {
return [];
}
}
function printAllDiagnostics(program, fileNameReplacements) {
const stats = { errors: 0, warnings: 0, any: 0 };
function printDiagnostic(categoryOverride, d) {
DEBUG(d);
if (d.code === 2339) { // Property '...' does not exist on type '...'
if (String(d.messageText).indexOf("does not exist on type '{") >= 0) {
DEBUG('Ignoring issue with object literal.');
return;
}
} else if (d.code === 2345) {
if (String(d.messageText).indexOf("to parameter of type 'never'") >= 0) {
DEBUG('Ignoring likely issue with array literal.');
return;
}
}
const sourceFile = d.file;
let locationString = 'global(0,0)';
if (sourceFile) {
const line = extractLine(sourceFile, d.start);
const fileName = fileNameReplacements[sourceFile.fileName] ||
sourceFile.fileName;
locationString = util.format('%s(%d,%d)', fileName, line.line, line.character);
const lineNumberString = String(line.line);
if (argv.pretty) {
console.log('%s %s', colors.bgGreen(lineNumberString), line.excerpt);
console.log(
'%s %s%s',
colors.bgGreen(repeatCharacter(' ', lineNumberString.length)),
repeatCharacter(' ', line.character),
colors.red(repeatCharacter('~', d.length))
);
console.log();
}
}
let node = typeof d.messageText === 'object' ? d.messageText : d;
let indent = '';
while (node) {
let messageText = node.messageText;
let category = typeof categoryOverride === 'undefined' ?
node.category :
categoryOverride;
if (messageText.indexOf('IsNotImplementedInRockyYet') >= 0) {
messageText = 'This API is not supported in Rocky.js yet.';
category = ts.DiagnosticCategory.Error;
}
if (category === ts.DiagnosticCategory.Error) {
stats.errors += 1;
} else if (category === ts.DiagnosticCategory.Warning) {
stats.warnings += 1;
}
stats.any += 1;
const formatter = categoryFormatMap[category] || colors.white;
const categoryString = formatter(
ts.DiagnosticCategory[category].toLowerCase()
);
console.log(
'%s: %s: %s%s',
colors.magenta(locationString),
`${categoryString} TS${node.code}`,
indent,
messageText
);
indent += ' > ';
node = node.next;
}
}
DEBUG('Printing options diagnostics...');
program.getOptionsDiagnostics()
.forEach(printDiagnostic.bind(this, undefined));
DEBUG('Printing global diagnostics...');
program.getGlobalDiagnostics()
.forEach(printDiagnostic.bind(this, undefined));
DEBUG('Printing syntactic diagnostics...');
program.getSyntacticDiagnostics()
.forEach(printDiagnostic.bind(this, ts.DiagnosticCategory.Error));
DEBUG('Printing semantic diagnostics...');
program.getSemanticDiagnostics()
.forEach(printDiagnostic.bind(this, ts.DiagnosticCategory.Warning));
return stats;
}
DEBUG(argv);
fs.mkdtemp(path.join(os.tmpdir(), 'rocky-lint-'), (err, dir) => {
if (err) {
WARN(err);
process.exit(255);
}
const inputFiles = argv._;
const definitionFiles = getArrayParam('definition');
const invalidFiles = inputFiles.concat(definitionFiles).filter((file) => {
return !isFile(file);
});
if (invalidFiles.length > 0) {
yargs.showHelp();
invalidFiles.forEach((file) => {
WARN("File does not exist (or isn't a file):", file);
});
process.exit(1);
}
const fileNameReplacements = {};
const copiedInputFiles = inputFiles.map((file, i) => {
const ext = path.extname(file);
const destinationName = `${path.basename(file, ext)}.ts`;
copyAndUncommentSync(file, path.join(dir, destinationName));
fileNameReplacements[destinationName] = file;
return destinationName;
});
const copiedDefinitions = definitionFiles.map((file, i) => {
const basename = path.basename(file);
const destinationName = `${i}_${basename}`;
// prefix with index to prevent collision
copySync(file, path.join(dir, destinationName));
return destinationName;
});
INFO('Working directory: %s', dir);
process.chdir(dir);
const compilerOptions = {
target: 'es5',
allowUnreachableCode: true,
allowUnusedLabels: true,
strictNullChecks: true,
lib: ['lib.es5.d.ts'],
};
const compilerHost = ts.createCompilerHost(compilerOptions);
const allFiles = copiedInputFiles.concat(copiedDefinitions);
DEBUG('Compiling files:', allFiles);
const program = ts.createProgram(allFiles, compilerOptions, compilerHost);
const stats = printAllDiagnostics(program, fileNameReplacements);
function printSummary() {
WARN();
WARN(
'Errors: %s, Warnings: %s',
colors.bold(stats.errors),
colors.bold(stats.warnings)
);
}
if (stats.errors > 0) {
printSummary();
WARN("Please fix the issues marked with '%s' above.", colors.red('error'));
process.exit(255);
} else if (stats.warnings > 0) {
printSummary();
WARN('There are some issues with your code.');
const maxWarnings = argv['max-warnings'];
if (typeof maxWarnings !== 'undefined' && stats.warnings > maxWarnings) {
WARN(`Too many warnings (max: ${maxWarnings}).`);
process.exit(254);
}
} else {
WARN('Everything looks %s!', colors.rainbow('AWESOME'));
}
if (!program.emit().emitSkipped) {
INFO('Transformed file has been emitted.');
}
});