Files
dbx/apps/desktop/src/lib/mongoShellCommand.ts
T
gggdsgandCursor e7ec2ecaf2 fix(mongodb): route find result sorting through shell sort clauses
Use find().sort() for MongoDB query grid sorting instead of SQL ORDER BY,
and normalize document browser sort input the same way as filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 15:47:38 +08:00

1095 lines
35 KiB
TypeScript

import type { QueryResult } from "@/types/database";
export interface MongoFindCommand {
collection: string;
filter: string;
projection?: string;
skip: number;
limit: number;
sort?: string;
}
export interface MongoCountDocumentsCommand {
collection: string;
filter: string;
}
export interface MongoAggregateCommand {
collection: string;
pipeline: string;
}
export interface MongoGetIndexesCommand {
collection: string;
}
export interface MongoUseCommand {
database: string;
}
export interface MongoVersionCommand {
kind: "version";
}
type MongoWriteKind = "insert" | "update" | "delete" | "createIndex" | "dropIndex" | "dropIndexes";
export type MongoCommand =
| ({ kind: "find" } & MongoFindCommand)
| MongoVersionCommand
| ({ kind: "countDocuments" } & MongoCountDocumentsCommand)
| ({ kind: "aggregate" } & MongoAggregateCommand)
| ({ kind: "getIndexes" } & MongoGetIndexesCommand)
| ({ kind: "use" } & MongoUseCommand)
| { kind: "insert"; collection: string; docsJson: string }
| { kind: "update"; collection: string; filter: string; update: string; many: boolean }
| { kind: "delete"; collection: string; filter: string; many: boolean }
| { kind: "createIndex"; collection: string; keys: string; options?: string }
| { kind: "dropIndex"; collection: string; index: string }
| { kind: "dropIndexes"; collection: string; indexes?: string };
export type MongoWriteCommand = Extract<MongoCommand, { kind: MongoWriteKind }>;
export interface ParsedMongoCommand {
text: string;
command: MongoCommand;
}
export interface ParsedMongoCommandRange extends ParsedMongoCommand {
from: number;
to: number;
}
export interface MongoAggregateSafetyOptions {
allowWrites?: boolean;
allowDangerous?: boolean;
}
const DEFAULT_LIMIT = 100;
export function parseMongoFindCommand(input: string): MongoFindCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseFindTarget(source);
if (!target) return null;
const findOpenIndex = source.indexOf("(", target.findCallIndex);
const findCloseIndex = findMatchingParen(source, findOpenIndex);
if (findCloseIndex < 0) return null;
const findArgs = splitTopLevel(source.slice(findOpenIndex + 1, findCloseIndex));
if (findArgs.length > 2 && findArgs.slice(2).some((arg) => arg.trim())) return null;
const filter = normalizeJsonArgument(findArgs[0] || "{}");
if (!filter) return null;
let projection: string | undefined;
if (findArgs[1]?.trim()) {
const parsedProjection = normalizeJsonArgument(findArgs[1]);
if (!parsedProjection) return null;
projection = parsedProjection;
}
const chain = source.slice(findCloseIndex + 1).trim();
if (chain && !chain.startsWith(".")) return null;
if (findChainedMethodCallIndex(chain, "count") >= 0) return null;
const sortArg = readChainedCallArgument(chain, "sort");
let sort: string | undefined;
if (sortArg !== undefined) {
const parsedSort = normalizeJsonArgument(sortArg);
if (!parsedSort) return null;
sort = parsedSort;
}
const skip = readChainedIntegerArgument(chain, "skip", 0);
const limit = readChainedIntegerArgument(chain, "limit", DEFAULT_LIMIT);
if (skip === null || limit === null) return null;
return {
collection: target.collection,
filter,
...(projection ? { projection } : {}),
skip,
limit,
sort,
};
}
export function applyMongoFindSort(input: string, column: string, direction: "asc" | "desc"): string | null {
const source = input.trim().replace(/;$/, "").trim();
const parsed = parseMongoFindCommand(source);
if (!parsed) return null;
const target = parseFindTarget(source);
if (!target) return null;
const findOpenIndex = source.indexOf("(", target.findCallIndex);
const findCloseIndex = findMatchingParen(source, findOpenIndex);
if (findCloseIndex < 0) return null;
const prefix = source.slice(0, findCloseIndex + 1);
const chainSource = source.slice(findCloseIndex + 1).trim();
if (chainSource && !chainSource.startsWith(".")) return null;
const chain = removeChainedMethodCall(chainSource, "sort");
const sortCall = `.sort(${JSON.stringify({ [column]: direction === "asc" ? 1 : -1 })})`;
return `${prefix}${sortCall}${chain}`;
}
export function parseMongoCountDocumentsCommand(input: string): MongoCountDocumentsCommand | null {
const source = input.trim().replace(/;$/, "").trim();
// Accept deprecated Mongo shell count helpers for old server workflows, but
// keep DBX's internal execution mapped to the countDocuments result shape.
return parseCollectionCountCommand(source, "countDocuments") ?? parseCollectionCountCommand(source, "count") ?? parseFindCountCommand(source);
}
function parseCollectionCountCommand(source: string, method: "countDocuments" | "count"): MongoCountDocumentsCommand | null {
const target = parseCollectionMethodTarget(source, method);
if (!target) return null;
const openIndex = source.indexOf("(", target.methodCallIndex);
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex < 0 || source.slice(closeIndex + 1).trim()) return null;
const args = splitTopLevel(source.slice(openIndex + 1, closeIndex));
if (args.length > 1 && args.slice(1).some((arg) => arg.trim())) return null;
const filter = normalizeJsonArgument(args[0] || "{}");
if (!filter) return null;
return {
collection: target.collection,
filter,
};
}
function parseFindCountCommand(source: string): MongoCountDocumentsCommand | null {
const target = parseFindTarget(source);
if (!target) return null;
const findOpenIndex = source.indexOf("(", target.findCallIndex);
const findCloseIndex = findMatchingParen(source, findOpenIndex);
if (findCloseIndex < 0) return null;
const chain = source.slice(findCloseIndex + 1).trim();
if (!hasSingleEmptyChainedCall(chain, "count")) return null;
const findArgs = splitTopLevel(source.slice(findOpenIndex + 1, findCloseIndex));
if (findArgs.length > 2 && findArgs.slice(2).some((arg) => arg.trim())) return null;
const filter = normalizeJsonArgument(findArgs[0] || "{}");
if (!filter) return null;
return {
collection: target.collection,
filter,
};
}
export function parseMongoAggregateCommand(input: string): MongoAggregateCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "aggregate");
if (!target) return null;
const openIndex = source.indexOf("(", target.methodCallIndex);
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex < 0 || source.slice(closeIndex + 1).trim()) return null;
const args = splitTopLevel(source.slice(openIndex + 1, closeIndex));
if (args.length !== 1) return null;
const pipeline = normalizeJsonArgument(args[0]);
if (!pipeline) return null;
try {
if (!Array.isArray(JSON.parse(pipeline))) return null;
} catch {
return null;
}
return {
collection: target.collection,
pipeline,
};
}
export function parseMongoGetIndexesCommand(input: string): MongoGetIndexesCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const target = parseCollectionMethodTarget(source, "getIndexes");
if (!target) return null;
const openIndex = source.indexOf("(", target.methodCallIndex);
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex < 0 || source.slice(closeIndex + 1).trim()) return null;
const args = splitTopLevel(source.slice(openIndex + 1, closeIndex));
if (args.some((arg) => arg.trim())) return null;
return {
collection: target.collection,
};
}
export function parseMongoUseCommand(input: string): MongoUseCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const match = /^use\s+([a-zA-Z0-9_-]+)$/i.exec(source);
if (!match) return null;
return {
database: match[1],
};
}
export function parseMongoVersionCommand(input: string): MongoVersionCommand | null {
const source = input.trim().replace(/;$/, "").trim();
return /^db\s*\.\s*version\s*\(\s*\)$/i.test(source) ? { kind: "version" } : null;
}
export function parseMongoWriteCommand(input: string): MongoWriteCommand | null {
const source = input.trim().replace(/;$/, "").trim();
const insertOne = parseCollectionMethodTarget(source, "insertOne");
if (insertOne) {
const args = parseMethodArgs(source, insertOne.methodCallIndex);
if (!args || args.length !== 1) return null;
const doc = normalizeJsonArgument(args[0]);
return doc ? { kind: "insert", collection: insertOne.collection, docsJson: doc } : null;
}
const insertMany = parseCollectionMethodTarget(source, "insertMany");
if (insertMany) {
const args = parseMethodArgs(source, insertMany.methodCallIndex);
if (!args || args.length !== 1) return null;
const docs = normalizeJsonArgument(args[0]);
if (!docs) return null;
return Array.isArray(JSON.parse(docs)) ? { kind: "insert", collection: insertMany.collection, docsJson: docs } : null;
}
for (const method of ["updateOne", "updateMany"] as const) {
const target = parseCollectionMethodTarget(source, method);
if (!target) continue;
const args = parseMethodArgs(source, target.methodCallIndex);
if (!args || args.length !== 2) return null;
const filter = normalizeJsonArgument(args[0]);
const update = normalizeJsonArgument(args[1]);
if (!filter || !update) return null;
return { kind: "update", collection: target.collection, filter, update, many: method === "updateMany" };
}
for (const method of ["deleteOne", "deleteMany"] as const) {
const target = parseCollectionMethodTarget(source, method);
if (!target) continue;
const args = parseMethodArgs(source, target.methodCallIndex);
if (!args || args.length !== 1) return null;
const filter = normalizeJsonArgument(args[0]);
if (!filter) return null;
return { kind: "delete", collection: target.collection, filter, many: method === "deleteMany" };
}
const createIndex = parseCollectionMethodTarget(source, "createIndex");
if (createIndex) {
const args = parseMethodArgs(source, createIndex.methodCallIndex);
if (!args || args.length < 1 || args.length > 2) return null;
const keys = normalizeJsonArgument(args[0]);
if (!keys) return null;
let options: string | undefined;
if (args[1]?.trim()) {
const parsedOptions = normalizeJsonArgument(args[1]);
if (!parsedOptions) return null;
options = parsedOptions;
}
return { kind: "createIndex", collection: createIndex.collection, keys, ...(options ? { options } : {}) };
}
const dropIndex = parseCollectionMethodTarget(source, "dropIndex");
if (dropIndex) {
const args = parseMethodArgs(source, dropIndex.methodCallIndex);
if (!args) return null;
const index = parseMongoDropIndexArgument(args);
return index ? { kind: "dropIndex", collection: dropIndex.collection, index } : null;
}
const dropIndexes = parseCollectionMethodTarget(source, "dropIndexes");
if (dropIndexes) {
const args = parseMethodArgs(source, dropIndexes.methodCallIndex);
if (!args) return null;
const indexes = parseMongoDropIndexesArgument(args);
return indexes !== null ? { kind: "dropIndexes", collection: dropIndexes.collection, ...(indexes ? { indexes } : {}) } : null;
}
return null;
}
export function parseMongoCommand(input: string): ParsedMongoCommand | null {
const text = trimMongoOuterComments(input);
if (!text) return null;
// Keep the more specific readers ahead of generic write parsing so the
// returned kind matches the result renderer we want to use downstream.
const parsers: Array<(source: string) => MongoCommand | null> = [
(source) => {
const version = parseMongoVersionCommand(source);
return version ?? null;
},
(source) => {
// Legacy Mongo shell uses count()/find().count(); keep accepting it
// while mapping to DBX's countDocuments-compatible result path.
const count = parseMongoCountDocumentsCommand(source);
return count ? { kind: "countDocuments", ...count } : null;
},
(source) => {
const find = parseMongoFindCommand(source);
return find ? { kind: "find", ...find } : null;
},
(source) => {
const aggregate = parseMongoAggregateCommand(source);
return aggregate ? { kind: "aggregate", ...aggregate } : null;
},
(source) => {
const getIndexes = parseMongoGetIndexesCommand(source);
return getIndexes ? { kind: "getIndexes", ...getIndexes } : null;
},
(source) => {
const write = parseMongoWriteCommand(source);
return write ?? null;
},
(source) => {
const use = parseMongoUseCommand(source);
return use ? { kind: "use", ...use } : null;
},
];
for (const parse of parsers) {
const command = parse(text);
if (command) return { text, command };
}
return null;
}
export function splitMongoCommands(input: string): ParsedMongoCommand[] {
return splitMongoCommandRanges(input).map(({ from: _from, to: _to, ...command }) => command);
}
export function splitMongoCommandRanges(input: string): ParsedMongoCommandRange[] {
const commands: ParsedMongoCommandRange[] = [];
for (const segment of splitMongoCommandTextRanges(input)) {
const parsed = parseMongoCommand(segment.text);
if (!parsed) return [];
commands.push({ from: segment.from, to: segment.to, ...parsed });
}
return commands;
}
export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: MongoAggregateSafetyOptions): { allowed: boolean; reason?: string } {
if (!options.allowWrites) {
return {
allowed: false,
reason: "MCP MongoDB execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
};
}
if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete") && isEmptyJsonObject(command.filter)) {
return {
allowed: false,
reason: "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
};
}
if (!options.allowDangerous && mongoDropIndexesRequiresDangerous(command)) {
return {
allowed: false,
reason: "MongoDB dropIndexes() without a specific single index requires DBX_MCP_ALLOW_DANGEROUS_SQL=1.",
};
}
return { allowed: true };
}
export function mongoAggregateWriteStage(pipelineJson: string): "$out" | "$merge" | null {
try {
const pipeline = JSON.parse(pipelineJson);
if (!Array.isArray(pipeline)) return null;
for (const stage of pipeline) {
if (!isRecord(stage)) continue;
if (Object.prototype.hasOwnProperty.call(stage, "$out")) return "$out";
if (Object.prototype.hasOwnProperty.call(stage, "$merge")) return "$merge";
}
} catch {
return null;
}
return null;
}
export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, options: MongoAggregateSafetyOptions): { allowed: boolean; reason?: string } {
const writeStage = mongoAggregateWriteStage(command.pipeline);
if (!writeStage) return { allowed: true };
if (!options.allowWrites) {
return {
allowed: false,
reason: `MongoDB aggregate stage "${writeStage}" writes data. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.`,
};
}
if (!options.allowDangerous) {
return {
allowed: false,
reason: `MongoDB aggregate stage "${writeStage}" is dangerous. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.`,
};
}
return { allowed: true };
}
export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number): QueryResult {
const columns: string[] = [];
for (const doc of documents) {
if (isRecord(doc)) {
for (const key of Object.keys(doc)) {
if (!columns.includes(key)) columns.push(key);
}
} else if (!columns.includes("value")) {
columns.push("value");
}
}
const rows = documents.map((doc) => {
if (isRecord(doc)) return columns.map((column) => toCellValue(doc[column]));
return columns.map((column) => (column === "value" ? toCellValue(doc) : null));
});
return {
columns,
rows,
affected_rows: total,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
truncated: total > documents.length,
};
}
export function mongoCountToQueryResult(total: number, executionTimeMs: number): QueryResult {
return {
columns: ["count"],
rows: [[total]],
affected_rows: total,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
export function mongoWriteToQueryResult(affectedRows: number, executionTimeMs: number): QueryResult {
return {
columns: [],
rows: [],
affected_rows: affectedRows,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
export function mongoCreateIndexToQueryResult(name: string, executionTimeMs: number): QueryResult {
return {
columns: ["name"],
rows: [[name]],
affected_rows: 1,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
export function mongoDroppedIndexesToQueryResult(names: string[], executionTimeMs: number): QueryResult {
return {
columns: ["name"],
rows: names.map((name) => [name]),
affected_rows: names.length,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
export function mongoUseToQueryResult(database: string, executionTimeMs: number): QueryResult {
return {
columns: ["message"],
rows: [[`switched to db ${database}`]],
affected_rows: 0,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
export function mongoVersionToQueryResult(version: string, executionTimeMs: number): QueryResult {
return {
columns: ["version"],
rows: [[version]],
affected_rows: 1,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
export function mongoIndexesToQueryResult(
indexes: {
name: string;
columns: string[];
is_unique: boolean;
is_primary: boolean;
filter?: string | null;
index_type?: string | null;
included_columns?: string[] | null;
comment?: string | null;
}[],
executionTimeMs: number,
): QueryResult {
return {
columns: ["name", "columns", "unique", "primary", "type", "filter"],
rows: indexes.map((index) => [index.name, index.columns.join(", "), index.is_unique, index.is_primary, index.index_type ?? null, index.filter ?? null]),
affected_rows: indexes.length,
execution_time_ms: Math.max(0, Math.round(executionTimeMs)),
};
}
function parseFindTarget(source: string): { collection: string; findCallIndex: number } | null {
const direct = parseCollectionMethodTarget(source, "find");
if (direct) {
return { collection: direct.collection, findCallIndex: direct.methodCallIndex };
}
return null;
}
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;
}
function normalizeJsonArgument(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return "{}";
const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(trimmed.replace(/ObjectId\s*\(\s*["']([^"']+)["']\s*\)/g, '{"$oid":"$1"}')));
try {
JSON.parse(preprocessed);
return preprocessed;
} catch {
return null;
}
}
function parseMethodArgs(source: string, methodCallIndex: number): string[] | null {
const openIndex = source.indexOf("(", methodCallIndex);
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex < 0 || source.slice(closeIndex + 1).trim()) return null;
return splitTopLevel(source.slice(openIndex + 1, closeIndex));
}
interface MongoTextRange {
from: number;
to: number;
text: string;
}
function splitMongoCommandTextRanges(input: string): MongoTextRange[] {
const commands: MongoTextRange[] = [];
for (const segment of splitMongoSemicolonSeparatedSegments(input)) {
const parsed = parseMongoCommand(segment.text);
if (parsed) {
commands.push({ ...segment, text: parsed.text });
continue;
}
// Mongo shell users often omit semicolons and rely on one top-level
// command per line, so fall back to a conservative newline split.
const softSplit = splitMongoSegmentAtSoftStarts(segment);
if (softSplit.length > 1) {
commands.push(...softSplit);
continue;
}
commands.push(segment);
}
return commands;
}
function splitMongoSemicolonSeparatedSegments(input: string): MongoTextRange[] {
const segments: MongoTextRange[] = [];
let start = 0;
let depth = 0;
let quote: string | null = null;
let escaped = false;
let lineComment = false;
let blockComment = false;
// Respect semicolons only when they appear at the top level; JSON literals,
// strings and comments are allowed to contain semicolons verbatim.
for (let i = 0; i < input.length; i += 1) {
const char = input[i] ?? "";
const next = input[i + 1] ?? "";
if (lineComment) {
if (char === "\n") lineComment = false;
continue;
}
if (blockComment) {
if (char === "*" && next === "/") {
blockComment = false;
i += 1;
}
continue;
}
if (quote) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === quote) quote = null;
continue;
}
if (char === "/" && next === "/") {
lineComment = true;
i += 1;
continue;
}
if (char === "/" && next === "*") {
blockComment = true;
i += 1;
continue;
}
if (char === '"' || char === "'" || char === "`") {
quote = char;
continue;
}
if (char === "{" || char === "[" || char === "(") depth += 1;
else if ((char === "}" || char === "]" || char === ")") && depth > 0) depth -= 1;
else if (char === ";" && depth === 0) {
pushMongoSegment(segments, input, start, i);
start = i + 1;
}
}
pushMongoSegment(segments, input, start, input.length);
return segments;
}
function splitMongoSegmentAtSoftStarts(segment: MongoTextRange): MongoTextRange[] {
const boundaries = mongoTopLevelCommandLineStarts(segment.text);
if (boundaries.length <= 1) return [segment];
const segments: MongoTextRange[] = [];
let start = boundaries[0] ?? 0;
for (let index = 1; index < boundaries.length; index += 1) {
const boundary = boundaries[index] ?? 0;
const candidate = trimMongoOuterCommentRange(segment.text, start, boundary);
// Only accept newline-based splitting when every slice is a valid command;
// otherwise keep the original text intact and let normal parsing reject it.
if (!candidate || !parseMongoCommand(candidate.text)) return [segment];
segments.push({
from: segment.from + candidate.from,
to: segment.from + candidate.to,
text: candidate.text,
});
start = boundary;
}
const last = trimMongoOuterCommentRange(segment.text, start, segment.text.length);
if (!last || !parseMongoCommand(last.text)) return [segment];
segments.push({
from: segment.from + last.from,
to: segment.from + last.to,
text: last.text,
});
return segments;
}
function mongoTopLevelCommandLineStarts(segment: string): number[] {
const starts: number[] = [];
let depth = 0;
let quote: string | null = null;
let escaped = false;
let lineComment = false;
let blockComment = false;
let lineStart = 0;
let firstNonWhitespaceOnLine = -1;
for (let i = 0; i < segment.length; i += 1) {
const char = segment[i] ?? "";
const next = segment[i + 1] ?? "";
if (char === "\n") {
if (lineComment) lineComment = false;
lineStart = i + 1;
firstNonWhitespaceOnLine = -1;
continue;
}
if (lineComment) continue;
if (blockComment) {
if (char === "*" && next === "/") {
blockComment = false;
i += 1;
}
continue;
}
if (quote) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === quote) quote = null;
continue;
}
if (char === "/" && next === "/") {
lineComment = true;
i += 1;
continue;
}
if (char === "/" && next === "*") {
blockComment = true;
i += 1;
continue;
}
if (char === '"' || char === "'" || char === "`") {
if (firstNonWhitespaceOnLine === -1 && !/\s/.test(char)) firstNonWhitespaceOnLine = i;
quote = char;
continue;
}
if (char === "{" || char === "[" || char === "(") depth += 1;
else if ((char === "}" || char === "]" || char === ")") && depth > 0) depth -= 1;
if (firstNonWhitespaceOnLine === -1 && !/\s/.test(char)) {
firstNonWhitespaceOnLine = i;
if (depth === 0 && char !== "." && isMongoCommandLineStart(segment, i)) starts.push(i);
}
}
return starts.length > 0 ? starts : [lineStart];
}
function isMongoCommandLineStart(segment: string, index: number): boolean {
const rest = segment.slice(index);
return /^use\b/i.test(rest) || /^db(?:\s*\.|\b)/i.test(rest);
}
function pushMongoSegment(segments: MongoTextRange[], source: string, from: number, to: number) {
const trimmed = trimMongoOuterCommentRange(source, from, to);
if (trimmed) segments.push(trimmed);
}
function trimMongoOuterComments(source: string): string {
let value = source.trim();
while (value) {
const next = value.replace(/^(?:\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*/u, "");
if (next === value) break;
value = next.trimStart();
}
while (value) {
const next = value.replace(/\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*$/u, "");
if (next === value) break;
value = next.trimEnd();
}
return value.trim();
}
function trimMongoOuterCommentRange(source: string, from: number, to: number): MongoTextRange | null {
let start = from;
let end = to;
while (start < end) {
const value = source.slice(start, end);
const trimmed = value.trimStart();
if (trimmed !== value) {
start += value.length - trimmed.length;
continue;
}
const next = value.replace(/^(?:\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*/u, "");
if (next !== value) {
start += value.length - next.length;
continue;
}
break;
}
while (start < end) {
const value = source.slice(start, end);
const trimmed = value.trimEnd();
if (trimmed !== value) {
end -= value.length - trimmed.length;
continue;
}
const next = value.replace(/\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*$/u, "");
if (next !== value) {
end -= value.length - next.length;
continue;
}
break;
}
if (start >= end) return null;
return {
from: start,
to: end,
text: source.slice(start, end),
};
}
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;
}
}
return quote === "'" ? source : result + source.slice(copiedUntil);
}
function parseMongoDropIndexArgument(args: string[]): string | null {
if (args.length !== 1 || !args[0]?.trim()) return null;
const normalized = normalizeJsonArgument(args[0]);
if (!normalized) return null;
const parsed = parseNormalizedJson(normalized);
if (typeof parsed === "string") return parsed === "*" ? null : normalized;
return isNonEmptyRecord(parsed) ? normalized : null;
}
function parseMongoDropIndexesArgument(args: string[]): string | undefined | null {
if (args.length !== 1) return null;
if (!args[0]?.trim()) return undefined;
const normalized = normalizeJsonArgument(args[0]);
if (!normalized) return null;
const parsed = parseNormalizedJson(normalized);
if (typeof parsed === "string") return normalized;
if (isNonEmptyRecord(parsed)) return normalized;
return Array.isArray(parsed) && parsed.length > 0 && parsed.every((item) => typeof item === "string") ? normalized : null;
}
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 readChainedIntegerArgument(source: string, name: string, fallback: number): number | null {
const raw = readChainedCallArgument(source, name);
if (raw === undefined) return fallback;
const value = Number(raw.trim());
if (!Number.isSafeInteger(value) || value < 0) return null;
return value;
}
function removeChainedMethodCall(chain: string, name: string): string {
if (!chain.trim()) return "";
let result = chain.trim();
const pattern = chainedMethodCallPattern(name);
let match: RegExpExecArray | null;
while ((match = pattern.exec(result)) !== null) {
const openIndex = result.indexOf("(", match.index);
const closeIndex = findMatchingParen(result, openIndex);
if (closeIndex < 0) break;
result = `${result.slice(0, match.index)}${result.slice(closeIndex + 1)}`.trim();
pattern.lastIndex = 0;
}
return result;
}
function readChainedCallArgument(source: string, name: string): string | undefined {
const pattern = chainedMethodCallPattern(name);
let match = pattern.exec(source);
while (match) {
const openIndex = source.indexOf("(", match.index);
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex >= 0) return source.slice(openIndex + 1, closeIndex);
match = pattern.exec(source);
}
return undefined;
}
function hasSingleEmptyChainedCall(source: string, name: string): boolean {
const trimmed = source.trim();
const match = chainedMethodCallPattern(name).exec(trimmed);
if (!match || match.index !== 0) return false;
const openIndex = trimmed.indexOf("(", match.index);
const closeIndex = findMatchingParen(trimmed, openIndex);
return closeIndex >= 0 && !trimmed.slice(openIndex + 1, closeIndex).trim() && !trimmed.slice(closeIndex + 1).trim();
}
function findChainedMethodCallIndex(source: string, name: string): number {
return chainedMethodCallPattern(name).exec(source)?.index ?? -1;
}
function chainedMethodCallPattern(name: string): RegExp {
return new RegExp(`\\.\\s*${escapeRegExp(name)}\\s*\\(`, "g");
}
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;
}
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;
}
function findMatchingParen(source: string, openIndex: number): number {
if (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;
}
if (char === '"' || char === "'") quote = char;
else if (char === "(") depth += 1;
else if (char === ")") {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function parseNormalizedJson(json: string): unknown {
try {
return JSON.parse(json);
} catch {
return undefined;
}
}
function isNonEmptyRecord(value: unknown): value is Record<string, unknown> {
return isRecord(value) && Object.keys(value).length > 0;
}
function isEmptyJsonObject(json: string): boolean {
const parsed = parseNormalizedJson(json);
return isRecord(parsed) && Object.keys(parsed).length === 0;
}
function mongoDropIndexesRequiresDangerous(command: MongoWriteCommand): boolean {
if (command.kind !== "dropIndexes") return false;
if (!command.indexes) return true;
const parsed = parseNormalizedJson(command.indexes);
if (parsed === "*") return true;
return Array.isArray(parsed) && parsed.length > 1;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toCellValue(value: unknown): string | number | boolean | null {
if (value === undefined || value === null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
return JSON.stringify(value);
}