/** * Shared Mongo shell → JSON argument preprocessing. * Single home for ObjectId/ISODate rewriting, key quoting, and paren/arg splitting. */ /** Normalize a shell argument to JSON text the backend can parse, or null if invalid. */ export function normalizeJsonArgument(value: string): string | null { const trimmed = value.trim(); if (!trimmed) return "{}"; const withoutComments = stripMongoJsonComments(trimmed).trim(); if (!withoutComments) return "{}"; const withoutEjsonDeserialize = replaceMongoEjsonDeserialize(withoutComments); // Rewrite mongo shell constructors that are not valid JSON into extended JSON // (mongo_driver::json_value_to_bson): ObjectId / NumberLong / ISODate / new Date. const withExtendedJson = replaceMongoShellConstructors(withoutEjsonDeserialize); const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson)); try { JSON.parse(preprocessed); return preprocessed; } catch { return null; } } /** Object-shaped shell arg (options documents, etc.). */ export function parseMongoObjectArgument(arg: string | undefined): string | null { if (!arg?.trim()) return null; const normalized = normalizeJsonArgument(arg); if (!normalized) return null; try { const value = JSON.parse(normalized) as unknown; return value !== null && typeof value === "object" && !Array.isArray(value) ? normalized : null; } catch { return null; } } export function parseCollectionMethodTarget(source: string, method: string): { collection: string; methodCallIndex: number } | null { const escapedMethod = escapeRegExp(method); const direct = new RegExp(`^db\\s*\\.\\s*([A-Za-z_$][\\w$]*)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source); if (direct) { return { collection: direct[1]!, methodCallIndex: findChainedMethodCallIndex(source, method) }; } const getCollection = new RegExp(`^db\\s*\\.\\s*getCollection\\s*\\(\\s*(["'])(.*?)\\1\\s*\\)\\s*\\.\\s*${escapedMethod}\\s*\\(`).exec(source); if (getCollection) { return { collection: getCollection[2]!, methodCallIndex: findChainedMethodCallIndex(source, method) }; } return null; } export function findChainedMethodCallIndex(source: string, method: string): number { return chainedMethodCallPattern(method).exec(source)?.index ?? -1; } export function chainedMethodCallPattern(method: string): RegExp { return new RegExp(`\\.\\s*${escapeRegExp(method)}\\s*\\(`, "g"); } export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } export function splitTopLevel(source: string): string[] { const parts: string[] = []; let start = 0; let depth = 0; let quote: string | null = null; let escaped = false; for (let i = 0; i < source.length; i += 1) { const char = source[i]; if (quote) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === quote) quote = null; continue; } const commentEnd = mongoCommentEndAt(source, i); if (commentEnd !== null) { i = commentEnd - 1; continue; } if (char === '"' || char === "'") quote = char; else if (char === "{" || char === "[" || char === "(") depth += 1; else if (char === "}" || char === "]" || char === ")") depth -= 1; else if (char === "," && depth === 0) { parts.push(source.slice(start, i).trim()); start = i + 1; } } parts.push(source.slice(start).trim()); return parts; } export function findMatchingParen(source: string, openIndex: number): number { if (openIndex < 0 || source[openIndex] !== "(") return -1; let depth = 0; let quote: string | null = null; let escaped = false; for (let i = openIndex; i < source.length; i += 1) { const char = source[i]; if (quote) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === quote) quote = null; continue; } const commentEnd = mongoCommentEndAt(source, i); if (commentEnd !== null) { i = commentEnd - 1; continue; } if (char === '"' || char === "'") quote = char; else if (char === "(") depth += 1; else if (char === ")") { depth -= 1; if (depth === 0) return i; } } return -1; } /** True when (), [], {}, or quotes are unbalanced. */ export function hasUnclosedMongoDelimiters(source: string): boolean { const stack: string[] = []; let quote: string | null = null; let escaped = false; for (let i = 0; i < source.length; i += 1) { const char = source[i] ?? ""; if (quote) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === quote) quote = null; continue; } const commentEnd = mongoCommentEndAt(source, i); if (commentEnd !== null) { i = commentEnd - 1; continue; } if (char === '"' || char === "'") { quote = char; continue; } if (char === "(" || char === "[" || char === "{") { stack.push(char); continue; } if (char === ")" || char === "]" || char === "}") { const expected = char === ")" ? "(" : char === "]" ? "[" : "{"; if (stack.pop() !== expected) return true; } } return quote !== null || stack.length > 0; } /** Remove shell/SQL-style comments from JSON-like Mongo arguments. */ export function stripMongoJsonComments(source: string): string { let result = ""; let quote: string | null = null; let escaped = false; for (let i = 0; i < source.length; i += 1) { const char = source[i] ?? ""; if (quote) { result += char; if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === quote) quote = null; continue; } if (char === '"' || char === "'") { quote = char; result += char; continue; } const commentEnd = mongoCommentEndAt(source, i); if (commentEnd !== null) { result += source .slice(i, commentEnd) .replace(/[^\n\r\u2028\u2029]/g, " ") .replace(/[\u2028\u2029]/g, "\n"); i = commentEnd - 1; continue; } result += char; } return result; } /** Strip leading line/block comments (//, --, and block comments). */ export function trimMongoOuterComments(source: string): string { let text = source; for (;;) { const trimmed = text.trimStart(); if (trimmed.startsWith("//") || trimmed.startsWith("--")) { const end = mongoLineCommentEnd(trimmed, 2); text = end >= trimmed.length ? "" : trimmed.slice(end); continue; } if (trimmed.startsWith("/*")) { const end = trimmed.indexOf("*/"); if (end < 0) return trimmed; text = trimmed.slice(end + 2); continue; } return trimmed.trimEnd(); } } function mongoCommentEndAt(source: string, index: number): number | null { const current = source[index]; const next = source[index + 1]; if ((current === "/" && next === "/") || (current === "-" && next === "-")) { return mongoLineCommentEnd(source, index + 2); } if (current === "/" && next === "*") { const end = source.indexOf("*/", index + 2); return end < 0 ? source.length : end + 2; } return null; } function mongoLineCommentEnd(source: string, start: number): number { for (let index = start; index < source.length; index += 1) { const char = source[index]; if (char === "\r") return source[index + 1] === "\n" ? index + 2 : index + 1; if (char === "\n" || char === "\u2028" || char === "\u2029") return index + 1; } return source.length; } function removeTrailingCommas(source: string): string { let result = ""; let inString = false; let escaped = false; for (let index = 0; index < source.length; index += 1) { const char = source[index] ?? ""; if (inString) { result += char; if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === '"') inString = false; continue; } if (char === '"') { inString = true; result += char; continue; } if (char === ",") { let next = index + 1; while (/\s/.test(source[next] ?? "")) next += 1; if (source[next] === "}" || source[next] === "]") continue; } result += char; } return result; } export function quoteUnquotedObjectKeys(source: string): string { let result = ""; let quote: string | null = null; let escaped = false; for (let i = 0; i < source.length; i += 1) { const char = source[i] ?? ""; if (quote) { result += char; if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === quote) quote = null; continue; } if (char === '"' || char === "'") { quote = char; result += char; continue; } if (/[A-Za-z_$]/.test(char) && shouldQuoteObjectKey(source, i)) { let end = i + 1; while (/[\w$]/.test(source[end] || "")) end += 1; result += `"${source.slice(i, end)}"`; i = end - 1; continue; } result += char; } return result; } function shouldQuoteObjectKey(source: string, index: number): boolean { let before = index - 1; while (/\s/.test(source[before] || "")) before -= 1; if (source[before] !== "{" && source[before] !== ",") return false; let after = index + 1; while (/[\w$]/.test(source[after] || "")) after += 1; while (/\s/.test(source[after] || "")) after += 1; return source[after] === ":"; } function replaceMongoEjsonDeserialize(source: string): string { const callPattern = /^EJSON\s*\.\s*deserialize\s*\(/; let result = ""; let index = 0; while (index < source.length) { const quote = source[index]; if (quote === '"' || quote === "'") { const start = index++; while (index < source.length) { if (source[index] === "\\") index += 2; else if (source[index] === quote) { index++; break; } else index++; } result += source.slice(start, index); continue; } const match = source.slice(index).match(callPattern); if (!match) { result += source[index++]!; continue; } const openIndex = index + match[0].lastIndexOf("("); const closeIndex = findMatchingParen(source, openIndex); if (closeIndex < 0) { result += source[index++]!; continue; } const args = splitTopLevel(source.slice(openIndex + 1, closeIndex)); if (args.length !== 1 || !args[0]?.trim()) { result += source.slice(index, closeIndex + 1); index = closeIndex + 1; continue; } result += args[0].trim(); index = closeIndex + 1; } return result; } function replaceMongoShellConstructors(source: string): string { const constructor = /^(ObjectId|NumberLong|ISODate)\s*\(\s*["']([^"']+)["']\s*\)|^(ObjectId|NumberLong)\s*\(\s*(-?\d+)\s*\)|^(?:new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/; let result = ""; let index = 0; while (index < source.length) { const quote = source[index]; if (quote === '"' || quote === "'") { const start = index++; while (index < source.length) { if (source[index] === "\\") index += 2; else if (source[index] === quote) { index++; break; } else index++; } result += source.slice(start, index); continue; } const match = source.slice(index).match(constructor); if (!match) { result += source[index++]!; continue; } if (match[1]) { result += match[1] === "ObjectId" ? `{"$oid":"${match[2]}"}` : match[1] === "NumberLong" ? `{"$numberLong":"${match[2]}"}` : `{"$date":"${match[2]}"}`; } else if (match[3]) { result += match[3] === "NumberLong" ? `{"$numberLong":"${match[4]}"}` : `{"$oid":"${match[4]}"}`; } else { result += `{"$date":"${match[5]}"}`; } index += match[0].length; } return result; } function convertSingleQuotedStrings(source: string): string { let result = ""; let copiedUntil = 0; let quote: string | null = null; let start = 0; let value = ""; let escaped = false; for (let i = 0; i < source.length; i += 1) { const char = source[i]; if (!quote) { if (char === "'") { quote = char; start = i; value = ""; escaped = false; } else if (char === '"') { quote = char; } continue; } if (quote === '"') { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === '"') quote = null; continue; } if (escaped) { value += char; escaped = false; } else if (char === "\\") { escaped = true; } else if (char === "'") { result += source.slice(copiedUntil, start) + JSON.stringify(value); copiedUntil = i + 1; quote = null; } else { value += char; } } const converted = quote === "'" ? source : result + source.slice(copiedUntil); return removeTrailingCommas(converted); }