polymer-cli
Version:
A commandline tool for Polymer projects
499 lines (459 loc) • 15.3 kB
text/typescript
/**
* @license
* Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at
* http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at
* http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at
* http://polymer.github.io/PATENTS.txt
*/
import chalk from 'chalk';
import * as chokidar from 'chokidar';
import * as fs from 'mz/fs';
import * as path from 'path';
import * as logging from 'plylog';
import {Analysis, Analyzer, applyEdits, Edit, EditAction, FsUrlLoader, makeParseLoader, ResolvedUrl, Severity, UrlResolver, Warning} from 'polymer-analyzer';
import {WarningFilter} from 'polymer-analyzer/lib/warning/warning-filter';
import {WarningPrinter} from 'polymer-analyzer/lib/warning/warning-printer';
import * as lintLib from 'polymer-linter';
import {ProjectConfig} from 'polymer-project-config';
import {CommandResult} from '../commands/command';
import {Options} from '../commands/lint';
import {getProjectSources, indent, prompt} from '../util';
const logger = logging.getLogger('cli.lint');
if (Symbol.asyncIterator === undefined) {
// tslint:disable-next-line: no-any polyfilling.
(Symbol as any).asyncIterator = Symbol('asyncIterator');
}
export async function lint(options: Options, config: ProjectConfig) {
const lintOptions: Partial<typeof config.lint> = (config.lint || {});
const ruleCodes = options.rules || lintOptions.rules;
if (ruleCodes === undefined) {
logger.warn(
`You must state which lint rules to use. You can use --rules, ` +
`but for a project it's best to use polymer.json. e.g.
{
"lint": {
"rules": ["polymer-2"]
}
}`);
return new CommandResult(1);
}
const rules = lintLib.registry.getRules(ruleCodes);
const {analyzer, urlLoader, urlResolver, warningFilter} =
await config.initializeAnalyzer();
const linter = new lintLib.Linter(rules, analyzer);
if (options.watch) {
return watchLoop(
analyzer,
urlLoader,
urlResolver,
linter,
options,
config,
warningFilter);
} else {
return run(
analyzer,
urlLoader,
urlResolver,
linter,
options,
config,
warningFilter);
}
}
interface PrivateOptions extends Options {
/**
* When running in --watch mode we want to report warnings if we're running
* with --fix but there weren't any warnings to fix.
*/
reportIfNoFix?: boolean;
}
/**
* Run a single pass of the linter, and then report the results or fix warnings
* as requested by `options`.
*
* In a normal run this is called once and then it's done. When running with
* `--watch` this function is called each time files on disk change.
*/
async function run(
analyzer: Analyzer,
urlLoader: FsUrlLoader,
urlResolver: UrlResolver,
linter: lintLib.Linter,
options: PrivateOptions,
config: ProjectConfig,
filter: WarningFilter,
editActionsToAlwaysApply = new Set(options.edits || []),
watcher?: FilesystemChangeStream) {
const sources = await getProjectSources(options, config);
const {warnings, analysis} = sources !== undefined ?
await linter.lint(sources) :
await linter.lintPackage();
const filtered = warnings.filter((w) => !filter.shouldIgnore(w));
if (options.fix) {
const changedFiles = await fix(
filtered,
options,
config,
analyzer,
analysis,
urlLoader,
urlResolver,
editActionsToAlwaysApply);
if (watcher) {
// Some file watcher interfaces won't notice this change immediately after
// the one that initiated this lint run. Ensure that we notice these
// changes.
for (const changedFile of changedFiles) {
watcher.ensureChangeIsNoticed(path.resolve(config.root, changedFile));
}
}
if (changedFiles.size === 0 && options.reportIfNoFix) {
await report(filtered, urlResolver);
}
} else {
return report(filtered, urlResolver);
}
}
async function watchLoop(
analyzer: Analyzer,
urlLoader: FsUrlLoader,
urlResolver: UrlResolver,
linter: lintLib.Linter,
options: Options,
config: ProjectConfig,
filter: WarningFilter) {
let analysis;
if (options.input) {
analysis = await analyzer.analyze(options.input);
} else {
analysis = await analyzer.analyzePackage();
}
/** Remember the user's preferences across runs. */
const lintActionsToAlwaysApply = new Set(options.edits || []);
const urls =
new Set([...analysis.getFeatures({kind: 'document'})].map((d) => d.url));
const paths = [];
for (const url of urls) {
const result = urlLoader.getFilePath(url);
if (result.successful) {
paths.push(result.value);
}
}
const watcher =
new FilesystemChangeStream(chokidar.watch(paths, {persistent: true}));
for await (const changeBatch of watcher) {
const packageRelative =
[...changeBatch].map((absPath) => path.relative(config.root, absPath));
await analyzer.filesChanged(packageRelative);
await run(
analyzer,
urlLoader,
urlResolver,
linter,
{...options, reportIfNoFix: true},
config,
filter,
lintActionsToAlwaysApply,
// We pass the watcher to run() so that it can inform the watcher
// about files that it changes when fixing wanings.
watcher);
console.log('\nLint pass complete, waiting for filesystem changes.\n\n');
}
}
/**
* Converts the event-based FSWatcher into a batched async iterator.
*/
class FilesystemChangeStream implements AsyncIterable<Set<string>> {
private nextBatch = new Set<string>();
private alertWaiter: (() => void)|undefined = undefined;
private outOfBandNotices: undefined|Set<string> = undefined;
constructor(watcher: chokidar.FSWatcher) {
watcher.on('change', (path: string) => {
this.noticeChange(path);
});
watcher.on('unlink', (path: string) => {
this.noticeChange(path);
});
}
/**
* Called when we have noticed a change to the file. Ensures that the file
* will be in the next batch of changes.
*/
private noticeChange(path: string) {
this.nextBatch.add(path);
if (this.alertWaiter) {
this.alertWaiter();
this.alertWaiter = undefined;
}
if (this.outOfBandNotices) {
this.outOfBandNotices.delete(path);
}
}
/**
* Ensures that we will notice a change to the given path, without creating
* duplicated change notices if the normal filesystem watcher also notices
* a change to the same path soon.
*
* This is a way to notify the watcher when we change a file in response
* to another change. The FS event watcher used on linux will ignore our
* change, as it gets grouped in with the change that we were responding to.
*/
ensureChangeIsNoticed(path: string) {
if (!this.outOfBandNotices) {
const notices = new Set();
this.outOfBandNotices = notices;
setTimeout(() => {
for (const path of notices) {
this.noticeChange(path);
}
this.outOfBandNotices = undefined;
}, 100);
}
this.outOfBandNotices.add(path);
}
/**
* Yields batches of filenames.
*
* Each batch of files are those changes that have changed since the last
* batch. Never yields an empty batch, but waits until at least one change is
* noticed.
*/
async * [Symbol.asyncIterator](): AsyncIterator<Set<string>> {
yield new Set();
while (true) {
/**
* If there are changes, yield them. If there are not, wait until
* there are.
*/
if (this.nextBatch.size > 0) {
const batch = this.nextBatch;
this.nextBatch = new Set();
yield batch;
} else {
const waitingPromise = new Promise((resolve) => {
this.alertWaiter = resolve;
});
await waitingPromise;
}
}
}
}
/**
* Report a friendly description of the given warnings to stdout.
*/
async function report(
warnings: ReadonlyArray<Warning>, urlResolver: UrlResolver) {
const printer = new WarningPrinter(
process.stdout, {verbosity: 'full', color: true, resolver: urlResolver});
await printer.printWarnings(warnings);
if (warnings.length > 0) {
let message = '';
const errors = warnings.filter((w) => w.severity === Severity.ERROR);
const warningLevelWarnings =
warnings.filter((w) => w.severity === Severity.WARNING);
const infos = warnings.filter((w) => w.severity === Severity.INFO);
const fixable = warnings.filter((w) => !!w.fix).length;
const hasEditAction = (w: Warning) =>
!!(w.actions && w.actions.find((a) => a.kind === 'edit'));
const editable = warnings.filter(hasEditAction).length;
if (errors.length > 0) {
message += ` ${errors.length} ` +
`${chalk.red('error' + plural(errors.length))}`;
}
if (warningLevelWarnings.length > 0) {
message += ` ${warningLevelWarnings.length} ` +
`${chalk.yellow('warning' + plural(warnings.length))}`;
}
if (infos.length > 0) {
message += ` ${infos.length} ${chalk.green('info')} message` +
plural(infos.length);
}
if (fixable > 0) {
message += `. ${fixable} can be automatically fixed with --fix`;
if (editable > 0) {
message +=
` and ${editable} ${plural(editable, 'have', 'has')} edit actions`;
}
} else if (editable > 0) {
message += `. ${editable} ${plural(editable, 'have', 'has')} ` +
`edit actions, run with --fix for more info`;
}
console.log(`\n\nFound${message}.`);
return new CommandResult(1);
}
}
/**
* Fix all fixable warnings given. Changes files on the filesystem.
*
* Reports a summary of the fixes made to stdout.
*/
async function fix(
warnings: ReadonlyArray<Warning>,
options: Options,
config: ProjectConfig,
analyzer: Analyzer,
analysis: Analysis,
urlLoader: FsUrlLoader,
urlResolver: UrlResolver,
editActionsToAlwaysApply: Set<string>): Promise<Set<string>> {
const edits = await getPermittedEdits(
warnings, options, editActionsToAlwaysApply, urlResolver);
if (edits.length === 0) {
const editCount = warnings.filter((w) => !!w.actions).length;
if (!options.prompt && editCount) {
console.log(
`No fixes to apply. ` +
`${editCount} action${plural(editCount)} may be applied though. ` +
`Run in an interactive terminal ` +
`with --prompt=true for more details.`);
} else {
console.log(`No fixes to apply.`);
}
return new Set();
}
const {appliedEdits, incompatibleEdits, editedFiles} =
await applyEdits(edits, makeParseLoader(analyzer, analysis));
const pathToFileMap = new Map<string, string>();
for (const [url, newContents] of editedFiles) {
const conversionResult = urlLoader.getFilePath(url);
if (conversionResult.successful === false) {
logger.error(
`Problem applying fix to url ${url}: ${conversionResult.error}`);
return new Set();
} else {
pathToFileMap.set(conversionResult.value, newContents);
}
}
for (const [newPath, newContents] of pathToFileMap) {
// need to write a file:// url here.
await fs.writeFile(newPath, newContents, {encoding: 'utf8'});
}
function getPaths(edits: ReadonlyArray<Edit>) {
const paths = new Set<string>();
for (const edit of edits) {
for (const replacement of edit) {
const url = replacement.range.file;
paths.add(getRelativePath(config, urlLoader, url) || url);
}
}
return paths;
}
const changedPaths = getPaths(appliedEdits);
const incompatibleChangedPaths = getPaths(incompatibleEdits);
if (changedPaths.size > 0) {
console.log(`Made changes to:`);
for (const path of changedPaths) {
console.log(` ${path}`);
}
}
if (incompatibleChangedPaths.size > 0) {
console.log('\n');
console.log(`There were incompatible changes to:`);
for (const file of incompatibleChangedPaths) {
console.log(` ${file}`);
}
console.log(
`\nFixed ${appliedEdits.length} ` +
`warning${plural(appliedEdits.length)}. ` +
`${incompatibleEdits.length} fixes had conflicts with other fixes. ` +
`Rerunning the command may apply them.`);
} else {
console.log(
`\nFixed ${appliedEdits.length} ` +
`warning${plural(appliedEdits.length)}.`);
}
return changedPaths;
}
function plural(n: number, pluralVal = 's', singularVal = ''): string {
if (n === 1) {
return singularVal;
}
return pluralVal;
}
/**
* Returns edits from fixes and from edit actions with explicit user consent
* (including prompting the user if we're connected to an interactive
* terminal).
*/
async function getPermittedEdits(
warnings: ReadonlyArray<Warning>,
options: Options,
editActionsToAlwaysApply: Set<string>,
urlResolver: UrlResolver): Promise<Edit[]> {
const edits: Edit[] = [];
for (const warning of warnings) {
if (warning.fix) {
edits.push(warning.fix);
}
for (const action of warning.actions || []) {
if (action.kind === 'edit') {
if (editActionsToAlwaysApply.has(action.code)) {
edits.push(action.edit);
continue;
}
if (options.prompt) {
const answer = await askUserForConsentToApplyEditAction(
action, warning, urlResolver);
switch (answer) {
case 'skip':
continue;
case 'apply-all':
editActionsToAlwaysApply.add(action.code);
// fall through
case 'apply':
edits.push(action.edit);
break;
default:
const never: never = answer;
throw new Error(`Got unknown user consent result: ${never}`);
}
}
}
}
}
return edits;
}
type Choice = 'skip'|'apply'|'apply-all';
async function askUserForConsentToApplyEditAction(
action: EditAction, warning: Warning, urlResolver: UrlResolver):
Promise<Choice> {
type ChoiceObject = {name: string, value: Choice};
const choices: ChoiceObject[] = [
{
value: 'skip',
name: 'Do not apply this edit',
},
{
value: 'apply',
name: 'Apply this edit',
},
{
value: 'apply-all',
name: `Apply all edits like this [${action.code}]`,
}
];
const message = `
This warning can be addressed with an edit:
${indent(warning.toString({resolver: urlResolver}), ' ')}
The edit is:
${indent(action.description, ' ')}
What should be done?
`.trim();
return await prompt({message, choices}) as Choice;
}
function getRelativePath(
config: ProjectConfig, urlLoader: FsUrlLoader, url: ResolvedUrl): string|
undefined {
const result = urlLoader.getFilePath(url);
if (result.successful) {
return path.relative(config.root, result.value);
}
return undefined;
}