Skip to content
Open
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
37 changes: 33 additions & 4 deletions packages/formatter/src/addMissingParentheses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@ const parentheses = {
const openParentheses = objectKeys(parentheses);
const closeParentheses = Object.values(parentheses);

function isAsciiAlphaNumeric(char: string): boolean {
const code = char.charCodeAt(0);
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122)
);
}

function addMissingFunctionReturnType(type: string): string {
for (let openIndex = 0; openIndex < type.length; openIndex++) {
if (type[openIndex] !== "(") continue;

let colonIndex = openIndex + 1;
while (colonIndex < type.length && isAsciiAlphaNumeric(type[colonIndex]!)) {
colonIndex++;
}

if (type[colonIndex] !== ":") continue;

const closeIndex = type.indexOf(")", colonIndex + 1);
if (closeIndex === -1) return type;

return `${type.slice(0, closeIndex + 1)} => ...${type.slice(
closeIndex + 1
)}`;
}

return type;
}

export function addMissingParentheses(type: string): string {
const openStack: (typeof openParentheses)[number][] = [];
const missingClosingChars: string[] = [];
Expand Down Expand Up @@ -49,10 +80,8 @@ export function addMissingParentheses(type: string): string {
validType += "...";
}

validType = (validType + "\n..." + missingClosingChars.join("")).replace(
// Change (param: ...) to (param) => __RETURN_TYPE__ if needed
/(\([a-zA-Z0-9]*:[^)]*\))/,
(p1) => `${p1} => ...`
validType = addMissingFunctionReturnType(
validType + "\n..." + missingClosingChars.join("")
);

return validType;
Expand Down
116 changes: 96 additions & 20 deletions packages/formatter/src/errorMessagePrettifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ export function createErrorMessagePrettifier(
const rules = await getRules(codeBlock);
let output = message;

for (const { pattern, replacer } of rules) {
for (const rule of rules) {
if ("replace" in rule) {
output = await rule.replace(output);
continue;
}

const { pattern, replacer } = rule;
let result = "";
let lastIndex = 0;
for (const match of output.matchAll(pattern)) {
Expand All @@ -30,10 +36,70 @@ export function createErrorMessagePrettifier(
};
}

type Rule = {
pattern: RegExp;
replacer: (...args: any[]) => string | Promise<string>;
};
type Rule =
| {
pattern: RegExp;
replacer: (...args: any[]) => string | Promise<string>;
}
| {
replace: (message: string) => string | Promise<string>;
};

async function replaceQuotedStringLiteralTypes(
output: string,
codeBlock: CodeBlockFn
): Promise<string> {
let result = "";
let lastIndex = 0;
let searchIndex = 0;

while (searchIndex < output.length) {
let startIndex = -1;
for (let i = searchIndex; i < output.length - 2; i++) {
if (
/\s/.test(output.charAt(i)) &&
output.charAt(i + 1) === "'" &&
output.charAt(i + 2) === '"'
) {
startIndex = i;
break;
}
}

if (startIndex === -1) break;

const contentStart = startIndex + 3;
let endIndex = -1;
for (let i = contentStart; i < output.length - 1; i++) {
if (output[i] === "\n" || output[i] === "\r") break;
if (
output[i] === `"` &&
output[i + 1] === "'" &&
output[i - 1] !== "\\"
) {
endIndex = i;
break;
}
}

if (endIndex === -1) {
searchIndex = contentStart;
continue;
}

result += output.slice(lastIndex, startIndex);
result += await formatTypeBlock(
"",
`"${output.slice(contentStart, endIndex)}"`,
codeBlock
);

lastIndex = endIndex + 2;
searchIndex = lastIndex;
}

return result + output.slice(lastIndex);
}

async function getRules(codeBlock: CodeBlockFn): Promise<Rule[]> {
const formatTypeScriptBlock = (code: string) => codeBlock(code, "typescript");
Expand All @@ -51,17 +117,17 @@ async function getRules(codeBlock: CodeBlockFn): Promise<Rule[]> {

return [
{
pattern: /(?:\s)'"(.*?)(?<!\\)"'(?:\s|:|.|$)/g,
replacer: async (p1: string) => formatTypeBlock("", `"${p1}"`, codeBlock),
replace: (output: string) =>
replaceQuotedStringLiteralTypes(output, codeBlock),
},
{
pattern: /['“](declare module )['”](.*)['“];['”]/g,
pattern: /['“](declare module )['”]([^'“”\r\n]*)['“];['”]/g,
replacer: (p1: string, p2: string) =>
formatTypeScriptBlock(`${p1} "${p2}"`),
},
{
pattern:
/(is missing the following properties from type\s?)'(.*)': ((?:#?\w+, )*(?:(?!and)\w+)?)/g,
/(is missing the following properties from type\s?)'([^'\r\n]*)': ((?:#?\w+, )*(?:(?!and)\w+)?)/g,
replacer: async (pre: string, type: string, post: string) => {
const formattedType = await formatTypeBlock("", type, codeBlock);
const list = post
Expand All @@ -73,7 +139,7 @@ async function getRules(codeBlock: CodeBlockFn): Promise<Rule[]> {
},
},
{
pattern: /(types) ['“](.*?)['”] and ['“](.*?)['”][.]?/gi,
pattern: /(types) ['“]([^'“”\r\n]*)['”] and ['“]([^'“”\r\n]*)['”][.]?/gi,
replacer: async (p1: string, p2: string, p3: string) => {
const [left, right] = await Promise.all([
formatTypeBlock(p1, p2, codeBlock),
Expand All @@ -83,7 +149,8 @@ async function getRules(codeBlock: CodeBlockFn): Promise<Rule[]> {
},
},
{
pattern: /type annotation must be ['“](.*?)['”] or ['“](.*?)['”][.]?/gi,
pattern:
/type annotation must be ['“]([^'“”\r\n]*)['”] or ['“]([^'“”\r\n]*)['”][.]?/gi,
replacer: async (p1: string, p2: string, p3: string | number) => {
if (typeof p3 === "string") {
const [left, right] = await Promise.all([
Expand All @@ -100,7 +167,7 @@ async function getRules(codeBlock: CodeBlockFn): Promise<Rule[]> {
},
},
{
pattern: /(Overload \d of \d), ['“](.*?)['”], /gi,
pattern: /(Overload \d of \d), ['“]([^'“”\r\n]*)['”], /gi,
replacer: async (p1: string, p2: string) =>
`${p1}${await formatTypeBlock("", p2, codeBlock)}`,
},
Expand All @@ -114,18 +181,27 @@ async function getRules(codeBlock: CodeBlockFn): Promise<Rule[]> {
},
{
pattern:
/(module|file|file name|imported via) ['"“](.*?)['"“](?=[\s(.|,]|$)/gi,
replacer: async (p1: string, p2: string) =>
formatTypeBlock(p1, `"${p2}"`, codeBlock),
/(module|file|file name|imported via) (?:"([^"\r\n]*)"|'([^'\r\n]*)'|“([^“”\r\n]*)[“”])(?=[\s(.|,]|$)/gi,
replacer: async (
p1: string,
doubleQuoted: string | undefined,
singleQuoted: string | undefined,
curlyQuoted: string | undefined
) =>
formatTypeBlock(
p1,
`"${doubleQuoted ?? singleQuoted ?? curlyQuoted ?? ""}"`,
codeBlock
),
},
{
pattern:
/(type|type alias|interface|module|file|file name|class|method's|subtype of constraint) ['“](.*?)['“](?=[\s(.|,)]|$)/gi,
/\b(type|type alias|interface|module|file|file name|class|method's|subtype of constraint) ['“]((?:[^'“\r\n]|['“](?![\s(.|,)]|$))*)['“](?=[\s(.|,)]|$)/gi,
replacer: (p1: string, p2: string) => formatTypeOrModuleBlock(p1, p2),
},
{
pattern:
/['“]([^>]*)['”] (type|interface|return type|file|module|is (not )?assignable)/gi,
/['“]([^'“”>\r\n]*)['”] (type|interface|return type|file|module|is (not )?assignable)/gi,
replacer: async (p1: string, p2: string) =>
`${await formatTypeOrModuleBlock("", p1)} ${p2}`,
},
Expand All @@ -136,16 +212,16 @@ async function getRules(codeBlock: CodeBlockFn): Promise<Rule[]> {
},
{
pattern:
/['“](import|export|require|in|continue|break|let|false|true|const|new|throw|await|for await|[0-9]+)( ?.*?)['”]/g,
/['“](import|export|require|in|continue|break|let|false|true|const|new|throw|await|for await|[0-9]+)( ?[^'“”\r\n]*)['”]/g,
replacer: (p1: string, p2: string) => formatTypeScriptBlock(`${p1}${p2}`),
},
{
pattern: /(return|operator) ['“](.*?)['”]/gi,
pattern: /(return|operator) ['“]([^'“”\r\n]*)['”]/gi,
replacer: (p1: string, p2: string) =>
`${p1} ${formatTypeScriptBlock(p2)}`,
},
{
pattern: /(?<!\w)'((?:(?!["]).)*?)'(?!\w)/g,
pattern: /(?<!\w)'([^'"\r\n]*)'(?!\w)/g,
replacer: (p1: string) => ` ${codeBlock(p1)} `,
},
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ exports[`formatter > prettifies mock error message: errorWithSimpleIndentations

exports[`formatter > prettifies mock error message: errorWithSpecialCharsInObjectKeys 1`] = `"Type \`string\` is not assignable to type \`{ "abc*bc": string }\`."`;

exports[`formatter > prettifies mock error message: errorWithStringChars 1`] = `"Type \`"' 'Oh no"\`is not assignable to type \`"' 'Oh n"o"\` "'."`;
exports[`formatter > prettifies mock error message: errorWithStringChars 1`] = `"Type \`"' 'Oh no"\` is not assignable to type \`"' 'Oh n"o"\` "'."`;

exports[`formatter > prettifies mock error message: errorWithTruncatedType2 1`] = `
"Type:
Expand Down Expand Up @@ -181,7 +181,7 @@ exports[`formatter > prettifies mock error message: ts1378Error 1`] = `"Top-leve

exports[`formatter > prettifies mock error message: ts2304Error 1`] = `"Cannot find name \`varname\` ."`;

exports[`formatter > prettifies mock error message: ts2305Error 1`] = `"Module \`"@pretty-ts-errors/formatter"\`has no exported member \`values\` ."`;
exports[`formatter > prettifies mock error message: ts2305Error 1`] = `"Module \`"@pretty-ts-errors/formatter"\` has no exported member \`values\` ."`;

exports[`formatter > prettifies mock error message: ts2307Error 1`] = `"Cannot find module \`"events"\` or its corresponding type declarations."`;

Expand Down
56 changes: 56 additions & 0 deletions packages/formatter/test/formatter.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,62 @@ describe("formatter", (context) => {
);
});

it("adds missing function return types", () => {
expect(addMissingParentheses("(ref: any)")).toBe("(ref: any) => ...\n...");
});

it("handles adversarial missing-parentheses parameter patterns without catastrophic backtracking", () => {
const message = "(:".repeat(40_000);

expect(addMissingParentheses(message)).toContain(" => ...");
}, 2_000);

it("handles adversarial formatter patterns without catastrophic backtracking", async () => {
const repeat = 5_000;
const messages = [
"\\" + "\t'\"\t".repeat(repeat) + "\n",
";'declare module ”".repeat(repeat) + ";”\n'declare module '“;'",
"is missing the following properties from type's".repeat(repeat) + "\n",
"TYPES “".repeat(repeat) + "'\nTYPES “” AND ''",
"TYPE ANNOTATION MUST BE “".repeat(repeat) + "'",
"OVERLOAD 0 OF 0, “".repeat(repeat) + "\nOVERLOAD 0 OF 0, '', ",
" " + 'FILE "P'.repeat(repeat) + "\n",
"E" + "MTYPE 'R".repeat(repeat) + "\n",
" FILE“".repeat(repeat) + " FILE",
"'0" + "0".repeat(repeat) + "\n“0”",
"RETURN “".repeat(repeat) + "\nRETURN '”",
"'" + "'0'0\x00".repeat(repeat) + "\n",
];

for (const message of messages) {
await expect(prettifyErrorMessage(message)).resolves.toBeTypeOf("string");
}
}, 2_000);

it("does not prettify quoted string literals across line breaks", async () => {
const message = "Type '\"first\nsecond\"' remains unjoined.";

expect(await prettifyErrorMessage(message)).toContain(
"'\"first\nsecond\"' remains"
);
});

it("preserves text after quoted string literal replacements", async () => {
await expect(
prettifyErrorMessage(`Type '"' 'Oh no"' is not assignable.`)
).resolves.toContain("`\"' 'Oh no\"` is not assignable");
});

it("formats double-quoted module names containing apostrophes", async () => {
await expect(
prettifyErrorMessage(
`Cannot find module "C:/Users/O'Connor/project/file.ts" or its corresponding type declarations.`
)
).resolves.toBe(
`Cannot find module \`"C:/Users/O'Connor/project/file.ts"\` or its corresponding type declarations.`
);
});

it("formats Special characters in object keys", async () => {
expect(await prettifyErrorMessage(errorWithSpecialCharsInObjectKeys)).toBe(
'Type `string` is not assignable to type `{ "abc*bc": string }`.'
Expand Down