Add a way to call Tsetse with fixes in the diagnostic text.

This adds a way to turn diagnostics into text with an inlined fix, and the related tests. Also sneak in a few tests for imports that I seemed to have forgot about.

PiperOrigin-RevId: 268656099
diff --git a/internal/tsetse/failure.ts b/internal/tsetse/failure.ts
index 61680ac..317faf4 100644
--- a/internal/tsetse/failure.ts
+++ b/internal/tsetse/failure.ts
@@ -19,7 +19,7 @@
    * This returns a structure compatible with ts.Diagnostic, but with added
    * fields, for convenience and to support suggested fixes.
    */
-  toDiagnostic(): ts.Diagnostic&{end: number, fix?: Fix} {
+  toDiagnostic(): DiagnosticWithFix {
     return {
       file: this.sourceFile,
       start: this.start,
@@ -35,15 +35,87 @@
     };
   }
 
+  /**
+   * 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.
+ * A `Fix` is a potential repair to the associated `Failure`.
  */
 export interface Fix {
   /**
@@ -52,12 +124,26 @@
   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
 }
 
 /**
- * Stringifies a Fix, replacing the ts.SourceFile with the matching filename.
+ * 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';
diff --git a/internal/tsetse/tests/ban_conformance_pattern/fixer_test.ts b/internal/tsetse/tests/ban_conformance_pattern/fixer_test.ts
index 3b7c85f..24e53ad 100644
--- a/internal/tsetse/tests/ban_conformance_pattern/fixer_test.ts
+++ b/internal/tsetse/tests/ban_conformance_pattern/fixer_test.ts
@@ -1,9 +1,9 @@
 import 'jasmine';
 import * as ts from 'typescript';
-import {Fix} from '../../failure';
+import {Failure, Fix} from '../../failure';
 import {ConformancePatternRule, PatternKind} from '../../rules/conformance_pattern_rule';
-import {buildReplacementFixer, Fixer} from '../../util/fixer';
-import {compileAndCheck, customMatchers} from '../../util/testing/test_support';
+import {buildReplacementFixer, Fixer, maybeAddNamedImport, maybeAddNamespaceImport} from '../../util/fixer';
+import {compile, compileAndCheck, customMatchers} from '../../util/testing/test_support';
 
 const uppercaseFixer: Fixer = {
   getFixForFlaggedNode(node: ts.Node): Fix {
@@ -22,19 +22,19 @@
   return {replaceWith: node.getText().toUpperCase()};
 })
 
+// The initial config and source off which we run those checks.
+const baseConfig = {
+  errorMessage: 'found citation',
+  kind: PatternKind.BANNED_PROPERTY_WRITE,
+  values: ['HTMLQuoteElement.prototype.cite'],
+};
+
+const source = `export {};\n` +
+    `const q = document.createElement('q');\n` +
+    `q.cite = 'some example string';\n`;
+
 describe('ConformancePatternRule\'s fixer', () => {
   describe('Generates basic fixes', () => {
-    const source = `export {};\n` +
-        `const q = document.createElement('q');\n` +
-        `q.cite = 'some example string';\n`;
-
-    // The initial config off which we run those checks.
-    const baseConfig = {
-      errorMessage: 'found citation',
-      kind: PatternKind.BANNED_PROPERTY_WRITE,
-      values: ['HTMLQuoteElement.prototype.cite'],
-    };
-
     it('for a single match', () => {
       const rule = new ConformancePatternRule(baseConfig, uppercaseFixer);
       const results = compileAndCheck(rule, source);
@@ -49,7 +49,6 @@
       ]);
     });
 
-
     it('for a single match (alternate fixer)', () => {
       const rule = new ConformancePatternRule(baseConfig, uppercaseFixerBuilt);
       const results = compileAndCheck(rule, source);
@@ -82,15 +81,117 @@
       expect(results[0]).toHaveFixMatching([
         {start: 50, end: 80, replacement: `Q.CITE = 'SOME EXAMPLE STRING'`}
       ]);
+      expect(results[0].fixToReadableStringInContext())
+          .toBe(
+              `Suggested fix:\n` +
+              `- Replace the full match with: Q.CITE = 'SOME EXAMPLE STRING'`);
       expect(results[1]).toHaveFixMatching([{
         start: 82,
         end: 118,
         replacement: `Q.CITE = 'SOME OTHER EXAMPLE STRING'`
       }]);
+      expect(results[1].fixToReadableStringInContext())
+          .toBe(
+              `Suggested fix:\n` +
+              `- Replace the full match with: Q.CITE = 'SOME OTHER EXAMPLE STRING'`);
+    });
+  });
+
+  describe('adds imports', () => {
+    const addNamedImportFixer: Fixer = {
+      getFixForFlaggedNode(n: ts.Node) {
+        const ic =
+            maybeAddNamedImport(n.getSourceFile(), 'foo', './file_1', 'bar');
+        if (ic) return {changes: [ic]};
+        return;
+      }
+    };
+
+    it('maybeAddNamedImport additions', () => {
+      const results = compileAndCheck(
+          new ConformancePatternRule(baseConfig, addNamedImportFixer), source);
+
+      expect(results[0]).toHaveFixMatching([{
+        start: 0,
+        end: 0,
+        replacement: `import {foo as bar} from './file_1';\n`
+      }]);
+      expect(results[0].fixToReadableStringInContext())
+          .toBe(
+              `Suggested fix:\n` +
+              `- Add new import: import {foo as bar} from './file_1';`);
+    });
+
+    it('maybeAddNamedImport already there', () => {
+      const results = compileAndCheck(
+          new ConformancePatternRule(baseConfig, addNamedImportFixer),
+          'import {foo as bar} from \'./file_1\';\n' + source,
+          'export const foo = 1;');
+
+      expect(results[0]).toHaveNoFix();
+      expect(results[0].fixToReadableStringInContext()).toBe('');
+    });
+
+    it('maybeAddNamedImport different name', () => {
+      const results = compileAndCheck(
+          new ConformancePatternRule(baseConfig, addNamedImportFixer),
+          'import {foo as baz} from \'./file_1\';\n' + source,
+          'export const foo = 1;');
+
+      expect(results[0]).toHaveFixMatching([
+        {start: 8, end: 8, replacement: `foo as bar, `}
+      ]);
+      expect(results[0].fixToReadableStringInContext())
+          .toBe(
+              `Suggested fix:\n` +
+              `- Insert at line 1, char 9: foo as bar,`);
+    });
+
+    it('maybeAddNamespacedImport', () => {
+      const addNamespacedImportFixer: Fixer = {
+        getFixForFlaggedNode(n: ts.Node): Fix |
+        undefined {
+          const ic =
+              maybeAddNamespaceImport(n.getSourceFile(), './file_1', 'foo');
+          if (ic) return {changes: [ic]};
+          return;
+        }
+      };
+      const results = compileAndCheck(
+          new ConformancePatternRule(baseConfig, addNamespacedImportFixer),
+          source);
+
+      expect(results[0]).toHaveFixMatching([
+        {start: 0, end: 0, replacement: `import * as foo from './file_1';\n`}
+      ]);
+    });
+  });
+
+  describe('the logic for location->text transforms', () => {
+    const sourceFile = compile(`let a;\nlet b;\n`)
+                           .getSourceFiles()
+                           .filter(f => f.fileName.indexOf('file_0') !== -1)[0];
+    // let a;\nlet b;\n
+    // 0123456 7890123  Positions
+    // 1234567 1234567  Expected result in characters
+
+    it('stringifies as expected', () => {
+      // Only the sourceFile matters here.
+      const failure = new Failure(sourceFile, NaN, NaN, 'whatever', NaN);
+
+      expect(failure.readableRange(0, 0)).toBe('at line 1, char 1');
+      expect(failure.readableRange(1, 1)).toBe('at line 1, char 2');
+      expect(failure.readableRange(0, 1)).toBe('line 1, from char 1 to 2');
+      expect(failure.readableRange(0, 1)).toBe('line 1, from char 1 to 2');
+      expect(failure.readableRange(7, 7)).toBe('at line 2, char 1');
+      expect(failure.readableRange(0, 7))
+          .toBe('from line 1, char 1 to line 2, char 1');
     });
   });
 });
 
+
+
 beforeEach(() => {
   jasmine.addMatchers(customMatchers);
 });
diff --git a/internal/tsetse/util/testing/test_support.ts b/internal/tsetse/util/testing/test_support.ts
index c12e28a..fb2460e 100644
--- a/internal/tsetse/util/testing/test_support.ts
+++ b/internal/tsetse/util/testing/test_support.ts
@@ -213,7 +213,22 @@
         return {pass: regrets === '', message: regrets};
       }
     };
+  },
+
+  /**
+   * Asserts that a Failure has no fix.
+   */
+  toHaveNoFix(): jasmine.CustomMatcher {
+    return {
+      compare: (actualFailure: Failure) => {
+        return {
+          pass: actualFailure.toDiagnostic().fix === undefined,
+          message: 'This failure should not have a fix.'
+        };
+      }
+    };
   }
+
 };
 
 function expectation(fieldname: string, expectation: any, actual: any) {
@@ -232,11 +247,15 @@
         messageText?: string,
       }): void;
 
+      /** Checks that a Failure has the expected Fix field. */
       toHaveFixMatching(expected: [
         {fileName?: string, start?: number, end?: number, replacement?: string}
       ]): void;
 
       toHaveNFailures(expected: Number, config?: Config): void;
+
+      /** Asserts that a Failure has no fix. */
+      toHaveNoFix(): void;
     }
   }
 }