Skip to content

Commit

Permalink
chore: split functionality internally (#117)
Browse files Browse the repository at this point in the history
## PR Checklist

- [x] Addresses an existing open issue: fixes #116
- [x] That issue was marked as [`status: accepting
prs`](https://github.com/JoshuaKGoldberg/eslint-plugin-expect-type/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in
[CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/eslint-plugin-expect-type/blob/main/.github/CONTRIBUTING.md)
were taken

## Overview

Splits out the roughly-not-specific-to-ESLint logic from the `expect`
rule into:

* `src/assertions`: parsing assertions out from source code
* `src/failures`: determined unmatched/unmet assertions
* `src/utils`: miscellaneous helpers
  • Loading branch information
JoshuaKGoldberg authored Dec 21, 2023
1 parent 6524dc1 commit 740cd78
Show file tree
Hide file tree
Showing 9 changed files with 662 additions and 551 deletions.
136 changes: 136 additions & 0 deletions src/assertions/parseAssertions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import ts from "typescript";

import { getTypeSnapshot } from "../utils/snapshot.js";
import { parseTwoslashAssertion } from "./parseTwoslashAssertion.js";
import { Assertion, TwoSlashAssertion } from "./types.js";
import { Assertions, SyntaxError } from "./types.js";

export function parseAssertions(sourceFile: ts.SourceFile): Assertions {
const errorLines = new Set<number>();
const typeAssertions = new Map<number, Assertion>();
const duplicates: number[] = [];
const syntaxErrors: SyntaxError[] = [];
const twoSlashAssertions: TwoSlashAssertion[] = [];

const { text } = sourceFile;
const commentRegexp = /\/\/(.*)/g;
const lineStarts = sourceFile.getLineStarts();
let curLine = 0;

while (true) {
const commentMatch = commentRegexp.exec(text);
if (commentMatch === null) {
break;
}

// Match on the contents of that comment so we do nothing in a commented-out assertion,
// i.e. `// foo; // $ExpectType number`
const comment = commentMatch[1];
// eslint-disable-next-line regexp/no-unused-capturing-group
const matchExpect = /^ ?\$Expect(TypeSnapshot|Type|Error)( (.*))?$/.exec(
comment,
) as [never, "Error" | "Type" | "TypeSnapshot", never, string?] | null;
const commentIndex = commentMatch.index;
const line = getLine(commentIndex);
if (matchExpect) {
const directive = matchExpect[1];
const payload = matchExpect[3];
switch (directive) {
case "TypeSnapshot":
const snapshotName = payload;
if (snapshotName) {
if (typeAssertions.delete(line)) {
duplicates.push(line);
} else {
typeAssertions.set(line, {
assertionType: "snapshot",
expected: getTypeSnapshot(sourceFile.fileName, snapshotName),
snapshotName,
});
}
} else {
syntaxErrors.push({
line,
type: "MissingSnapshotName",
});
}

break;

case "Error":
if (errorLines.has(line)) {
duplicates.push(line);
}

errorLines.add(line);
break;

case "Type": {
const expected = payload;
if (expected) {
// Don't bother with the assertion if there are 2 assertions on 1 line. Just fail for the duplicate.
if (typeAssertions.delete(line)) {
duplicates.push(line);
} else {
typeAssertions.set(line, { assertionType: "manual", expected });
}
} else {
syntaxErrors.push({
line,
type: "MissingExpectType",
});
}

break;
}
}
} else {
// Maybe it's a twoslash assertion
const assertion = parseTwoslashAssertion(
comment,
commentIndex,
line,
text,
lineStarts,
);
if (assertion) {
if ("type" in assertion) {
syntaxErrors.push(assertion);
} else {
twoSlashAssertions.push(assertion);
}
}
}
}

return {
duplicates,
errorLines,
syntaxErrors,
twoSlashAssertions,
typeAssertions,
};

function getLine(pos: number): number {
// advance curLine to be the line preceding 'pos'
while (lineStarts[curLine + 1] <= pos) {
curLine++;
}

// If this is the first token on the line, it applies to the next line.
// Otherwise, it applies to the text to the left of it.
return isFirstOnLine(text, lineStarts[curLine], pos)
? curLine + 1
: curLine;
}
}

function isFirstOnLine(text: string, lineStart: number, pos: number): boolean {
for (let i = lineStart; i < pos; i++) {
if (/\S/.test(text[i])) {
return false;
}
}

return true;
}
90 changes: 90 additions & 0 deletions src/assertions/parseTwoslashAssertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { SyntaxError, TwoSlashAssertion } from "./types.js";

export function parseTwoslashAssertion(
comment: string,
commentIndex: number,
commentLine: number,
sourceText: string,
lineStarts: readonly number[],
): SyntaxError | TwoSlashAssertion | null {
const matchTwoslash = /^( *)\^\?(.*)$/.exec(comment) as
| [never, string, string]
| null;
if (!matchTwoslash) {
return null;
}

const whitespace = matchTwoslash[1];
const rawPayload = matchTwoslash[2];
if (rawPayload.length && !rawPayload.startsWith(" ")) {
// This is an error: there must be a space after the ^?
return {
line: commentLine - 1,
type: "InvalidTwoslash",
};
}

let expected = rawPayload.slice(1); // strip leading space, or leave it as "".
if (commentLine === 1) {
// This will become an attachment error later.
return {
assertionType: "twoslash",
expected,
expectedPrefix: "",
expectedRange: [-1, -1],
insertSpace: false,
position: -1,
};
}

// The position of interest is wherever the "^" (caret) is, but on the previous line.
const caretIndex = commentIndex + whitespace.length + 2; // 2 = length of "//"
const position =
caretIndex - (lineStarts[commentLine - 1] - lineStarts[commentLine - 2]);

const expectedRange: [number, number] = [
commentIndex + whitespace.length + 5,
commentLine < lineStarts.length
? lineStarts[commentLine] - 1
: sourceText.length,
];
// Peak ahead to the next lines to see if the expected type continues
const expectedPrefix =
sourceText.slice(
lineStarts[commentLine - 1],
commentIndex + 2 + whitespace.length,
) + " ";
for (let nextLine = commentLine; nextLine < lineStarts.length; nextLine++) {
const thisLineEnd =
nextLine + 1 < lineStarts.length
? lineStarts[nextLine + 1] - 1
: sourceText.length;
const lineText = sourceText.slice(lineStarts[nextLine], thisLineEnd + 1);
if (lineText.startsWith(expectedPrefix)) {
if (nextLine === commentLine) {
expected += "\n";
}

expected += lineText.slice(expectedPrefix.length);
expectedRange[1] = thisLineEnd;
} else {
break;
}
}

let insertSpace = false;
if (expectedRange[0] > expectedRange[1]) {
// this happens if the line ends with "^?" and nothing else
expectedRange[0] = expectedRange[1];
insertSpace = true;
}

return {
assertionType: "twoslash",
expected,
expectedPrefix,
expectedRange,
insertSpace,
position,
};
}
76 changes: 76 additions & 0 deletions src/assertions/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export interface SyntaxError {
readonly line: number;
readonly type:
| "InvalidTwoslash"
| "MissingExpectType"
| "MissingSnapshotName";
}

export interface ManualAssertion {
readonly assertionType: "manual";
readonly expected: string;
}

export interface SnapshotAssertion {
readonly assertionType: "snapshot";
readonly expected: string | undefined;
readonly snapshotName: string;
}

export interface TwoSlashAssertion {
readonly assertionType: "twoslash";

/**
* The expected type in the twoslash comment
*/
readonly expected: string;

/**
* Text before the "^?" (used to produce continuation lines for fixer)
*/
readonly expectedPrefix: string;

/**
* Range of positions corresponding to the "expected" string (for fixer)
*/
readonly expectedRange: [number, number];

/**
* Does a space need to be added after "^?" when fixing? (If "^?" ends the line.)
*/
readonly insertSpace: boolean;

/**
* Position in the source file that the twoslash assertion points at
*/
readonly position: number;
}

export type Assertion = ManualAssertion | SnapshotAssertion | TwoSlashAssertion;

export interface Assertions {
/**
* Lines with more than one assertion (these are errors).
*/
readonly duplicates: readonly number[];

/**
* Lines with an $ExpectError.
*/
readonly errorLines: ReadonlySet<number>;

/**
* Syntax Errors
*/
readonly syntaxErrors: readonly SyntaxError[];

/**
* Twoslash-style type assertions in the file
*/
readonly twoSlashAssertions: readonly TwoSlashAssertion[];

/**
* Map from a line number to the expected type at that line.
*/
readonly typeAssertions: Map<number, Assertion>;
}
Loading

0 comments on commit 740cd78

Please sign in to comment.