@fimbul/wotan
Version:
Pluggable TypeScript and JavaScript linter
401 lines • 17.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LanguageServiceInterceptor = exports.version = void 0;
const ts = require("typescript");
const ymir_1 = require("@fimbul/ymir");
const inversify_1 = require("inversify");
const core_module_1 = require("../src/di/core.module");
const default_module_1 = require("../src/di/default.module");
const configuration_manager_1 = require("../src/services/configuration-manager");
const linter_1 = require("../src/linter");
const utils_1 = require("../src/utils");
const cached_file_system_1 = require("../src/services/cached-file-system");
const resolve = require("resolve");
const path = require("path");
const yaml = require("js-yaml");
const argparse_1 = require("../src/argparse");
const normalize_glob_1 = require("normalize-glob");
const minimatch_1 = require("minimatch");
const program_state_1 = require("../src/services/program-state");
const config_hash_1 = require("../src/config-hash");
const tsutils_1 = require("tsutils");
const fix_1 = require("../src/fix");
exports.version = '2';
const DIAGNOSTIC_CODE = 3;
class LanguageServiceInterceptor {
constructor(config,
// tslint:disable:no-submodule-imports
project, serverHost,
// tslint:enable:no-submodule-imports
languageService, require, log) {
this.config = config;
this.project = project;
this.serverHost = serverHost;
this.languageService = languageService;
this.require = require;
this.log = log;
this.lastProjectVersion = '';
this.findingsForFile = new WeakMap();
this.oldState = undefined;
}
updateConfig(config) {
this.config = config;
}
getSemanticDiagnostics(fileName) {
const diagnostics = this.languageService.getSemanticDiagnostics(fileName);
this.log(`getSemanticDiagnostics for ${fileName}`);
const result = this.getFindingsForFile(fileName);
if (!(result === null || result === void 0 ? void 0 : result.findings.length))
return diagnostics;
const findingDiagnostics = utils_1.mapDefined(result.findings, (finding) => finding.severity === 'suggestion'
? undefined
: {
file: result.file,
category: this.config.displayErrorsAsWarnings || finding.severity === 'warning'
? ts.DiagnosticCategory.Warning
: ts.DiagnosticCategory.Error,
code: DIAGNOSTIC_CODE,
source: 'wotan',
messageText: `[${finding.ruleName}] ${finding.message}`,
start: finding.start.position,
length: finding.end.position - finding.start.position,
});
return [...diagnostics, ...findingDiagnostics];
}
getSuggestionDiagnostics(fileName) {
const diagnostics = this.languageService.getSuggestionDiagnostics(fileName);
this.log(`getSuggestionDiagnostics for ${fileName}`);
const result = this.getFindingsForFile(fileName);
if (!(result === null || result === void 0 ? void 0 : result.findings.length))
return diagnostics;
const findingDiagnostics = utils_1.mapDefined(result.findings, (finding) => finding.severity !== 'suggestion'
? undefined
: {
file: result.file,
category: ts.DiagnosticCategory.Suggestion,
code: DIAGNOSTIC_CODE,
source: 'wotan',
messageText: `[${finding.ruleName}] ${finding.message}`,
start: finding.start.position,
length: finding.end.position - finding.start.position,
});
return [...diagnostics, ...findingDiagnostics];
}
getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences) {
const fixes = this.languageService.getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences);
if (!errorCodes.includes(DIAGNOSTIC_CODE))
return fixes;
this.log(`getCodeFixesAtPosition for ${fileName} from ${start} to ${end}`);
const result = this.getFindingsForFile(fileName);
if (!result)
return fixes;
const ruleFixes = [];
const disables = [];
let fixableFindings;
for (const finding of result.findings) {
if (finding.start.position === start && finding.end.position === end) {
if (finding.fix !== undefined) {
fixableFindings !== null && fixableFindings !== void 0 ? fixableFindings : (fixableFindings = result.findings.filter((f) => f.fix !== undefined));
const multipleFixableFindingsForRule = fixableFindings.some((f) => f.ruleName === finding.ruleName && f !== finding);
ruleFixes.push({
fixName: 'wotan:' + finding.ruleName,
description: 'Fix ' + finding.ruleName,
fixId: multipleFixableFindingsForRule ? 'wotan:' + finding.ruleName : undefined,
fixAllDescription: multipleFixableFindingsForRule ? 'Fix all ' + finding.ruleName : undefined,
changes: [{
fileName,
textChanges: finding.fix.replacements.map((r) => ({ span: { start: r.start, length: r.end - r.start }, newText: r.text })),
}],
});
}
disables.push({
fixName: 'disable ' + finding.ruleName,
description: `Disable ${finding.ruleName} for this line`,
changes: [{
fileName,
textChanges: [
getDisableCommentChange(finding.start.position, result.file, finding.ruleName, formatOptions.newLineCharacter),
],
}],
});
}
}
if (fixableFindings !== undefined && fixableFindings.length > 1) {
try {
const fixAll = fix_1.applyFixes(result.file.text, fixableFindings.map((f) => f.fix));
if (fixAll.fixed > 1)
ruleFixes.push({
fixName: 'wotan:fixall',
description: 'Apply all auto-fixes',
changes: [{
fileName,
textChanges: [{
span: fixAll.range.span,
newText: fixAll.result.substr(fixAll.range.span.start, fixAll.range.newLength),
}],
}],
});
}
catch (e) {
this.log('Error in fixAll: ' + (e === null || e === void 0 ? void 0 : e.message));
}
}
return [...fixes, ...ruleFixes, ...disables];
}
getCombinedCodeFix(scope, fixId, formatOptions, preferences) {
if (typeof fixId !== 'string' || !fixId.startsWith('wotan:'))
return this.languageService.getCombinedCodeFix(scope, fixId, formatOptions, preferences);
const findingsForFile = this.getFindingsForFile(scope.fileName);
const ruleName = fixId.substring('wotan:'.length);
const fixAll = fix_1.applyFixes(findingsForFile.file.text, utils_1.mapDefined(findingsForFile.findings, (f) => f.ruleName === ruleName ? f.fix : undefined));
return {
changes: [{
fileName: scope.fileName,
textChanges: [{
span: fixAll.range.span,
newText: fixAll.result.substr(fixAll.range.span.start, fixAll.range.newLength),
}],
}],
};
}
getFindingsForFile(fileName) {
const program = this.languageService.getProgram();
if (program === undefined)
return;
const file = program.getSourceFile(fileName);
if (file === undefined) {
this.log(`File ${fileName} is not included in the Program`);
return;
}
const projectVersion = this.project.getProjectVersion();
if (this.lastProjectVersion === projectVersion) {
const cached = this.findingsForFile.get(file);
if (cached !== undefined) {
this.log(`Reusing last result with ${cached.length} findings`);
return { file, findings: cached };
}
}
else {
this.findingsForFile = new WeakMap();
this.lastProjectVersion = projectVersion;
}
try {
const findings = this.getFindingsForFileWorker(file, program);
this.findingsForFile.set(file, findings);
return { file, findings };
}
catch (e) {
this.log(`Error linting ${fileName}: ${e === null || e === void 0 ? void 0 : e.message}`);
this.findingsForFile.set(file, utils_1.emptyArray);
return;
}
}
getFindingsForFileWorker(file, program) {
let globalConfigDir = this.project.getCurrentDirectory();
let globalOptions;
while (true) {
const scriptSnapshot = this.project.getScriptSnapshot(globalConfigDir + '/.fimbullinter.yaml');
if (scriptSnapshot !== undefined) {
this.log(`Using '${globalConfigDir}/.fimbullinter.yaml' for global options`);
globalOptions = yaml.load(scriptSnapshot.getText(0, scriptSnapshot.getLength())) || {};
break;
}
const parentDir = path.dirname(globalConfigDir);
if (parentDir === globalConfigDir) {
this.log("Cannot find '.fimbullinter.yaml'");
globalOptions = {};
break;
}
globalConfigDir = parentDir;
}
const globalConfig = argparse_1.parseGlobalOptions(globalOptions);
if (!isIncluded(file.fileName, globalConfigDir, globalConfig)) {
this.log('File is excluded by global options');
return [];
}
const container = new inversify_1.Container({ defaultScope: inversify_1.BindingScopeEnum.Singleton });
for (const module of globalConfig.modules)
container.load(this.loadPluginModule(module, globalConfigDir, globalOptions));
container.bind(ymir_1.StatePersistence).toConstantValue({
loadState: () => this.oldState,
saveState: (_, state) => this.oldState = state,
});
container.bind(ymir_1.ContentId).toConstantValue({
forFile: (fileName) => this.project.getScriptVersion(fileName),
});
container.bind(ymir_1.FileSystem).toConstantValue(new ProjectFileSystem(this.project));
container.bind(ymir_1.DirectoryService).toConstantValue({
getCurrentDirectory: () => this.project.getCurrentDirectory(),
});
container.bind(ymir_1.Resolver).toDynamicValue((context) => {
const fs = context.container.get(cached_file_system_1.CachedFileSystem);
return {
getDefaultExtensions() {
return ['.js'];
},
resolve(id, basedir, extensions = ['.js'], paths) {
return resolve.sync(id, {
basedir,
extensions,
paths,
isFile: (f) => fs.isFile(f),
readFileSync: (f) => fs.readFile(f),
});
},
require: this.require,
};
});
const warnings = [];
container.bind(ymir_1.MessageHandler).toConstantValue({
log: this.log,
warn: (message) => {
if (utils_1.addUnique(warnings, message))
this.log(message);
},
error(e) {
this.log(e.message);
},
});
container.load(core_module_1.createCoreModule(globalOptions), default_module_1.createDefaultModule());
const fileFilter = container.get(ymir_1.FileFilterFactory).create({ program, host: this.project });
if (!fileFilter.filter(file)) {
this.log('File is excluded by FileFilter');
return [];
}
const configManager = container.get(configuration_manager_1.ConfigurationManager);
const config = globalConfig.config === undefined
? configManager.find(file.fileName)
: configManager.loadLocalOrResolved(globalConfig.config, globalConfigDir);
const effectiveConfig = config && configManager.reduce(config, file.fileName);
if (effectiveConfig === undefined) {
this.log('File is excluded by configuration');
return [];
}
const linterOptions = {
reportUselessDirectives: globalConfig.reportUselessDirectives
? globalConfig.reportUselessDirectives === true
? 'error'
: globalConfig.reportUselessDirectives
: undefined,
};
const programState = container.get(program_state_1.ProgramStateFactory).create(program, this.project, this.project.projectName);
const configHash = config_hash_1.createConfigHash(effectiveConfig, linterOptions);
const cached = programState.getUpToDateResult(file.fileName, configHash);
if (cached !== undefined) {
this.log(`Using ${cached.length} cached findings`);
return cached;
}
this.log('Start linting');
const linter = container.get(linter_1.Linter);
const result = linter.lintFile(file, effectiveConfig, program, linterOptions);
programState.setFileResult(file.fileName, configHash, result);
programState.save();
this.log(`Found ${result.length} findings`);
return result;
}
loadPluginModule(moduleName, basedir, options) {
moduleName = resolve.sync(moduleName, {
basedir,
extensions: ['.js'],
isFile: (f) => this.project.fileExists(f),
readFileSync: (f) => this.project.readFile(f),
});
const m = this.require(moduleName);
if (!m || typeof m.createModule !== 'function')
throw new Error(`Module '${moduleName}' does not export a function 'createModule'`);
return m.createModule(options);
}
getSupportedCodeFixes(fixes) {
return [...fixes, '' + DIAGNOSTIC_CODE];
}
cleanupSemanticCache() {
this.findingsForFile = new WeakMap();
this.oldState = undefined;
}
dispose() {
return this.languageService.dispose();
}
}
exports.LanguageServiceInterceptor = LanguageServiceInterceptor;
function isIncluded(fileName, basedir, options) {
outer: if (options.files.length !== 0) {
for (const include of options.files)
for (const normalized of normalize_glob_1.normalizeGlob(include, basedir))
if (new minimatch_1.Minimatch(normalized).match(fileName))
break outer;
return false;
}
for (const exclude of options.exclude)
for (const normalized of normalize_glob_1.normalizeGlob(exclude, basedir))
if (new minimatch_1.Minimatch(normalized, { dot: true }).match(fileName))
return false;
return true;
}
class ProjectFileSystem {
constructor(host) {
this.host = host;
this.realpath = this.host.realpath && ((f) => this.host.realpath(f));
}
createDirectory() {
throw new Error('should not be called');
}
deleteFile() {
throw new Error('should not be called');
}
writeFile() {
throw new Error('should not be called');
}
normalizePath(f) {
f = f.replace(/\\/g, '/');
return this.host.useCaseSensitiveFileNames() ? f : f.toLowerCase();
}
readFile(f) {
const result = this.host.readFile(f);
if (result === undefined)
throw new Error('ENOENT');
return result;
}
readDirectory(dir) {
return this.host.readDirectory(dir, undefined, undefined, ['*']);
}
stat(f) {
const isFile = this.host.fileExists(f)
? true
: this.host.directoryExists(f)
? false
: undefined;
return {
isDirectory() {
return isFile === false;
},
isFile() {
return isFile === true;
},
};
}
}
// TODO this should be done by Linter or FindingFilter
function getDisableCommentChange(pos, sourceFile, ruleName, newline = tsutils_1.getLineBreakStyle(sourceFile)) {
const lineStart = pos - ts.getLineAndCharacterOfPosition(sourceFile, pos).character;
let whitespace = '';
for (let i = lineStart, ch; i < sourceFile.text.length; i += charSize(ch)) {
ch = sourceFile.text.codePointAt(i);
if (ts.isWhiteSpaceSingleLine(ch)) {
whitespace += String.fromCodePoint(ch);
}
else {
break;
}
}
return {
newText: `${whitespace}// wotan-disable-next-line ${ruleName}${newline}`,
span: {
start: lineStart,
length: 0,
},
};
}
function charSize(ch) {
return ch >= 0x10000 ? 2 : 1;
}
//# sourceMappingURL=index.js.map