UNPKG

@fimbul/wotan

Version:

Pluggable TypeScript and JavaScript linter

401 lines 17.6 kB
"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