Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: split functionality internally #117

Merged
merged 3 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);

Check warning on line 43 in src/assertions/parseAssertions.ts

View check run for this annotation

Codecov / codecov/patch

src/assertions/parseAssertions.ts#L43

Added line #L43 was not covered by tests
} 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);
}

Check warning on line 63 in src/assertions/parseAssertions.ts

View check run for this annotation

Codecov / codecov/patch

src/assertions/parseAssertions.ts#L62-L63

Added lines #L62 - L63 were not covered by tests

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);

Check warning on line 73 in src/assertions/parseAssertions.ts

View check run for this annotation

Codecov / codecov/patch

src/assertions/parseAssertions.ts#L73

Added line #L73 was not covered by tests
} else {
typeAssertions.set(line, { assertionType: "manual", expected });
}
} else {
syntaxErrors.push({
line,
type: "MissingExpectType",
});
}

Check warning on line 82 in src/assertions/parseAssertions.ts

View check run for this annotation

Codecov / codecov/patch

src/assertions/parseAssertions.ts#L78-L82

Added lines #L78 - L82 were not covered by tests

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>;
}

Check warning on line 76 in src/assertions/types.ts

View check run for this annotation

Codecov / codecov/patch

src/assertions/types.ts#L1-L76

Added lines #L1 - L76 were not covered by tests
Loading
Loading