| import * as ts from 'typescript'; |
| |
| /** |
| * A Tsetse check Failure is almost identical to a Diagnostic from TypeScript |
| * except that: |
| * (1) The error code is defined by each individual Tsetse rule. |
| * (2) The optional `source` property is set to `Tsetse` so the host (VS Code |
| * for instance) would use that to indicate where the error comes from. |
| * (3) There's an optional suggestedFix field. |
| */ |
| export class Failure { |
| constructor( |
| private readonly sourceFile: ts.SourceFile, |
| private readonly start: number, private readonly end: number, |
| private readonly failureText: string, private readonly code: number, |
| private readonly suggestedFix?: Fix) {} |
| |
| /** |
| * This returns a structure compatible with ts.Diagnostic, but with added |
| * fields, for convenience and to support suggested fixes. |
| */ |
| toDiagnostic(): DiagnosticWithFix { |
| return { |
| file: this.sourceFile, |
| start: this.start, |
| end: this.end, // Not in ts.Diagnostic, but always useful for |
| // start-end-using systems. |
| length: this.end - this.start, |
| messageText: this.failureText, |
| category: ts.DiagnosticCategory.Error, |
| code: this.code, |
| // source is the name of the plugin. |
| source: 'Tsetse', |
| fix: this.suggestedFix |
| }; |
| } |
| |
| /** |
| * Same as toDiagnostic, but include the fix in the message, so that systems |
| * that don't support displaying suggested fixes can still surface that |
| * information. This assumes the diagnostic message is going to be presented |
| * within the context of the problematic code |
| */ |
| toDiagnosticWithStringifiedFix(): DiagnosticWithFix { |
| const diagnostic = this.toDiagnostic(); |
| if (this.suggestedFix) { |
| diagnostic.messageText += ' ' + this.fixToReadableStringInContext(); |
| } |
| return diagnostic; |
| } |
| |
| toString(): string { |
| return `{ sourceFile:${ |
| this.sourceFile ? this.sourceFile.fileName : 'unknown'}, start:${ |
| this.start}, end:${this.end}, fix:${fixToString(this.suggestedFix)} }`; |
| } |
| |
| |
| /** |
| * Stringifies a `Fix`, in a way that makes sense when presented alongside the |
| * finding. This is a heuristic, obviously. |
| */ |
| fixToReadableStringInContext() { |
| if (!this.suggestedFix) return ''; // no changes, nothing to state. |
| const f: Fix = this.suggestedFix; |
| let fixText = ''; |
| |
| for (const c of f.changes) { |
| // Insertion. |
| if (c.start === c.end) { |
| // Try to see if that's an import. |
| if (c.replacement.indexOf('import') !== -1) { |
| fixText += `- Add new import: ${c.replacement}\n`; |
| } else { |
| // Insertion that's not a full import. This should rarely happen in |
| // our context, and we don't have a great message for these. |
| // For instance, this could be the addition of a new symbol in an |
| // existing import (`import {foo}` becoming `import {foo, bar}`). |
| fixText += `- Insert ${this.readableRange(c.start, c.end)}: ${ |
| c.replacement}\n`; |
| } |
| } else if (c.start === this.start && c.end === this.end) { |
| // We assume the replacement is the main part of the fix, so put that |
| // individual change first in `fixText`. |
| fixText = `- Replace the full match with: ${c.replacement}\n` + fixText; |
| } else { |
| // Fallback case: Use a numerical range to specify a replacement. In |
| // general, falling through in this case should be avoided, as it's not |
| // really readable without an IDE (the range can be outside of the |
| // matched code). |
| fixText = `- Replace ${this.readableRange(c.start, c.end)} with: ` + |
| `${c.replacement}\n${fixText}`; |
| } |
| } |
| |
| return 'Suggested fix:\n' + fixText.trim(); |
| } |
| |
| // TS indexes from 0 both ways, but tooling generally indexes from 1 for both |
| // lines and columns. The translation is done here. |
| readableRange(from: number, to: number) { |
| const lcf = this.sourceFile.getLineAndCharacterOfPosition(from); |
| const lct = this.sourceFile.getLineAndCharacterOfPosition(to); |
| if (lcf.line === lct.line) { |
| if (lcf.character === lct.character) { |
| return `at line ${lcf.line + 1}, char ${lcf.character + 1}`; |
| } |
| return `line ${lcf.line + 1}, from char ${lcf.character + 1} to ${ |
| lct.character + 1}`; |
| } else { |
| return `from line ${lcf.line + 1}, char ${lcf.character + 1} to line ${ |
| lct.line + 1}, char ${lct.character + 1}`; |
| } |
| } |
| } |
| |
| /** |
| * A `Fix` is a potential repair to the associated `Failure`. |
| */ |
| export interface Fix { |
| /** |
| * The individual text replacements composing that fix. |
| */ |
| changes: IndividualChange[], |
| } |
| |
| /** |
| * An individual text replacement/insertion in a source file. Used as part of a |
| * `Fix`. |
| */ |
| export interface IndividualChange { |
| sourceFile: ts.SourceFile, start: number, end: number, replacement: string |
| } |
| |
| /** |
| * A ts.Diagnostic that might include a `Fix`, and with an added `end` field for |
| * convenience. |
| */ |
| export interface DiagnosticWithFix extends ts.Diagnostic { |
| end: number; |
| fix?: Fix; |
| } |
| |
| /** |
| * Stringifies a `Fix`, replacing the `ts.SourceFile` with the matching |
| * filename. |
| */ |
| export function fixToString(f?: Fix) { |
| if (!f) return 'undefined'; |
| return '{' + JSON.stringify(f.changes.map(ic => { |
| return { |
| start: ic.start, |
| end: ic.end, |
| replacement: ic.replacement, |
| fileName: ic.sourceFile.fileName |
| }; |
| })) + |
| '}' |
| } |