diff --git a/CHANGELOG.md b/CHANGELOG.md index d916910..9c1f101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi. - Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi. - Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi. +- Fixed Fastify plugin callback route mapping for typed parameters and plugin aliases, thanks @rohitjavvadi. - Fixed Bun package-manager detection to recognize the text `bun.lock` lockfile, thanks @austinm911. ## 0.3.0 - 2026-05-18 diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 75399bb..f3d8285 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2408,12 +2408,131 @@ describe("mapFeatures", () => { root, "src/fastify-plugin.ts", [ + "import fastifyPlugin from 'fastify-plugin';", + "import fp from 'fastify-plugin';", + "import cjsPlugin = require('fastify-plugin');", "import { FastifyInstance } from 'fastify';", + "import type { FastifyInstance as FastifyApp } from 'fastify';", "", "export async function routes(fastify: FastifyInstance) {", " fastify.get('/plugin-users', listPluginUsers);", "}", + "export async function appRoutes(app: FastifyInstance) {", + " app.get('/plugin-app-users', listPluginAppUsers);", + "}", + "export async function typedReturnRoutes(app: FastifyInstance): Promise {", + " app.get('/plugin-typed-return-users', listPluginTypedReturnUsers);", + "}", + "export async function typedObjectReturnRoutes(app: FastifyInstance): Promise<{ ok: true }> {", + " app.get('/plugin-typed-object-return-users', listPluginTypedObjectReturnUsers);", + "}", + "export async function aliasedTypeRoutes(app: FastifyApp) {", + " app.get('/plugin-aliased-type-users', listPluginAliasedTypeUsers);", + "}", + "const app = createHttpServer();", + "app.get('/not-plugin-app-typed', ignoredApp);", + "export const serverRoutes = fastifyPlugin(async function routes(server) {", + " server.get('/plugin-server-users', listPluginServerUsers);", + "});", + "export const serverReturnRoutes = fastifyPlugin(function routes(server): Promise {", + " server.get('/plugin-server-return-users', listPluginServerReturnUsers);", + "});", + "export const arrowRoutes = fastifyPlugin(async (app) => {", + " app.get('/plugin-arrow-users', listPluginArrowUsers);", + "});", + "export const bareArrowRoutes = fastifyPlugin(async bareApp => {", + " bareApp.get('/plugin-bare-arrow-users', listPluginBareArrowUsers);", + "});", + "export const instanceRoutes = fp(async (instance, options) => {", + " instance.get('/plugin-instance-users', listPluginInstanceUsers);", + " options.get('/not-plugin-options', ignoredOptions);", + "});", + "export const commentRoutes = fp(async (instance) => {", + " // }", + " instance.get('/plugin-comment-users', listPluginCommentUsers);", + "});", + "export const commentedArgumentRoutes = fp( /* routes */ async (commentedApp) => {", + " commentedApp.get('/plugin-commented-argument-users', listPluginCommentedArgumentUsers);", + "});", + "export const aliasedRoutes = fp(async (app) => {", + " app.get('/plugin-aliased-users', listPluginAliasedUsers);", + "});", + "type PluginOptions = { prefix: string };", + "export const genericRoutes = fastifyPlugin(async (server) => {", + " server.get('/plugin-generic-users', listPluginGenericUsers);", + "});", + "export const importEqualsRoutes = cjsPlugin(async (server) => {", + " server.get('/plugin-import-equals-users', listPluginImportEqualsUsers);", + "});", + "const defaultPlugin = require('fastify-plugin').default;", + "export const defaultRequireRoutes = defaultPlugin(async (app) => {", + " app.get('/plugin-default-require-users', listPluginDefaultRequireUsers);", + "});", + "export const typedArrowRoutes = async (server: FastifyInstance): Promise => {", + " server.get('/plugin-typed-arrow-users', listPluginTypedArrowUsers);", + "};", + "const server = createHttpServer();", + "server.get('/not-plugin-server', ignoredServer);", + 'export async function inlineRoutes(inlineApp: import("fastify").FastifyInstance) {', + ' inlineApp.get("/plugin-inline-users", listPluginInlineUsers);', + "}", + 'export const inlineArrowRoutes = async (inlineServer: import("fastify").FastifyInstance): Promise => {', + ' inlineServer.get("/plugin-inline-arrow-users", listPluginInlineArrowUsers);', + "};", "function listPluginUsers() {}", + "function listPluginAppUsers() {}", + "function listPluginTypedReturnUsers() {}", + "function listPluginTypedObjectReturnUsers() {}", + "function listPluginAliasedTypeUsers() {}", + "function listPluginServerUsers() {}", + "function listPluginServerReturnUsers() {}", + "function listPluginArrowUsers() {}", + "function listPluginBareArrowUsers() {}", + "function listPluginInstanceUsers() {}", + "function listPluginCommentUsers() {}", + "function listPluginCommentedArgumentUsers() {}", + "function listPluginAliasedUsers() {}", + "function listPluginGenericUsers() {}", + "function listPluginImportEqualsUsers() {}", + "function listPluginDefaultRequireUsers() {}", + "function listPluginTypedArrowUsers() {}", + "function listPluginInlineUsers() {}", + "function listPluginInlineArrowUsers() {}", + "function createHttpServer() { return { get() {} }; }", + "function ignoredApp() {}", + "function ignoredServer() {}", + "function ignoredOptions() {}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/fastify-multiline-import.ts", + [ + "import {", + " FastifyInstance,", + "} from 'fastify';", + "", + "export async function multilineRoutes(app: FastifyInstance) {", + " app.get('/plugin-multiline-users', listPluginMultilineUsers);", + "}", + "function listPluginMultilineUsers() {}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/not-fastify-plugin.ts", + [ + 'import { FastifyInstance } from "./types"', + 'import Fastify from "fastify"', + "export async function genericAppRoutes(app) {", + ' app.get("/not-plugin-app", ignored);', + "}", + "export async function shadowInstanceRoutes(instance: FastifyInstance) {", + ' instance.get("/shadow-fastify-instance", ignored);', + "}", + "function ignored() {}", "", ].join("\n"), ); @@ -2585,6 +2704,25 @@ describe("mapFeatures", () => { "Fastify route GET /route-status", "Fastify route POST /webhook/github", "Fastify route GET /plugin-users", + "Fastify route GET /plugin-app-users", + "Fastify route GET /plugin-typed-return-users", + "Fastify route GET /plugin-typed-object-return-users", + "Fastify route GET /plugin-aliased-type-users", + "Fastify route GET /plugin-server-users", + "Fastify route GET /plugin-server-return-users", + "Fastify route GET /plugin-arrow-users", + "Fastify route GET /plugin-bare-arrow-users", + "Fastify route GET /plugin-instance-users", + "Fastify route GET /plugin-comment-users", + "Fastify route GET /plugin-commented-argument-users", + "Fastify route GET /plugin-aliased-users", + "Fastify route GET /plugin-generic-users", + "Fastify route GET /plugin-import-equals-users", + "Fastify route GET /plugin-default-require-users", + "Fastify route GET /plugin-typed-arrow-users", + "Fastify route GET /plugin-inline-users", + "Fastify route GET /plugin-inline-arrow-users", + "Fastify route GET /plugin-multiline-users", "Hono route GET /api/items", "Hono route DELETE /sessions/:id", ]), @@ -2607,6 +2745,11 @@ describe("mapFeatures", () => { expect(titles).not.toContain("Express route GET /assigned-not-router"); expect(titles).not.toContain("Express route GET /dynamic/"); expect(titles).not.toContain("Fastify route GET /dynamic/"); + expect(titles).not.toContain("Fastify route GET /not-plugin-app"); + expect(titles).not.toContain("Fastify route GET /not-plugin-app-typed"); + expect(titles).not.toContain("Fastify route GET /not-plugin-server"); + expect(titles).not.toContain("Fastify route GET /not-plugin-options"); + expect(titles).not.toContain("Fastify route GET /shadow-fastify-instance"); expect(titles).not.toContain("Fastify route GET /concat-"); expect(titles).not.toContain("Express route DELETE /reports"); expect(admin?.source).toBe("express-route"); diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index fafbfd4..2a6ab09 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -158,7 +158,9 @@ function parseServerRoutes( const routes: ServerRoute[] = []; for (const framework of projectFrameworks) { const targets = routeTargetNames(source, framework); - if (targets.size === 0) { + const scopedFastifyRoutes = + framework === "fastify" ? fastifyScopedCallbackRoutes(source, filePath) : []; + if (targets.size === 0 && scopedFastifyRoutes.length === 0) { continue; } routes.push(...directMethodRoutes(source, filePath, framework, targets)); @@ -166,6 +168,7 @@ function parseServerRoutes( routes.push(...expressRouteChains(source, filePath, targets)); } else if (framework === "fastify") { routes.push(...fastifyRouteObjects(source, filePath, targets)); + routes.push(...scopedFastifyRoutes); } } return uniqueRoutes(routes); @@ -320,7 +323,7 @@ function routeTargetNames(source: string, framework: ServerFramework): Set { return names; } -function functionParameterTargets(source: string, preferredName: string): Set { +function fastifyParameterTargets(source: string): Set { + const names = new Set(); + for (const callback of functionParameterCallbacks(source)) { + for (const parameter of callback.parameters) { + if (parameter.name === "fastify") { + names.add(parameter.name); + } + } + } + return names; +} + +function fastifyPluginCallTargetNames(source: string): Set { const names = new Set(); + for (const clause of fastifyPluginImportClauses(source)) { + addFastifyPluginImportNames(names, clause); + } for (const pattern of [ - /(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\(([^)]*)\)/gu, - /(?:async\s*)?\(([^)]*)\)\s*=>/gu, + /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\s*:\s*[^=;]+)?\s*=\s*require\s*\(\s*["']fastify-plugin["']\s*\)(?:\s*\.\s*default)?/gu, + /\bimport\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*require\s*\(\s*["']fastify-plugin["']\s*\)/gu, ]) { pattern.lastIndex = 0; for (const match of source.matchAll(pattern)) { - const matchIndex = match.index ?? 0; - if (isInsideCommentOrString(source, matchIndex)) { + if (isInsideCommentOrString(source, match.index ?? 0)) { continue; } - for (const parameter of parameterNames(match[1] ?? "")) { - if (parameter === preferredName) { - names.add(parameter); - } + const name = match[1]; + if (name !== undefined) { + names.add(name); } } } return names; } -function parameterNames(parameters: string): string[] { +function fastifyPluginImportClauses(source: string): string[] { + const clauses: string[] = []; + const pattern = /\bimport\b/gu; + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + const importIndex = match.index ?? 0; + if ( + isInsideCommentOrString(source, importIndex) || + !isImportDeclarationStart(source, importIndex) + ) { + continue; + } + const clause = readStaticImportClause(source, importIndex, "fastify-plugin"); + if (clause !== null) { + clauses.push(clause); + } + } + return clauses; +} + +function addFastifyPluginImportNames(names: Set, clause: string): void { + const namespace = /^\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/u.exec(clause.trim())?.[1]; + if (namespace !== undefined) { + names.add(namespace); + return; + } + + const defaultName = /^([A-Za-z_$][A-Za-z0-9_$]*)\b/u.exec(clause.trim())?.[1]; + if (defaultName !== undefined && defaultName !== "type") { + names.add(defaultName); + } + + const named = /\{([^}]*)\}/u.exec(clause)?.[1]; + if (named === undefined) { + return; + } + for (const part of named.split(",")) { + const binding = part.trim(); + if (binding.startsWith("type ")) { + continue; + } + const match = /^(?:default|fastifyPlugin)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/u.exec( + binding, + ); + if (match !== null) { + names.add(match[1] ?? binding); + } + } +} + +function fastifyInstanceTypeNames(source: string): Set { + const names = new Set(); + const pattern = /\bimport\b/gu; + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + const importIndex = match.index ?? 0; + if ( + isInsideCommentOrString(source, importIndex) || + !isImportDeclarationStart(source, importIndex) + ) { + continue; + } + const clause = readFastifyStaticImportClause(source, importIndex); + if (clause !== null) { + addFastifyInstanceTypeNames(names, clause); + } + } + return names; +} + +function addFastifyInstanceTypeNames(names: Set, clause: string): void { + const named = /\{([^}]*)\}/u.exec(clause)?.[1]; + if (named === undefined) { + return; + } + for (const part of named.split(",")) { + const binding = part.trim().replace(/^type\s+/u, ""); + const match = /^FastifyInstance(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/u.exec(binding); + if (match !== null) { + names.add(match[1] ?? "FastifyInstance"); + } + } +} + +function readFastifyStaticImportClause(source: string, importIndex: number): string | null { + return readStaticImportClause(source, importIndex, "fastify"); +} + +function readStaticImportClause( + source: string, + importIndex: number, + moduleName: string, +): string | null { + let cursor = importIndex + "import".length; + cursor = skipWhitespaceAndComments(source, cursor); + if ( + source[cursor] === "(" || + source[cursor] === "." || + source[cursor] === "'" || + source[cursor] === '"' + ) { + return null; + } + if (!isImportClauseStart(source[cursor])) { + return null; + } + const clauseStart = cursor; + const limit = Math.min(source.length, importIndex + 500); + while (cursor < limit) { + const char = source[cursor]; + const next = source[cursor + 1]; + if (char === undefined) { + break; + } + if (char === ";") { + return null; + } + if (char === "/" && next === "/") { + cursor = skipLineComment(source, cursor + 2); + continue; + } + if (char === "/" && next === "*") { + cursor = skipBlockComment(source, cursor + 2); + continue; + } + if (char === "'" || char === '"' || char === "`") { + cursor = skipQuoted(source, cursor, char); + continue; + } + if (isKeywordAt(source, cursor, "from")) { + const specifier = readImportSpecifier(source, cursor + "from".length); + if (specifier === null) { + cursor += "from".length; + continue; + } + return specifier.value === moduleName ? source.slice(clauseStart, cursor) : null; + } + cursor += 1; + } + return null; +} + +function fastifyScopedCallbackRoutes(source: string, filePath: string): ServerRoute[] { + const pluginCallTargets = fastifyPluginCallTargetNames(source); + const instanceTypeNames = fastifyInstanceTypeNames(source); + const routes: ServerRoute[] = []; + for (const callback of [ + ...functionParameterCallbacks(source), + ...inlineFastifyInstanceCallbacks(source), + ]) { + const targets = new Set(); + for (const [index, parameter] of callback.parameters.entries()) { + if ( + isFastifyInstanceParameter(parameter.source, instanceTypeNames) || + (pluginCallTargets.size > 0 && + index === 0 && + isInsideFastifyPluginCall(source, callback.index, pluginCallTargets)) + ) { + targets.add(parameter.name); + } + } + if (targets.size === 0) { + continue; + } + const body = functionBodySource(source, callback.bodySearchStart); + if (body === null) { + continue; + } + routes.push(...directMethodRoutes(body, filePath, "fastify", targets)); + routes.push(...fastifyRouteObjects(body, filePath, targets)); + } + return routes; +} + +function inlineFastifyInstanceCallbacks(source: string): Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; +}> { + const callbacks: Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; + }> = []; + const functionPattern = + /(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*import\s*\(\s*["']fastify["']\s*\)\s*\.\s*FastifyInstance\b[^)]*\)/gu; + functionPattern.lastIndex = 0; + for (const match of source.matchAll(functionPattern)) { + const matchIndex = match.index ?? 0; + addInlineFastifyInstanceCallback(callbacks, source, matchIndex, match[0].length, match[1]); + } + const arrowPattern = + /(^|[^A-Za-z0-9_$])((?:async\s*)?\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*import\s*\(\s*["']fastify["']\s*\)\s*\.\s*FastifyInstance\b[^)]*\)\s*(?::\s*[^=]+?)?=>)/gu; + arrowPattern.lastIndex = 0; + for (const match of source.matchAll(arrowPattern)) { + const prefixLength = match[1]?.length ?? 0; + const callbackIndex = (match.index ?? 0) + prefixLength; + addInlineFastifyInstanceCallback( + callbacks, + source, + callbackIndex, + match[2]?.length ?? 0, + match[3], + ); + } + return callbacks; +} + +function addInlineFastifyInstanceCallback( + callbacks: Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; + }>, + source: string, + callbackIndex: number, + matchLength: number, + name: string | undefined, +): void { + if (name === undefined || isInsideCommentOrString(source, callbackIndex)) { + return; + } + callbacks.push({ + index: callbackIndex, + bodySearchStart: callbackIndex + matchLength, + parameters: [ + { + name, + source: `${name}: import("fastify").FastifyInstance`, + }, + ], + }); +} + +function functionParameterCallbacks(source: string): Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; +}> { + const callbacks: Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; + }> = []; + const functionPattern = /(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\(([^)]*)\)/gu; + functionPattern.lastIndex = 0; + for (const match of source.matchAll(functionPattern)) { + const matchIndex = match.index ?? 0; + addFunctionParameterCallback(callbacks, source, matchIndex, match[0].length, match[1]); + } + const arrowPattern = /(^|[^A-Za-z0-9_$])((?:async\s*)?\(([^()]*)\)\s*(?::\s*[^=]+?)?=>)/gu; + arrowPattern.lastIndex = 0; + for (const match of source.matchAll(arrowPattern)) { + const prefixLength = match[1]?.length ?? 0; + const callbackIndex = (match.index ?? 0) + prefixLength; + addFunctionParameterCallback(callbacks, source, callbackIndex, match[2]?.length ?? 0, match[3]); + } + const bareArrowPattern = /(^|[^A-Za-z0-9_$])((?:async\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=>)/gu; + bareArrowPattern.lastIndex = 0; + for (const match of source.matchAll(bareArrowPattern)) { + const prefixLength = match[1]?.length ?? 0; + const callbackIndex = (match.index ?? 0) + prefixLength; + addFunctionParameterCallback(callbacks, source, callbackIndex, match[2]?.length ?? 0, match[3]); + } + return callbacks; +} + +function addFunctionParameterCallback( + callbacks: Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; + }>, + source: string, + callbackIndex: number, + matchLength: number, + parameters: string | undefined, +): void { + if (isInsideCommentOrString(source, callbackIndex)) { + return; + } + callbacks.push({ + index: callbackIndex, + bodySearchStart: callbackIndex + matchLength, + parameters: functionParameters(parameters ?? ""), + }); +} + +function functionBodySource(source: string, bodySearchStart: number): string | null { + let bodyStart = skipWhitespaceAndComments(source, bodySearchStart); + if (source[bodyStart] === ":") { + bodyStart = skipWhitespaceAndComments(source, skipFunctionReturnType(source, bodyStart + 1)); + } + if (source[bodyStart] !== "{") { + return null; + } + const bodyEnd = endOfObject(source, bodyStart + 1); + return bodyEnd === null ? null : source.slice(bodyStart + 1, bodyEnd - 1); +} + +function skipFunctionReturnType(source: string, start: number): number { + let quote: string | null = null; + let escaped = false; + let parenDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + return index; + } + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + } else if (char === "(") { + parenDepth += 1; + } else if (char === ")") { + parenDepth = Math.max(0, parenDepth - 1); + } else if (char === "[") { + bracketDepth += 1; + } else if (char === "]") { + bracketDepth = Math.max(0, bracketDepth - 1); + } else if (char === "{") { + if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + const previous = previousSignificantChar(source, index - 1, start); + if ( + previous !== ":" && + previous !== "|" && + previous !== "&" && + previous !== "," && + previous !== "<" + ) { + return index; + } + } + braceDepth += 1; + } else if (char === "}") { + braceDepth = Math.max(0, braceDepth - 1); + } + } + return source.length; +} + +function previousSignificantChar(source: string, start: number, lowerBound: number): string | null { + for (let index = start; index >= lowerBound; index -= 1) { + const char = source[index]; + if (char !== undefined && !/\s/u.test(char)) { + return char; + } + } + return ":"; +} + +function functionParameters(parameters: string): Array<{ name: string; source: string }> { return parameters .split(",") - .map((parameter) => /^\.{0,3}\s*([A-Za-z_$][A-Za-z0-9_$]*)/u.exec(parameter.trim())?.[1]) - .filter((parameter): parameter is string => parameter !== undefined); + .map((parameter) => { + const source = parameter.trim(); + const name = /^\.{0,3}\s*([A-Za-z_$][A-Za-z0-9_$]*)/u.exec(source)?.[1]; + return name === undefined ? null : { name, source }; + }) + .filter((parameter): parameter is { name: string; source: string } => parameter !== null); +} + +function isFastifyInstanceParameter( + parameter: string, + instanceTypeNames: ReadonlySet, +): boolean { + if (/:\s*import\s*\(\s*["']fastify["']\s*\)\s*\.\s*FastifyInstance\b/u.test(parameter)) { + return true; + } + for (const name of instanceTypeNames) { + if (new RegExp(String.raw`:\s*${escapeRegExp(name)}\b`, "u").test(parameter)) { + return true; + } + } + return false; +} + +function isInsideFastifyPluginCall( + source: string, + functionIndex: number, + pluginCallTargets: ReadonlySet, +): boolean { + const prefix = source + .slice(Math.max(0, functionIndex - 500), functionIndex) + .replace(/\/\*[\s\S]*?\*\//gu, " ") + .replace(/\/\/[^\n\r]*/gu, " "); + const targetPattern = [...pluginCallTargets].map(escapeRegExp).join("|"); + return new RegExp(`\\b(?:${targetPattern})${genericArguments}\\s*\\(\\s*$`, "u").test(prefix); } function isRouteTarget(targets: ReadonlySet, target: string): boolean { @@ -747,7 +1157,13 @@ function endOfObject(source: string, start: number): number | null { } continue; } - if (char === "'" || char === '"' || char === "`") { + if (char === "/" && source[index + 1] === "/") { + const newline = source.indexOf("\n", index + 2); + index = newline < 0 ? source.length : newline; + } else if (char === "/" && source[index + 1] === "*") { + const close = source.indexOf("*/", index + 2); + index = close < 0 ? source.length : close + 1; + } else if (char === "'" || char === '"' || char === "`") { quote = char; } else if (char === "{") { depth += 1;