1 | "use strict";
|
2 | Object.defineProperty(exports, "__esModule", { value: true });
|
3 | exports.LanguageServiceInterceptor = exports.version = void 0;
|
4 | const ts = require("typescript");
|
5 | const ymir_1 = require("@fimbul/ymir");
|
6 | const inversify_1 = require("inversify");
|
7 | const core_module_1 = require("../src/di/core.module");
|
8 | const default_module_1 = require("../src/di/default.module");
|
9 | const configuration_manager_1 = require("../src/services/configuration-manager");
|
10 | const linter_1 = require("../src/linter");
|
11 | const utils_1 = require("../src/utils");
|
12 | const cached_file_system_1 = require("../src/services/cached-file-system");
|
13 | const resolve = require("resolve");
|
14 | const path = require("path");
|
15 | const yaml = require("js-yaml");
|
16 | const argparse_1 = require("../src/argparse");
|
17 | const normalize_glob_1 = require("normalize-glob");
|
18 | const minimatch_1 = require("minimatch");
|
19 | const program_state_1 = require("../src/services/program-state");
|
20 | const config_hash_1 = require("../src/config-hash");
|
21 | const tsutils_1 = require("tsutils");
|
22 | const fix_1 = require("../src/fix");
|
23 | exports.version = '2';
|
24 | const DIAGNOSTIC_CODE = 3;
|
25 | class LanguageServiceInterceptor {
|
26 | constructor(config,
|
27 | // tslint:disable:no-submodule-imports
|
28 | project, serverHost,
|
29 | // tslint:enable:no-submodule-imports
|
30 | languageService, require, log) {
|
31 | this.config = config;
|
32 | this.project = project;
|
33 | this.serverHost = serverHost;
|
34 | this.languageService = languageService;
|
35 | this.require = require;
|
36 | this.log = log;
|
37 | this.lastProjectVersion = '';
|
38 | this.findingsForFile = new WeakMap();
|
39 | this.oldState = undefined;
|
40 | }
|
41 | updateConfig(config) {
|
42 | this.config = config;
|
43 | }
|
44 | getSemanticDiagnostics(fileName) {
|
45 | const diagnostics = this.languageService.getSemanticDiagnostics(fileName);
|
46 | this.log(`getSemanticDiagnostics for ${fileName}`);
|
47 | const result = this.getFindingsForFile(fileName);
|
48 | if (!(result === null || result === void 0 ? void 0 : result.findings.length))
|
49 | return diagnostics;
|
50 | const findingDiagnostics = utils_1.mapDefined(result.findings, (finding) => finding.severity === 'suggestion'
|
51 | ? undefined
|
52 | : {
|
53 | file: result.file,
|
54 | category: this.config.displayErrorsAsWarnings || finding.severity === 'warning'
|
55 | ? ts.DiagnosticCategory.Warning
|
56 | : ts.DiagnosticCategory.Error,
|
57 | code: DIAGNOSTIC_CODE,
|
58 | source: 'wotan',
|
59 | messageText: `[${finding.ruleName}] ${finding.message}`,
|
60 | start: finding.start.position,
|
61 | length: finding.end.position - finding.start.position,
|
62 | });
|
63 | return [...diagnostics, ...findingDiagnostics];
|
64 | }
|
65 | getSuggestionDiagnostics(fileName) {
|
66 | const diagnostics = this.languageService.getSuggestionDiagnostics(fileName);
|
67 | this.log(`getSuggestionDiagnostics for ${fileName}`);
|
68 | const result = this.getFindingsForFile(fileName);
|
69 | if (!(result === null || result === void 0 ? void 0 : result.findings.length))
|
70 | return diagnostics;
|
71 | const findingDiagnostics = utils_1.mapDefined(result.findings, (finding) => finding.severity !== 'suggestion'
|
72 | ? undefined
|
73 | : {
|
74 | file: result.file,
|
75 | category: ts.DiagnosticCategory.Suggestion,
|
76 | code: DIAGNOSTIC_CODE,
|
77 | source: 'wotan',
|
78 | messageText: `[${finding.ruleName}] ${finding.message}`,
|
79 | start: finding.start.position,
|
80 | length: finding.end.position - finding.start.position,
|
81 | });
|
82 | return [...diagnostics, ...findingDiagnostics];
|
83 | }
|
84 | getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences) {
|
85 | const fixes = this.languageService.getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences);
|
86 | if (!errorCodes.includes(DIAGNOSTIC_CODE))
|
87 | return fixes;
|
88 | this.log(`getCodeFixesAtPosition for ${fileName} from ${start} to ${end}`);
|
89 | const result = this.getFindingsForFile(fileName);
|
90 | if (!result)
|
91 | return fixes;
|
92 | const ruleFixes = [];
|
93 | const disables = [];
|
94 | let fixableFindings;
|
95 | for (const finding of result.findings) {
|
96 | if (finding.start.position === start && finding.end.position === end) {
|
97 | if (finding.fix !== undefined) {
|
98 | fixableFindings !== null && fixableFindings !== void 0 ? fixableFindings : (fixableFindings = result.findings.filter((f) => f.fix !== undefined));
|
99 | const multipleFixableFindingsForRule = fixableFindings.some((f) => f.ruleName === finding.ruleName && f !== finding);
|
100 | ruleFixes.push({
|
101 | fixName: 'wotan:' + finding.ruleName,
|
102 | description: 'Fix ' + finding.ruleName,
|
103 | fixId: multipleFixableFindingsForRule ? 'wotan:' + finding.ruleName : undefined,
|
104 | fixAllDescription: multipleFixableFindingsForRule ? 'Fix all ' + finding.ruleName : undefined,
|
105 | changes: [{
|
106 | fileName,
|
107 | textChanges: finding.fix.replacements.map((r) => ({ span: { start: r.start, length: r.end - r.start }, newText: r.text })),
|
108 | }],
|
109 | });
|
110 | }
|
111 | disables.push({
|
112 | fixName: 'disable ' + finding.ruleName,
|
113 | description: `Disable ${finding.ruleName} for this line`,
|
114 | changes: [{
|
115 | fileName,
|
116 | textChanges: [
|
117 | getDisableCommentChange(finding.start.position, result.file, finding.ruleName, formatOptions.newLineCharacter),
|
118 | ],
|
119 | }],
|
120 | });
|
121 | }
|
122 | }
|
123 | if (fixableFindings !== undefined && fixableFindings.length > 1) {
|
124 | try {
|
125 | const fixAll = fix_1.applyFixes(result.file.text, fixableFindings.map((f) => f.fix));
|
126 | if (fixAll.fixed > 1)
|
127 | ruleFixes.push({
|
128 | fixName: 'wotan:fixall',
|
129 | description: 'Apply all auto-fixes',
|
130 | changes: [{
|
131 | fileName,
|
132 | textChanges: [{
|
133 | span: fixAll.range.span,
|
134 | newText: fixAll.result.substr(fixAll.range.span.start, fixAll.range.newLength),
|
135 | }],
|
136 | }],
|
137 | });
|
138 | }
|
139 | catch (e) {
|
140 | this.log('Error in fixAll: ' + (e === null || e === void 0 ? void 0 : e.message));
|
141 | }
|
142 | }
|
143 | return [...fixes, ...ruleFixes, ...disables];
|
144 | }
|
145 | getCombinedCodeFix(scope, fixId, formatOptions, preferences) {
|
146 | if (typeof fixId !== 'string' || !fixId.startsWith('wotan:'))
|
147 | return this.languageService.getCombinedCodeFix(scope, fixId, formatOptions, preferences);
|
148 | const findingsForFile = this.getFindingsForFile(scope.fileName);
|
149 | const ruleName = fixId.substring('wotan:'.length);
|
150 | const fixAll = fix_1.applyFixes(findingsForFile.file.text, utils_1.mapDefined(findingsForFile.findings, (f) => f.ruleName === ruleName ? f.fix : undefined));
|
151 | return {
|
152 | changes: [{
|
153 | fileName: scope.fileName,
|
154 | textChanges: [{
|
155 | span: fixAll.range.span,
|
156 | newText: fixAll.result.substr(fixAll.range.span.start, fixAll.range.newLength),
|
157 | }],
|
158 | }],
|
159 | };
|
160 | }
|
161 | getFindingsForFile(fileName) {
|
162 | const program = this.languageService.getProgram();
|
163 | if (program === undefined)
|
164 | return;
|
165 | const file = program.getSourceFile(fileName);
|
166 | if (file === undefined) {
|
167 | this.log(`File ${fileName} is not included in the Program`);
|
168 | return;
|
169 | }
|
170 | const projectVersion = this.project.getProjectVersion();
|
171 | if (this.lastProjectVersion === projectVersion) {
|
172 | const cached = this.findingsForFile.get(file);
|
173 | if (cached !== undefined) {
|
174 | this.log(`Reusing last result with ${cached.length} findings`);
|
175 | return { file, findings: cached };
|
176 | }
|
177 | }
|
178 | else {
|
179 | this.findingsForFile = new WeakMap();
|
180 | this.lastProjectVersion = projectVersion;
|
181 | }
|
182 | try {
|
183 | const findings = this.getFindingsForFileWorker(file, program);
|
184 | this.findingsForFile.set(file, findings);
|
185 | return { file, findings };
|
186 | }
|
187 | catch (e) {
|
188 | this.log(`Error linting ${fileName}: ${e === null || e === void 0 ? void 0 : e.message}`);
|
189 | this.findingsForFile.set(file, utils_1.emptyArray);
|
190 | return;
|
191 | }
|
192 | }
|
193 | getFindingsForFileWorker(file, program) {
|
194 | let globalConfigDir = this.project.getCurrentDirectory();
|
195 | let globalOptions;
|
196 | while (true) {
|
197 | const scriptSnapshot = this.project.getScriptSnapshot(globalConfigDir + '/.fimbullinter.yaml');
|
198 | if (scriptSnapshot !== undefined) {
|
199 | this.log(`Using '${globalConfigDir}/.fimbullinter.yaml' for global options`);
|
200 | globalOptions = yaml.load(scriptSnapshot.getText(0, scriptSnapshot.getLength())) || {};
|
201 | break;
|
202 | }
|
203 | const parentDir = path.dirname(globalConfigDir);
|
204 | if (parentDir === globalConfigDir) {
|
205 | this.log("Cannot find '.fimbullinter.yaml'");
|
206 | globalOptions = {};
|
207 | break;
|
208 | }
|
209 | globalConfigDir = parentDir;
|
210 | }
|
211 | const globalConfig = argparse_1.parseGlobalOptions(globalOptions);
|
212 | if (!isIncluded(file.fileName, globalConfigDir, globalConfig)) {
|
213 | this.log('File is excluded by global options');
|
214 | return [];
|
215 | }
|
216 | const container = new inversify_1.Container({ defaultScope: inversify_1.BindingScopeEnum.Singleton });
|
217 | for (const module of globalConfig.modules)
|
218 | container.load(this.loadPluginModule(module, globalConfigDir, globalOptions));
|
219 | container.bind(ymir_1.StatePersistence).toConstantValue({
|
220 | loadState: () => this.oldState,
|
221 | saveState: (_, state) => this.oldState = state,
|
222 | });
|
223 | container.bind(ymir_1.ContentId).toConstantValue({
|
224 | forFile: (fileName) => this.project.getScriptVersion(fileName),
|
225 | });
|
226 | container.bind(ymir_1.FileSystem).toConstantValue(new ProjectFileSystem(this.project));
|
227 | container.bind(ymir_1.DirectoryService).toConstantValue({
|
228 | getCurrentDirectory: () => this.project.getCurrentDirectory(),
|
229 | });
|
230 | container.bind(ymir_1.Resolver).toDynamicValue((context) => {
|
231 | const fs = context.container.get(cached_file_system_1.CachedFileSystem);
|
232 | return {
|
233 | getDefaultExtensions() {
|
234 | return ['.js'];
|
235 | },
|
236 | resolve(id, basedir, extensions = ['.js'], paths) {
|
237 | return resolve.sync(id, {
|
238 | basedir,
|
239 | extensions,
|
240 | paths,
|
241 | isFile: (f) => fs.isFile(f),
|
242 | readFileSync: (f) => fs.readFile(f),
|
243 | });
|
244 | },
|
245 | require: this.require,
|
246 | };
|
247 | });
|
248 | const warnings = [];
|
249 | container.bind(ymir_1.MessageHandler).toConstantValue({
|
250 | log: this.log,
|
251 | warn: (message) => {
|
252 | if (utils_1.addUnique(warnings, message))
|
253 | this.log(message);
|
254 | },
|
255 | error(e) {
|
256 | this.log(e.message);
|
257 | },
|
258 | });
|
259 | container.load(core_module_1.createCoreModule(globalOptions), default_module_1.createDefaultModule());
|
260 | const fileFilter = container.get(ymir_1.FileFilterFactory).create({ program, host: this.project });
|
261 | if (!fileFilter.filter(file)) {
|
262 | this.log('File is excluded by FileFilter');
|
263 | return [];
|
264 | }
|
265 | const configManager = container.get(configuration_manager_1.ConfigurationManager);
|
266 | const config = globalConfig.config === undefined
|
267 | ? configManager.find(file.fileName)
|
268 | : configManager.loadLocalOrResolved(globalConfig.config, globalConfigDir);
|
269 | const effectiveConfig = config && configManager.reduce(config, file.fileName);
|
270 | if (effectiveConfig === undefined) {
|
271 | this.log('File is excluded by configuration');
|
272 | return [];
|
273 | }
|
274 | const linterOptions = {
|
275 | reportUselessDirectives: globalConfig.reportUselessDirectives
|
276 | ? globalConfig.reportUselessDirectives === true
|
277 | ? 'error'
|
278 | : globalConfig.reportUselessDirectives
|
279 | : undefined,
|
280 | };
|
281 | const programState = container.get(program_state_1.ProgramStateFactory).create(program, this.project, this.project.projectName);
|
282 | const configHash = config_hash_1.createConfigHash(effectiveConfig, linterOptions);
|
283 | const cached = programState.getUpToDateResult(file.fileName, configHash);
|
284 | if (cached !== undefined) {
|
285 | this.log(`Using ${cached.length} cached findings`);
|
286 | return cached;
|
287 | }
|
288 | this.log('Start linting');
|
289 | const linter = container.get(linter_1.Linter);
|
290 | const result = linter.lintFile(file, effectiveConfig, program, linterOptions);
|
291 | programState.setFileResult(file.fileName, configHash, result);
|
292 | programState.save();
|
293 | this.log(`Found ${result.length} findings`);
|
294 | return result;
|
295 | }
|
296 | loadPluginModule(moduleName, basedir, options) {
|
297 | moduleName = resolve.sync(moduleName, {
|
298 | basedir,
|
299 | extensions: ['.js'],
|
300 | isFile: (f) => this.project.fileExists(f),
|
301 | readFileSync: (f) => this.project.readFile(f),
|
302 | });
|
303 | const m = this.require(moduleName);
|
304 | if (!m || typeof m.createModule !== 'function')
|
305 | throw new Error(`Module '${moduleName}' does not export a function 'createModule'`);
|
306 | return m.createModule(options);
|
307 | }
|
308 | getSupportedCodeFixes(fixes) {
|
309 | return [...fixes, '' + DIAGNOSTIC_CODE];
|
310 | }
|
311 | cleanupSemanticCache() {
|
312 | this.findingsForFile = new WeakMap();
|
313 | this.oldState = undefined;
|
314 | }
|
315 | dispose() {
|
316 | return this.languageService.dispose();
|
317 | }
|
318 | }
|
319 | exports.LanguageServiceInterceptor = LanguageServiceInterceptor;
|
320 | function isIncluded(fileName, basedir, options) {
|
321 | outer: if (options.files.length !== 0) {
|
322 | for (const include of options.files)
|
323 | for (const normalized of normalize_glob_1.normalizeGlob(include, basedir))
|
324 | if (new minimatch_1.Minimatch(normalized).match(fileName))
|
325 | break outer;
|
326 | return false;
|
327 | }
|
328 | for (const exclude of options.exclude)
|
329 | for (const normalized of normalize_glob_1.normalizeGlob(exclude, basedir))
|
330 | if (new minimatch_1.Minimatch(normalized, { dot: true }).match(fileName))
|
331 | return false;
|
332 | return true;
|
333 | }
|
334 | class ProjectFileSystem {
|
335 | constructor(host) {
|
336 | this.host = host;
|
337 | this.realpath = this.host.realpath && ((f) => this.host.realpath(f));
|
338 | }
|
339 | createDirectory() {
|
340 | throw new Error('should not be called');
|
341 | }
|
342 | deleteFile() {
|
343 | throw new Error('should not be called');
|
344 | }
|
345 | writeFile() {
|
346 | throw new Error('should not be called');
|
347 | }
|
348 | normalizePath(f) {
|
349 | f = f.replace(/\\/g, '/');
|
350 | return this.host.useCaseSensitiveFileNames() ? f : f.toLowerCase();
|
351 | }
|
352 | readFile(f) {
|
353 | const result = this.host.readFile(f);
|
354 | if (result === undefined)
|
355 | throw new Error('ENOENT');
|
356 | return result;
|
357 | }
|
358 | readDirectory(dir) {
|
359 | return this.host.readDirectory(dir, undefined, undefined, ['*']);
|
360 | }
|
361 | stat(f) {
|
362 | const isFile = this.host.fileExists(f)
|
363 | ? true
|
364 | : this.host.directoryExists(f)
|
365 | ? false
|
366 | : undefined;
|
367 | return {
|
368 | isDirectory() {
|
369 | return isFile === false;
|
370 | },
|
371 | isFile() {
|
372 | return isFile === true;
|
373 | },
|
374 | };
|
375 | }
|
376 | }
|
377 |
|
378 | function getDisableCommentChange(pos, sourceFile, ruleName, newline = tsutils_1.getLineBreakStyle(sourceFile)) {
|
379 | const lineStart = pos - ts.getLineAndCharacterOfPosition(sourceFile, pos).character;
|
380 | let whitespace = '';
|
381 | for (let i = lineStart, ch; i < sourceFile.text.length; i += charSize(ch)) {
|
382 | ch = sourceFile.text.codePointAt(i);
|
383 | if (ts.isWhiteSpaceSingleLine(ch)) {
|
384 | whitespace += String.fromCodePoint(ch);
|
385 | }
|
386 | else {
|
387 | break;
|
388 | }
|
389 | }
|
390 | return {
|
391 | newText: `${whitespace}// wotan-disable-next-line ${ruleName}${newline}`,
|
392 | span: {
|
393 | start: lineStart,
|
394 | length: 0,
|
395 | },
|
396 | };
|
397 | }
|
398 | function charSize(ch) {
|
399 | return ch >= 0x10000 ? 2 : 1;
|
400 | }
|
401 |
|
\ | No newline at end of file |