Skip to content
This repository has been archived by the owner on Mar 25, 2021. It is now read-only.

Check for newly introduced diagnostics before reporting failures (for unused import) #1608

Merged
merged 10 commits into from
Nov 23, 2016
51 changes: 51 additions & 0 deletions src/language/languageServiceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,57 @@ import * as ts from "typescript";

import {createCompilerOptions} from "./utils";

interface LanguageServiceEditableHost extends ts.LanguageServiceHost {
editFile(fileName: string, newContent: string): void;
}

export function wrapProgram(program: ts.Program): ts.LanguageService {
const files: {[name: string]: string} = {};
const fileVersions: {[name: string]: number} = {};
const host: LanguageServiceEditableHost = {
getCompilationSettings: () => program.getCompilerOptions(),
getCurrentDirectory: () => program.getCurrentDirectory(),
getDefaultLibFileName: () => null,
getScriptFileNames: () => program.getSourceFiles().map((sf) => sf.fileName),
getScriptSnapshot: (name: string) => {
if (files.hasOwnProperty(name)) {
return ts.ScriptSnapshot.fromString(files[name]);
}
if (!program.getSourceFile(name)) {
return null;
}
return ts.ScriptSnapshot.fromString(program.getSourceFile(name).getFullText());
},
getScriptVersion: (name: string) => fileVersions.hasOwnProperty(name) ? fileVersions[name] + "" : "1",
log: () => { /* */ },
editFile(fileName: string, newContent: string) {
files[fileName] = newContent;
if (fileVersions.hasOwnProperty(fileName)) {
fileVersions[fileName]++;
} else {
fileVersions[fileName] = 0;
}
},
};
const langSvc = ts.createLanguageService(host, ts.createDocumentRegistry());
(langSvc as any).editFile = host.editFile;
return langSvc;
}

export function checkEdit(ls: ts.LanguageService, sf: ts.SourceFile, newText: string) {
if (ls.hasOwnProperty("editFile")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typeguard function would be nice here

const host = ls as any as LanguageServiceEditableHost;
host.editFile(sf.fileName, newText);
const newProgram = ls.getProgram();
const newSf = newProgram.getSourceFile(sf.fileName);
const newDiags = ts.getPreEmitDiagnostics(newProgram, newSf);
// revert
host.editFile(sf.fileName, sf.getFullText());
return newDiags;
}
return [];
}

