|
| 1 | +import type { SourceFile } from "ts-morph"; |
| 2 | +import { Node, ts } from "ts-morph"; |
| 3 | +import type { Context } from "../context.js"; |
| 4 | +import SyntaxKind = ts.SyntaxKind; |
| 5 | + |
| 6 | +/** |
| 7 | + * Add 'assertIsAppError' statements for type checking caught exception values in test files. |
| 8 | + */ |
| 9 | +export function isAppErrorInTestFiles(context: Context, sourceFile: SourceFile) { |
| 10 | + if (!sourceFile.getFilePath().endsWith(".test.ts")) { |
| 11 | + // We only run this for test files. All other files are runtime behavior which has to be |
| 12 | + // double-checked. |
| 13 | + return; |
| 14 | + } |
| 15 | + |
| 16 | + let nextDiagnostic; |
| 17 | + // Keep track of the last position, so we only move forwards in the file, and don't get stuck on |
| 18 | + // an expression we can't fix. |
| 19 | + let lastPosition = 0; |
| 20 | + |
| 21 | + while ( |
| 22 | + (nextDiagnostic = getNextObjectOfTypeUnknownDiagnostic(sourceFile, lastPosition)) |
| 23 | + ) { |
| 24 | + // Move past the full error |
| 25 | + lastPosition = nextDiagnostic.getStart()! + nextDiagnostic.getLength()! + 1; |
| 26 | + |
| 27 | + // For some reason, we can't find the position of the diagnostic. |
| 28 | + const expression = sourceFile.getDescendantAtPos(nextDiagnostic.getStart()!); |
| 29 | + if (!expression) { |
| 30 | + continue; |
| 31 | + } |
| 32 | + |
| 33 | + // Find the wrapping statement. These errors are always inside an expression part of some |
| 34 | + // statement. |
| 35 | + const parentStatement = |
| 36 | + Node.isStatement(expression) ? expression : ( |
| 37 | + expression.getParentWhile((_parent, node) => !Node.isStatement(node)) |
| 38 | + ); |
| 39 | + |
| 40 | + if (!parentStatement) { |
| 41 | + continue; |
| 42 | + } |
| 43 | + |
| 44 | + if (!isInCatchClause(parentStatement)) { |
| 45 | + continue; |
| 46 | + } |
| 47 | + |
| 48 | + // The diagnostic text is not always helpful, so retrieve the expression from the file contents. |
| 49 | + // - "'e' is of type unknown'" |
| 50 | + const expressionMatch = sourceFile |
| 51 | + .getFullText() |
| 52 | + .slice( |
| 53 | + nextDiagnostic.getStart(), |
| 54 | + nextDiagnostic.getStart()! + nextDiagnostic.getLength()!, |
| 55 | + ); |
| 56 | + |
| 57 | + try { |
| 58 | + const keyOrInfoRegex = new RegExp(`${expressionMatch}\\.(?:key|info|status)\\b`); |
| 59 | + |
| 60 | + if ( |
| 61 | + parentStatement.getText().match(keyOrInfoRegex) && |
| 62 | + isInCatchClause(parentStatement) |
| 63 | + ) { |
| 64 | + const functionCallToInsert = `assertIsAppError(${expressionMatch});\n\n`; |
| 65 | + |
| 66 | + // Assert is AppError does a type-narrowing assertion; meaning that it guarantees |
| 67 | + // TypeScript, that it would throw and thus prevents execution of the normal code-path. |
| 68 | + sourceFile.insertText(parentStatement.getStart(true), functionCallToInsert); |
| 69 | + |
| 70 | + // Make sure to the offset of the inserted call as well, so we don't stay in an infinite |
| 71 | + // loop |
| 72 | + lastPosition += functionCallToInsert.length; |
| 73 | + } |
| 74 | + // eslint-disable-next-line unused-imports/no-unused-vars |
| 75 | + } catch (e) { |
| 76 | + return; |
| 77 | + } |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * Get the next diagnostic which we can possible fix. |
| 83 | + * This way we rerun the diagnostics each time, getting an up-to-date view. Since one assertion can |
| 84 | + * fix multiple errors. |
| 85 | + */ |
| 86 | +function getNextObjectOfTypeUnknownDiagnostic( |
| 87 | + sourceFile: SourceFile, |
| 88 | + fromPosition: number, |
| 89 | +) { |
| 90 | + const errorCodes = { |
| 91 | + objectIsOfTypeUnknown: 18046, |
| 92 | + }; |
| 93 | + |
| 94 | + return sourceFile.getPreEmitDiagnostics().find((it) => { |
| 95 | + return ( |
| 96 | + Object.values(errorCodes).includes(it.getCode()) && |
| 97 | + (it.getStart() ?? 0) > fromPosition |
| 98 | + ); |
| 99 | + }); |
| 100 | +} |
| 101 | + |
| 102 | +function isInCatchClause(node: Node) { |
| 103 | + const parent = node.getParentWhile( |
| 104 | + (parentNode) => !parentNode.isKind(SyntaxKind.CatchClause), |
| 105 | + ); |
| 106 | + return parent !== null; |
| 107 | +} |
0 commit comments