Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
96 changes: 78 additions & 18 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,63 @@ 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 = contentStart;
while (true) {
endIndex = output.indexOf(`"'`, endIndex);
if (endIndex === -1) break;
if (output[endIndex - 1] !== "\\") break;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent quoted-literal scan from spanning across newlines

The new replaceQuotedStringLiteralTypes scanner searches for the closing "' with indexOf and never stops at \n, so it now matches across line boundaries where the previous regex did not. In any multi-line diagnostic that contains '" on one line and "' later, this rule can consume unrelated lines into one replacement and also drop the next character via matchEnd + 1, which corrupts the rendered message rather than just formatting one literal.

Useful? React with 👍 / 👎.

endIndex += 1;
}

if (endIndex === -1) break;

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

const matchEnd = endIndex + 2;
lastIndex = matchEnd < output.length ? matchEnd + 1 : matchEnd;
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 +110,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 +132,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 +142,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 +160,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 +174,18 @@ async function getRules(codeBlock: CodeBlockFn): Promise<Rule[]> {
},
{
pattern:
/(module|file|file name|imported via) ['"“](.*?)['"“](?=[\s(.|,]|$)/gi,
/(module|file|file name|imported via) ['"“]([^'"“”\r\n]*)['"“](?=[\s(.|,]|$)/gi,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow apostrophes in quoted module/file names

This character class now forbids ' inside the captured module/file name, so diagnostics like module "C:/Users/O'Connor/project/file.ts" no longer match this formatting rule at all. The previous (.*?) pattern handled apostrophes when the outer quotes were double quotes; this change drops that common path-name case and regresses formatting for valid TypeScript messages.

Useful? React with 👍 / 👎.

replacer: async (p1: string, p2: string) =>
formatTypeBlock(p1, `"${p2}"`, 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 +196,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
26 changes: 26 additions & 0 deletions packages/formatter/test/formatter.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,32 @@ 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",
"is missing the following properties from type's".repeat(repeat) + "\n",
"TYPE ANNOTATION MUST BE “".repeat(repeat) + "'",
"OVERLOAD 0 OF 0, “".repeat(repeat) + "\nOVERLOAD 0 OF 0, '', ",
"E" + "MTYPE 'R".repeat(repeat) + "\n",
"'" + "'0'0\x00".repeat(repeat) + "\n",
];

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

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
Loading