export function createLanguageServiceHost(fileName: string, source: string): ts.LanguageServiceHost {
return {
getCompilationSettings: () => createCompilerOptions(),
Expand Down
2 changes: 1 addition & 1 deletion src/language/rule/abstractRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export abstract class AbstractRule implements IRule {
return this.options;
}

public abstract apply(sourceFile: ts.SourceFile): RuleFailure[];
public abstract apply(sourceFile: ts.SourceFile, languageService: ts.LanguageService): RuleFailure[];

public applyWithWalker(walker: RuleWalker): RuleFailure[] {
walker.walk(walker.getSourceFile());
Expand Down
2 changes: 1 addition & 1 deletion src/language/rule/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export interface IDisabledInterval {
export interface IRule {
getOptions(): IOptions;
isEnabled(): boolean;
apply(sourceFile: ts.SourceFile): RuleFailure[];
apply(sourceFile: ts.SourceFile, languageService: ts.LanguageService): RuleFailure[];
applyWithWalker(walker: RuleWalker): RuleFailure[];
}

Expand Down
4 changes: 2 additions & 2 deletions src/language/rule/typedRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ export abstract class TypedRule extends AbstractRule {
return "applyWithProgram" in rule;
}

public apply(_sourceFile: ts.SourceFile): RuleFailure[] {
public apply(): RuleFailure[] {
// if no program is given to the linter, throw an error
throw new Error(`${this.getOptions().ruleName} requires type checking`);
}

public abstract applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[];
public abstract applyWithProgram(sourceFile: ts.SourceFile, languageService: ts.LanguageService): RuleFailure[];
}
11 changes: 9 additions & 2 deletions src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { EnableDisableRulesWalker } from "./enableDisableRules";
import { findFormatter } from "./formatterLoader";
import { ILinterOptions, LintResult } from "./index";
import { IFormatter } from "./language/formatter/formatter";
import { createLanguageService, wrapProgram } from "./language/languageServiceHost";
import { Fix, IRule, RuleFailure } from "./language/rule/rule";
import { TypedRule } from "./language/rule/typedRule";
import * as utils from "./language/utils";
Expand All @@ -50,6 +51,7 @@ class Linter {

private failures: RuleFailure[] = [];
private fixes: RuleFailure[] = [];
private languageService: ts.LanguageService;

/**
* Creates a TypeScript program object from a tsconfig.json file path and optional project directory.
Expand Down Expand Up @@ -93,6 +95,10 @@ class Linter {
throw new Error("ILinterOptions does not contain the property `configuration` as of version 4. " +
"Did you mean to pass the `IConfigurationFile` object to lint() ? ");
}

if (program) {
this.languageService = wrapProgram(program);
}
}

public lint(fileName: string, source?: string, configuration: IConfigurationFile = DEFAULT_CONFIG): void {
Expand Down Expand Up @@ -158,9 +164,9 @@ class Linter {
private applyRule(rule: IRule, sourceFile: ts.SourceFile) {
let ruleFailures: RuleFailure[] = [];
if (this.program && TypedRule.isTypedRule(rule)) {
ruleFailures = rule.applyWithProgram(sourceFile, this.program);
ruleFailures = rule.applyWithProgram(sourceFile, this.languageService);
} else {
ruleFailures = rule.apply(sourceFile);
ruleFailures = rule.apply(sourceFile, this.languageService);
}
let fileFailures: RuleFailure[] = [];
for (let ruleFailure of ruleFailures) {
Expand Down Expand Up @@ -201,6 +207,7 @@ class Linter {
}
} else {
sourceFile = utils.getSourceFile(fileName, source);
this.languageService = createLanguageService(fileName, source);
}

if (sourceFile === undefined) {
Expand Down
4 changes: 2 additions & 2 deletions src/rules/completedDocsRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ export class Rule extends Lint.Rules.TypedRule {
Rule.ARGUMENT_PROPERTIES,
];

public applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): Lint.RuleFailure[] {
public applyWithProgram(sourceFile: ts.SourceFile, langSvc: ts.LanguageService): Lint.RuleFailure[] {
const options = this.getOptions();
const completedDocsWalker = new CompletedDocsWalker(sourceFile, options, program);
const completedDocsWalker = new CompletedDocsWalker(sourceFile, options, langSvc.getProgram());

const nodesToCheck = this.getNodesToCheck(options.ruleArguments);
completedDocsWalker.setNodesToCheck(nodesToCheck);
Expand Down
4 changes: 2 additions & 2 deletions src/rules/noForInArrayRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ export class Rule extends Lint.Rules.TypedRule {

public static FAILURE_STRING = "for-in loops over arrays are forbidden. Use for-of or array.forEach instead.";

public applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): Lint.RuleFailure[] {
const noForInArrayWalker = new NoForInArrayWalker(sourceFile, this.getOptions(), program);
public applyWithProgram(sourceFile: ts.SourceFile, langSvc: ts.LanguageService): Lint.RuleFailure[] {
const noForInArrayWalker = new NoForInArrayWalker(sourceFile, this.getOptions(), langSvc.getProgram());
return this.applyWithWalker(noForInArrayWalker);
}
}
Expand Down
3 changes: 1 addition & 2 deletions src/rules/noMergeableNamespaceRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ export class Rule extends Lint.Rules.AbstractRule {
return `Mergeable namespace ${identifier} found. Merge its contents with the namespace on line ${locationToMerge.line}.`;
}

public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
const languageService = Lint.createLanguageService(sourceFile.fileName, sourceFile.getFullText());
public apply(sourceFile: ts.SourceFile, languageService: ts.LanguageService): Lint.RuleFailure[] {
const noMergeableNamespaceWalker = new NoMergeableNamespaceWalker(sourceFile, this.getOptions(), languageService);
return this.applyWithWalker(noMergeableNamespaceWalker);
}
Expand Down
35 changes: 30 additions & 5 deletions src/rules/noUnusedVariableRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ export class Rule extends Lint.Rules.AbstractRule {
return `Unused ${type}: '${name}'`;
}

public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
const languageService = Lint.createLanguageService(sourceFile.fileName, sourceFile.getFullText());
public apply(sourceFile: ts.SourceFile, languageService: ts.LanguageService): Lint.RuleFailure[] {
return this.applyWithWalker(new NoUnusedVariablesWalker(sourceFile, this.getOptions(), languageService));
}
}
Expand All @@ -93,8 +92,10 @@ class NoUnusedVariablesWalker extends Lint.RuleWalker {
private ignorePattern: RegExp;
private isReactUsed: boolean;
private reactImport: ts.NamespaceImport;
private possibleFailures: Lint.RuleFailure[] = [];

constructor(sourceFile: ts.SourceFile, options: Lint.IOptions, private languageService: ts.LanguageService) {
constructor(sourceFile: ts.SourceFile, options: Lint.IOptions,
private languageService: ts.LanguageService) {
super(sourceFile, options);
this.skipVariableDeclaration = false;
this.skipParameterDeclaration = false;
Expand Down Expand Up @@ -138,9 +139,33 @@ class NoUnusedVariablesWalker extends Lint.RuleWalker {
if (!this.isIgnored(nameText)) {
const start = this.reactImport.name.getStart();
const msg = Rule.FAILURE_STRING_FACTORY(Rule.FAILURE_TYPE_IMPORT, nameText);
this.addFailure(this.createFailure(start, nameText.length, msg));
this.possibleFailures.push(this.createFailure(start, nameText.length, msg));
}
}

let someFixBrokeIt = false;
// Performance optimization: type-check the whole file before verifying individual fixes
if (this.possibleFailures.some((f) => f.hasFix())) {
let newText = Lint.Fix.applyAll(this.getSourceFile().getFullText(),
this.possibleFailures.map((f) => f.getFix()).filter((f) => !!f));

// If we have the program, we can verify that the fix doesn't introduce failures
if (Lint.checkEdit(this.languageService, this.getSourceFile(), newText).length > 0) {
console.error(`Fixes caused errors in ${this.getSourceFile().fileName}`);
someFixBrokeIt = true;
}
}

this.possibleFailures.forEach((f) => {
if (!someFixBrokeIt || !f.hasFix()) {
this.addFailure(f);
} else {
let newText = f.getFix().apply(this.getSourceFile().getFullText());
if (Lint.checkEdit(this.languageService, this.getSourceFile(), newText).length === 0) {
this.addFailure(f);
}
}
});
}

public visitBindingElement(node: ts.BindingElement) {
Expand Down Expand Up @@ -421,7 +446,7 @@ class NoUnusedVariablesWalker extends Lint.RuleWalker {
if (replacements && replacements.length) {
fix = new Lint.Fix(Rule.metadata.ruleName, replacements);
}
this.addFailure(this.createFailure(position, name.length, Rule.FAILURE_STRING_FACTORY(type, name), fix));
this.possibleFailures.push(this.createFailure(position, name.length, Rule.FAILURE_STRING_FACTORY(type, name), fix));
}

private isIgnored(name: string) {
Expand Down
3 changes: 1 addition & 2 deletions src/rules/noUseBeforeDeclareRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ export class Rule extends Lint.Rules.AbstractRule {
public static FAILURE_STRING_PREFIX = "variable '";
public static FAILURE_STRING_POSTFIX = "' used before declaration";

public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
const languageService = Lint.createLanguageService(sourceFile.fileName, sourceFile.getFullText());
public apply(sourceFile: ts.SourceFile, languageService: ts.LanguageService): Lint.RuleFailure[] {
return this.applyWithWalker(new NoUseBeforeDeclareWalker(sourceFile, this.getOptions(), languageService));
}
}
Expand Down
1 change: 1 addition & 0 deletions src/rules/preferForOfRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class PreferForOfWalker extends Lint.RuleWalker {
if (indexVariableName != null) {
const incrementorState = this.incrementorMap[indexVariableName];
if (incrementorState.onlyArrayAccess) {
// Find `array[i]`-like usages by building up a regex
const length = incrementorState.endIncrementPos - node.getStart() + 1;
const failure = this.createFailure(node.getStart(), length, Rule.FAILURE_STRING);
this.addFailure(failure);
Expand Down
4 changes: 2 additions & 2 deletions src/rules/restrictPlusOperandsRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export class Rule extends Lint.Rules.TypedRule {
return `cannot add type ${type}`;
}

public applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): Lint.RuleFailure[] {
return this.applyWithWalker(new RestrictPlusOperandsWalker(sourceFile, this.getOptions(), program));
public applyWithProgram(sourceFile: ts.SourceFile, langSvc: ts.LanguageService): Lint.RuleFailure[] {
return this.applyWithWalker(new RestrictPlusOperandsWalker(sourceFile, this.getOptions(), langSvc.getProgram()));
}
}

Expand Down
20 changes: 18 additions & 2 deletions src/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import * as path from "path";
import * as ts from "typescript";

import {Fix} from "./language/rule/rule";
import {createCompilerOptions} from "./language/utils";
import * as Linter from "./linter";
import {LintError} from "./test/lintError";
import * as parse from "./test/parse";
Expand All @@ -48,6 +47,21 @@ export interface TestResult {
export function runTest(testDirectory: string, rulesDirectory?: string | string[]): TestResult {
const filesToLint = glob.sync(path.join(testDirectory, `**/*${MARKUP_FILE_EXTENSION}`));
const tslintConfig = Linter.findConfiguration(path.join(testDirectory, "tslint.json"), null).results;
const tsConfig = path.join(testDirectory, "tsconfig.json");
let compilerOptions: ts.CompilerOptions = { allowJs: true };
if (fs.existsSync(tsConfig)) {
const {config, error} = ts.readConfigFile(tsConfig, ts.sys.readFile);
if (error) {
throw new Error(JSON.stringify(error));
}

const parseConfigHost = {
fileExists: fs.existsSync,
readDirectory: ts.sys.readDirectory,
useCaseSensitiveFileNames: true,
};
compilerOptions = ts.parseJsonConfigFileContent(config, parseConfigHost, testDirectory).options;
}
const results: TestResult = { directory: testDirectory, results: {} };

for (const fileToLint of filesToLint) {
Expand All @@ -59,7 +73,6 @@ export function runTest(testDirectory: string, rulesDirectory?: string | string[

let program: ts.Program;
if (tslintConfig.linterOptions && tslintConfig.linterOptions.typeCheck) {
const compilerOptions = createCompilerOptions();
const compilerHost: ts.CompilerHost = {
fileExists: () => true,
getCanonicalFileName: (filename: string) => filename,
Expand All @@ -73,6 +86,9 @@ export function runTest(testDirectory: string, rulesDirectory?: string | string[
return ts.createSourceFile(filenameToGet, fileText, compilerOptions.target);
} else if (filenameToGet === fileCompileName) {
return ts.createSourceFile(fileBasename, fileTextWithoutMarkup, compilerOptions.target, true);
} else if (fs.existsSync(path.resolve(path.dirname(fileToLint), filenameToGet))) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious, what's this case for?

const text = fs.readFileSync(path.resolve(path.dirname(fileToLint), filenameToGet), {encoding: "utf-8"});
return ts.createSourceFile(filenameToGet, text, compilerOptions.target, true);
}
},
readFile: () => null,
Expand Down
2 changes: 2 additions & 0 deletions test/rules/no-unused-variable/type-checked/a.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export class A {}
export var a: A;
5 changes: 5 additions & 0 deletions test/rules/no-unused-variable/type-checked/test.ts.lint
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {a, A} from './a';

export class B {
static thing = a;
}
5 changes: 5 additions & 0 deletions test/rules/no-unused-variable/type-checked/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"compilerOptions": {
"declaration": true
}
}
8 changes: 8 additions & 0 deletions test/rules/no-unused-variable/type-checked/tslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"linterOptions": {
"typeCheck": true
},
"rules": {
"no-unused-variable": true
}
}