import { mongoExtendedJsonValueType } from "@/lib/mongo/mongoDocumentValues"; import { ACCUMULATORS, COMMON_OPERATORS, EXPRESSION_OPERATORS, EXTENDED_JSON_VALUES, FIELD_QUERY_OPERATORS, PIPELINE_STAGES, PUSH_MODIFIERS, STAGE_OPTION_KEYS, TOP_LEVEL_QUERY_OPERATORS, UPDATE_OPERATORS, UPDATE_OPERATOR_LABELS, VALUE_SNIPPETS, mongoOperatorItemType, type MongoOperatorSpec, } from "@/lib/mongo/mongoCompletionTables"; /** * What the cursor may usefully be completed with. Each mode maps to exactly one * item source, so `buildMongoCompletionItemsFromContext` never has to re-derive * position from text — `getMongoCompletionContext` already did. * * `none` means "the cursor is somewhere we have nothing useful to say" (a string * literal value, an argument we do not model, …). It yields an empty list, which * the editor turns into "no popup" — deliberately better than falling back to * `root` and showing unrelated `db.…` snippets mid-document. */ export type MongoCompletionMode = | "none" | "root" | "collection" | "collectionOrMethod" | "collectionRef" | "method" | "cursorMethod" | "field" | "filterField" | "fieldPath" | "fieldRef" | "value" | "valueWrapper" | "queryOperator" | "updateOperator" | "pushModifier" | "expression" | "accumulator" | "stage" | "stageOption"; export interface MongoCompletionField { name: string; type?: string; } export interface MongoCompletionItem { label: string; type: "column" | "function" | "keyword" | "snippet" | "table"; detail?: string; info?: string; apply?: string; filterText?: string; replaceClosingQuote?: '"' | "'"; boost: number; } export interface MongoCompletionContext { mode: MongoCompletionMode; prefix: string; from: number; /** The selected collection name should consume the existing closing quote. */ replaceClosingQuote?: '"' | "'"; /** Collection the cursor's command targets, used to load field metadata. */ collection?: string; /** Enclosing aggregation stage (`$lookup`, `$group`, …), when inside one. */ stage?: string; } export interface MongoCompletionInput { collections?: string[]; fields?: MongoCompletionField[]; } const COLLECTION_METHODS = [ { label: "find", detail: "Query matching documents", apply: "find({})" }, { label: "findOne", detail: "Query one matching document", apply: "findOne({})" }, { label: "aggregate", detail: "Run an aggregation pipeline", apply: "aggregate([])" }, { label: "countDocuments", detail: "Count matching documents", apply: "countDocuments({})" }, { label: "count", detail: "Count matching documents (legacy helper)", apply: "count({})" }, { label: "estimatedDocumentCount", detail: "Estimate the document count from collection metadata", apply: "estimatedDocumentCount()" }, { label: "distinct", detail: "List the distinct values of a field", apply: 'distinct("${field}")' }, { label: "insertOne", detail: "Insert one document", apply: "insertOne({})" }, { label: "insertMany", detail: "Insert multiple documents", apply: "insertMany([{}])" }, { label: "updateOne", detail: "Update one matching document", apply: "updateOne({}, { $set: {} })" }, { label: "updateMany", detail: "Update all matching documents", apply: "updateMany({}, { $set: {} })" }, { label: "replaceOne", detail: "Replace one matching document", apply: "replaceOne({}, {})" }, { label: "bulkWrite", detail: "Run several writes in one batch", apply: "bulkWrite([\n { insertOne: { document: {} } }\n])" }, { label: "deleteOne", detail: "Delete one matching document", apply: "deleteOne({})" }, { label: "deleteMany", detail: "Delete all matching documents", apply: "deleteMany({})" }, { label: "findOneAndUpdate", detail: "Atomically update and return a document", apply: "findOneAndUpdate({}, { $set: {} })" }, { label: "findOneAndReplace", detail: "Atomically replace and return a document", apply: "findOneAndReplace({}, {})" }, { label: "findOneAndDelete", detail: "Atomically delete and return a document", apply: "findOneAndDelete({})" }, { label: "getIndexes", detail: "List collection indexes", apply: "getIndexes()" }, { label: "stats", detail: "Show collection statistics", apply: "stats()" }, { label: "dataSize", detail: "Total size of documents in bytes", apply: "dataSize()" }, { label: "storageSize", detail: "Allocated storage size in bytes", apply: "storageSize()" }, { label: "totalIndexSize", detail: "Total size of all indexes in bytes", apply: "totalIndexSize()" }, { label: "createIndex", detail: "Create an index", apply: "createIndex({ ${field}: 1 })" }, { label: "dropIndex", detail: "Drop one index", apply: 'dropIndex("${indexName}")' }, { label: "dropIndexes", detail: "Drop collection indexes", apply: "dropIndexes()" }, { label: "renameCollection", detail: "Rename the collection", apply: 'renameCollection("${newName}")' }, { label: "drop", detail: "Drop the collection", apply: "drop()" }, ] as const; const COLLECTION_METHOD_BOOST: Record<(typeof COLLECTION_METHODS)[number]["label"], number> = { find: 240, findOne: 230, aggregate: 220, countDocuments: 210, estimatedDocumentCount: 205, distinct: 200, insertOne: 180, insertMany: 170, updateOne: 160, updateMany: 150, replaceOne: 145, bulkWrite: 135, deleteOne: 140, deleteMany: 130, findOneAndUpdate: 120, findOneAndReplace: 115, findOneAndDelete: 110, getIndexes: 100, stats: 95, createIndex: 90, count: 80, dataSize: 75, storageSize: 70, totalIndexSize: 65, renameCollection: 55, dropIndex: 50, dropIndexes: 45, drop: 30, }; /** Database-level helpers, offered next to the collection names after `db.`. */ const DATABASE_METHODS = [ { label: "getCollection", detail: "Reference a collection by name", apply: 'getCollection("${}")' }, { label: "version", detail: "Show the MongoDB server version", apply: "version()" }, { label: "stats", detail: "Show database statistics", apply: "stats()" }, { label: "serverStatus", detail: "Show server status", apply: "serverStatus()" }, ] as const; const CURSOR_METHODS = [ { label: "sort", detail: "Sort cursor results", apply: "sort({ ${field}: 1 })" }, { label: "limit", detail: "Limit cursor results", apply: "limit(100)" }, { label: "skip", detail: "Skip cursor results", apply: "skip(0)" }, { label: "explain", detail: "Show the query plan instead of the results", apply: 'explain("executionStats")' }, ] as const; /** * `find().count()` is only accepted when `count()` is the sole chained call, so * it is offered directly after `find(…)` and withheld once the chain has grown. */ const CURSOR_COUNT_METHOD = { label: "count", detail: "Count the documents matched by find()", apply: "count()" } as const; const ROOT_SNIPPETS = [ { label: "db.collection.find", detail: "Find documents", apply: "db.${collection}.find({})" }, { label: "db.collection.aggregate", detail: "Aggregation pipeline", apply: "db.${collection}.aggregate([\n { $match: {} }\n])" }, { label: "db.getCollection", detail: "Reference a collection by name", apply: 'db.getCollection("${}")' }, { label: "use", detail: "Switch the active database", apply: "use ${database}" }, { label: "db.version", detail: "Show the MongoDB server version", apply: "db.version()" }, { label: "db.stats", detail: "Show database statistics", apply: "db.stats()" }, { label: "db.serverStatus", detail: "Show server status", apply: "db.serverStatus()" }, ] as const; const ROOT_SNIPPET_BOOST: Record<(typeof ROOT_SNIPPETS)[number]["label"], number> = { "db.collection.find": 350, "db.collection.aggregate": 340, "db.getCollection": 330, use: 320, "db.version": 310, "db.stats": 305, "db.serverStatus": 300, }; /** Role of each positional argument, by collection helper. Drives cursor classification. */ type MongoArgRole = "filter" | "update" | "replacement" | "document" | "documents" | "operations" | "pipeline" | "projection" | "keys" | "sortKeys" | "fieldName" | "options"; const METHOD_ARG_ROLES: Record = { find: ["filter", "projection", "options"], findOne: ["filter", "projection", "options"], countDocuments: ["filter", "options"], count: ["filter", "options"], deleteOne: ["filter", "options"], deleteMany: ["filter", "options"], findOneAndDelete: ["filter", "options"], updateOne: ["filter", "update", "options"], updateMany: ["filter", "update", "options"], replaceOne: ["filter", "replacement", "options"], bulkWrite: ["operations", "options"], findOneAndUpdate: ["filter", "update", "options"], findOneAndReplace: ["filter", "replacement", "options"], insertOne: ["document", "options"], insertMany: ["documents", "options"], aggregate: ["pipeline", "options"], createIndex: ["keys", "options"], distinct: ["fieldName", "filter"], sort: ["sortKeys"], }; /** `{ $oid: "..." }` for a bare value position, where the user has not typed the braces yet. */ const BRACED_EXTENDED_JSON_VALUES: MongoOperatorSpec[] = EXTENDED_JSON_VALUES.map((spec) => ({ ...spec, apply: `{ ${spec.apply} }` })); /** Query operators whose array holds plain values, unlike `$and` / `$or` / `$nor` which hold sub-filters. */ const VALUE_ARRAY_OPERATORS = new Set(["$in", "$nin", "$all"]); const CALL_METHOD_PATTERN = new RegExp(`\\.(${Object.keys(METHOD_ARG_ROLES).join("|")})\\s*\\(`, "g"); /** Modes reached by walking the `db.collection.method` chain, where a `.` switches item source. */ const DOT_SCOPED_MODES = new Set(["root", "collection", "collectionOrMethod", "method", "cursorMethod"]); /** Stages whose body is a fixed set of option keys rather than a field map. */ const OPTION_STAGES = new Set(Object.keys(STAGE_OPTION_KEYS)); /** Stages taking a bare `"$field"` string, completed as a field reference. */ const FIELD_REF_STAGES = new Set(["$unwind", "$sortByCount", "$replaceWith"]); /** Value position inside a stage's option object, by stage and option key. */ const STAGE_OPTION_VALUE_MODES: Record> = { $lookup: { from: "collectionRef", localField: "fieldPath", foreignField: "fieldPath" }, $graphLookup: { from: "collectionRef", startWith: "fieldRef", connectFromField: "fieldPath", connectToField: "fieldPath" }, $unwind: { path: "fieldRef" }, $merge: { into: "collectionRef", on: "fieldPath" }, $unionWith: { coll: "collectionRef" }, $bucket: { groupBy: "fieldRef" }, $bucketAuto: { groupBy: "fieldRef" }, $setWindowFields: { partitionBy: "fieldRef" }, $geoNear: { key: "fieldPath" }, $replaceRoot: { newRoot: "fieldRef" }, }; export function getMongoCompletionContext(text: string, cursor: number): MongoCompletionContext { const safeCursor = Math.max(0, Math.min(cursor, text.length)); const beforeCursor = text.slice(0, safeCursor); const collection = extractActiveCollection(text, safeCursor); const { prefix, from } = readPropertyPrefix(text, safeCursor); const replaceClosingQuote = closingQuoteAtCursor(prefix, text, safeCursor); const at = (mode: MongoCompletionMode, stage?: string): MongoCompletionContext => ({ mode, prefix, from, replaceClosingQuote, collection, stage }); if (isInsideMongoComment(text, safeCursor)) return { mode: "none", prefix: "", from: safeCursor }; if (beforeCursor.endsWith("db.")) return { mode: "collection", prefix: "", from: safeCursor, collection }; const getCollectionPrefix = matchGetCollectionPrefix(beforeCursor); if (getCollectionPrefix) { return { mode: "collectionRef", prefix: getCollectionPrefix.prefix, from: getCollectionPrefix.from, replaceClosingQuote: closingQuoteAtCursor(getCollectionPrefix.prefix, text, safeCursor), collection, }; } const collectionPrefix = matchDbCollectionPrefix(beforeCursor); if (collectionPrefix) { return { mode: collectionPrefix.prefix.includes(".") ? "collectionOrMethod" : "collection", prefix: collectionPrefix.prefix, from: collectionPrefix.from, collection, }; } if (isAfterCollectionDot(beforeCursor)) { const methodPrefix = readMethodPrefix(beforeCursor); return { mode: "method", prefix: methodPrefix.prefix, from: methodPrefix.from, collection }; } const cursorChain = matchCursorMethodDot(beforeCursor); if (cursorChain) { const methodPrefix = readMethodPrefix(beforeCursor); return { mode: "cursorMethod", prefix: methodPrefix.prefix, from: methodPrefix.from, collection, stage: cursorChain.countable ? "countable" : undefined }; } const call = findInnermostMongoCall(beforeCursor); if (!call) return at("root"); const scan = scanMongoCallArguments(text, call.openParenIndex + 1, safeCursor); if (!scan) return at("root"); const classified = classifyCursorInCall(call.method, scan); return { ...at(classified.mode, classified.stage), collection: classified.collection ?? collection }; } export function buildMongoCompletionItems(text: string, cursor: number, input: MongoCompletionInput = {}): MongoCompletionItem[] { return buildMongoCompletionItemsFromContext(getMongoCompletionContext(text, cursor), input); } export function buildMongoCompletionItemsFromContext(context: MongoCompletionContext, input: MongoCompletionInput = {}): MongoCompletionItem[] { const { mode, prefix } = context; const collections = input.collections ?? []; const fields = input.fields ?? []; let items: MongoCompletionItem[]; switch (mode) { case "none": items = []; break; case "root": items = rootItems(prefix); break; case "collection": items = collectionItems(prefix, collections); break; case "collectionOrMethod": items = collectionOrMethodItems(prefix, collections); break; case "collectionRef": items = collectionRefItems(prefix, collections); break; case "method": items = methodItems(prefix); break; case "cursorMethod": items = cursorMethodItems(prefix, context.stage === "countable"); break; case "field": items = fieldItems(prefix, fields); break; case "filterField": // Fields lead; `$and` / `$or` and the other whole-filter operators follow once `$` is typed. items = [...fieldItems(prefix, fields), ...specItems(TOP_LEVEL_QUERY_OPERATORS, prefix, "query operator", 80)]; break; case "fieldPath": items = fieldPathItems(prefix, fields); break; case "fieldRef": items = fieldRefItems(prefix, fields); break; case "value": // Shell constructors first; the extended JSON spellings need their own braces here. items = [...specItems(VALUE_SNIPPETS, prefix, "value", 100), ...specItems(BRACED_EXTENDED_JSON_VALUES, prefix, "extended JSON value", 90)]; break; case "valueWrapper": items = specItems(EXTENDED_JSON_VALUES, prefix, "extended JSON value", 100); break; case "queryOperator": // `{ _id: { $oid: ... } }` is as valid here as `{ _id: { $gt: ... } }`. items = [...specItems(FIELD_QUERY_OPERATORS, prefix, "query operator", 100), ...specItems(EXTENDED_JSON_VALUES, prefix, "extended JSON value", 90)]; break; case "updateOperator": items = specItems(UPDATE_OPERATORS, prefix, "update operator", 100); break; case "pushModifier": items = specItems(PUSH_MODIFIERS, prefix, "array update modifier", 100); break; case "expression": items = [...specItems(EXPRESSION_OPERATORS, prefix, "aggregation expression", 100), ...fieldRefItems(prefix, fields, 80)]; break; case "accumulator": items = specItems(ACCUMULATORS, prefix, "accumulator", 100); break; case "stage": items = specItems(PIPELINE_STAGES, prefix, "aggregation stage", 100); break; case "stageOption": items = specItems(STAGE_OPTION_KEYS[context.stage ?? ""] ?? [], prefix, `${context.stage} option`, 100); break; default: items = []; } return finalizeQuotedMongoCompletionItems(context, items); } /** Modes whose items are built from the target collection's sampled fields. */ export function mongoCompletionNeedsFields(mode: MongoCompletionMode): boolean { return mode === "field" || mode === "filterField" || mode === "fieldPath" || mode === "fieldRef" || mode === "expression"; } /** Modes whose items are built from the database's collection names. */ export function mongoCompletionNeedsCollections(mode: MongoCompletionMode): boolean { return mode === "collection" || mode === "collectionOrMethod" || mode === "collectionRef"; } export function shouldAutoOpenMongoCompletion(text: string, cursor: number): boolean { const previousChar = text[cursor - 1]; if (!previousChar) return false; if (text.slice(0, cursor).endsWith("db.")) return true; if (previousChar === "$" || previousChar === "." || previousChar === '"' || previousChar === "'") return true; if (/[{,[:]/.test(previousChar) || /[{,[:]\s+$/.test(text.slice(0, cursor))) { return getMongoCompletionContext(text, cursor).mode !== "none"; } if (/[\w_$-]/.test(previousChar)) return true; return false; } /** * How far the editor may keep re-filtering a result before it has to ask us * again. In the `db.…` chain every `.` moves the cursor to a different item * source (root → collection → method → cursor method), so a result must stop * being valid as soon as the typed text gains a dot it did not have — otherwise * the editor keeps filtering collection names against `users.` and shows * nothing. Elsewhere (field paths, quoted collection names) dots are just part * of the identifier and are safe to keep. */ export function getMongoCompletionResultValidFor(context?: MongoCompletionContext): RegExp { if (!context || !DOT_SCOPED_MODES.has(context.mode)) return /["']?[\w_$.-]*$/; const segments = context.prefix.split(".").length; return new RegExp(`["']?[\\w_$-]*${"\\.[\\w_$-]*".repeat(segments - 1)}$`); } export function inferMongoCompletionFields(documents: unknown[]): MongoCompletionField[] { const typeByPath = new Map>(); for (const doc of documents) collectFieldTypes(doc, "", typeByPath, 0); return [...typeByPath.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, types]) => ({ name, type: [...types].sort().join(" | ") })); } /* ------------------------------------------------------------------ * * Cursor classification * ------------------------------------------------------------------ */ type MongoContainerKind = "object" | "array" | "call"; interface MongoContainer { kind: MongoContainerKind; /** Key this container is the value of, e.g. `age` in `{ age: { … } }`. */ key: string | null; } interface MongoCallScan { /** Index of the positional argument the cursor sits in. */ argIndex: number; /** Open containers between the call's `(` and the cursor, outermost first. */ stack: MongoContainer[]; /** Key whose value the cursor sits in, when past a `:` in the innermost object. */ valueKey: string | null; inValue: boolean; inString: boolean; } interface MongoCursorClass { mode: MongoCompletionMode; stage?: string; collection?: string; } /** * Walks a collection helper's arguments from its `(` to the cursor, tracking the * open `{}`/`[]`/`()` containers and the key each one is the value of. Returns * null when the call closes before the cursor — i.e. the cursor is not inside it. * * Intentionally not a real parser: it only needs enough structure (which * container we are in, and under which key) to know what to suggest, and it must * keep working on the half-typed input that completion always runs against. */ function scanMongoCallArguments(text: string, start: number, cursor: number): MongoCallScan | null { const stack: MongoContainer[] = []; let argIndex = 0; let token = ""; let valueKey: string | null = null; let inValue = false; let quote: string | null = null; for (let i = start; i < cursor; i++) { const char = text[i] ?? ""; if (quote) { if (char === "\\") { i++; continue; } if (char === quote) quote = null; else token += char; continue; } if ((char === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) || (char === "-" && text[i + 1] === "-")) { const skipped = skipMongoStringOrComment(text, i, cursor); if (skipped > i) { i = skipped - 1; // the for-loop's i++ lands us just past the comment token = ""; continue; } } if (char === '"' || char === "'") { quote = char; token = ""; continue; } if (char === "{" || char === "[" || char === "(") { stack.push({ kind: char === "{" ? "object" : char === "[" ? "array" : "call", key: inValue ? valueKey : null }); token = ""; valueKey = null; inValue = false; continue; } if (char === "}" || char === "]" || char === ")") { if (stack.length === 0) return null; stack.pop(); token = ""; valueKey = null; inValue = false; continue; } if (char === ":") { valueKey = token.trim() || valueKey; token = ""; inValue = true; continue; } if (char === ",") { if (stack.length === 0) argIndex++; token = ""; valueKey = null; inValue = false; continue; } if (!/\s/.test(char)) token += char; } return { argIndex, stack, valueKey, inValue, inString: quote !== null }; } function classifyCursorInCall(method: string, scan: MongoCallScan): MongoCursorClass { const role = METHOD_ARG_ROLES[method]?.[scan.argIndex]; if (!role) return { mode: "none" }; switch (role) { case "filter": return { mode: classifyFilter(scan, 0) }; case "update": return { mode: classifyUpdate(scan, 0) }; case "replacement": case "document": return { mode: classifyDocument(scan, 0) }; case "documents": return { mode: classifyDocument(scan, 1) }; case "projection": case "keys": case "sortKeys": return { mode: classifyKeyMap(scan, 0) }; // A bare string argument naming a field, e.g. distinct("category"). case "fieldName": return { mode: scan.stack.length === 0 ? "fieldPath" : "none" }; case "pipeline": return classifyPipeline(scan); // bulkWrite operations are `{ : { filter, update, … } }` entries; completing inside them is a follow-up. case "operations": case "options": return { mode: "none" }; default: return { mode: "none" }; } } /** Depth of the innermost container relative to the object that roots this argument. */ function innerDepth(scan: MongoCallScan, rootIndex: number): number { return scan.stack.length - 1 - rootIndex; } function innermost(scan: MongoCallScan): MongoContainer | undefined { return scan.stack[scan.stack.length - 1]; } function classifyFilter(scan: MongoCallScan, rootIndex: number): MongoCompletionMode { const inner = innermost(scan); if (!inner || innerDepth(scan, rootIndex) < 0) return "none"; // Elements of `$in: [...]` are values, so `{ _id: { $in: [ObjectId(...)] } }` completes like any value. if (inner.kind === "array") return VALUE_ARRAY_OPERATORS.has(inner.key ?? "") && !scan.inString ? "value" : "none"; if (inner.kind !== "object") return "none"; if (scan.inValue) return scan.inString ? "none" : "value"; if (innerDepth(scan, rootIndex) === 0) return "filterField"; // Inside a nested object: whose value is it? switch (inner.key) { case null: { // An object inside an array: a sub-filter under `$and` / `$or` / `$nor`, // or an extended JSON wrapper such as `{ $oid: ... }` under `$in`. const parent = scan.stack[scan.stack.length - 2]; return parent?.kind === "array" && VALUE_ARRAY_OPERATORS.has(parent.key ?? "") ? "valueWrapper" : "filterField"; } case "$elemMatch": return "filterField"; case "$expr": return "expression"; case "$jsonSchema": return "none"; default: return "queryOperator"; // a field's constraint object, or `$not` } } function classifyUpdate(scan: MongoCallScan, rootIndex: number): MongoCompletionMode { const inner = innermost(scan); if (!inner || innerDepth(scan, rootIndex) < 0) return "none"; if (scan.inValue) return scan.inString ? "none" : "value"; if (inner.kind !== "object") return "none"; if (innerDepth(scan, rootIndex) === 0) return "updateOperator"; const parent = scan.stack[scan.stack.length - 2]; if (parent?.key === "$push" || parent?.key === "$addToSet") return "pushModifier"; if (inner.key && UPDATE_OPERATOR_LABELS.has(inner.key)) return "field"; return "field"; } function classifyDocument(scan: MongoCallScan, rootIndex: number): MongoCompletionMode { const inner = innermost(scan); if (!inner || innerDepth(scan, rootIndex) < 0) return "none"; if (scan.inValue) return scan.inString ? "none" : "value"; return inner.kind === "object" ? "field" : "none"; } function classifyKeyMap(scan: MongoCallScan, rootIndex: number): MongoCompletionMode { const inner = innermost(scan); if (!inner || scan.inValue) return "none"; if (inner.kind !== "object" || innerDepth(scan, rootIndex) !== 0) return "none"; return "field"; } function classifyPipeline(scan: MongoCallScan): MongoCursorClass { const pipelineIndex = findPipelineArrayIndex(scan.stack); if (pipelineIndex < 0) return { mode: "none" }; const stageHolder = scan.stack[pipelineIndex + 1]; if (!stageHolder) return { mode: "none" }; // directly inside the array, no stage object yet if (stageHolder.kind !== "object") return { mode: "none" }; // `[{ … }]` — the cursor is in the stage object itself. if (scan.stack.length - 1 === pipelineIndex + 1) { if (!scan.inValue) return { mode: "stage" }; const stage = scan.valueKey ?? ""; return { mode: stageStringValueMode(stage), stage }; } const stage = scan.stack[pipelineIndex + 2]?.key ?? ""; return classifyStageBody(stage, scan, pipelineIndex + 2); } /** * The deepest array that holds pipeline stages: the `aggregate()` argument * itself, a `pipeline:` option (`$lookup`, `$unionWith`), or a `$facet` branch. * Anything else (`$in: [ … ]`, `$and: [ … ]`) is a value array, not a pipeline. */ function findPipelineArrayIndex(stack: MongoContainer[]): number { for (let i = stack.length - 1; i >= 0; i--) { if (stack[i]?.kind !== "array") continue; if (i === 0) return i; if (stack[i]?.key === "pipeline") return i; if (stack[i - 1]?.key === "$facet") return i; } return -1; } function stageStringValueMode(stage: string): MongoCompletionMode { if (FIELD_REF_STAGES.has(stage)) return "fieldRef"; if (stage === "$out") return "collectionRef"; if (stage === "$unset") return "fieldPath"; return "none"; } function classifyStageBody(stage: string, scan: MongoCallScan, bodyIndex: number): MongoCursorClass { if (stage === "$match") return { mode: classifyFilter(scan, bodyIndex), stage }; if (stage === "$group") return { mode: classifyGroup(scan, bodyIndex), stage }; if (stage === "$sort") return { mode: classifyKeyMap(scan, bodyIndex), stage }; if (stage === "$unset") return { mode: innermost(scan)?.kind === "array" ? "fieldPath" : "none", stage }; if (OPTION_STAGES.has(stage)) return classifyStageOptions(stage, scan, bodyIndex); // `$project`-shaped stages and anything unmodelled: keys are field names, values are expressions. return { mode: classifyProjection(scan, bodyIndex), stage }; } function classifyGroup(scan: MongoCallScan, bodyIndex: number): MongoCompletionMode { const depth = innerDepth(scan, bodyIndex); if (depth < 0) return "none"; if (depth === 0) { // `{ $group: { _id: … , total: … } }` — keys are output names, `_id` is required. if (!scan.inValue) return "field"; return scan.valueKey === "_id" ? "fieldRef" : "none"; } if (scan.inValue) return "fieldRef"; const inner = innermost(scan); if (inner?.kind !== "object") return "none"; // One level in: `_id: { … }` builds a compound key, anything else is an accumulator. if (depth === 1) return inner.key === "_id" ? "expression" : "accumulator"; return "expression"; } function classifyProjection(scan: MongoCallScan, bodyIndex: number): MongoCompletionMode { const depth = innerDepth(scan, bodyIndex); if (depth < 0) return "none"; if (scan.inValue) return "fieldRef"; const inner = innermost(scan); if (inner?.kind !== "object") return "none"; return depth === 0 ? "field" : "expression"; } function classifyStageOptions(stage: string, scan: MongoCallScan, bodyIndex: number): MongoCursorClass { const depth = innerDepth(scan, bodyIndex); if (depth < 0) return { mode: "none", stage }; if (depth === 0) { if (scan.inValue) return { mode: STAGE_OPTION_VALUE_MODES[stage]?.[scan.valueKey ?? ""] ?? "none", stage }; return { mode: innermost(scan)?.kind === "object" ? "stageOption" : "none", stage }; } if (scan.inValue) return { mode: "fieldRef", stage }; return { mode: innermost(scan)?.kind === "object" ? "expression" : "none", stage }; } /* ------------------------------------------------------------------ * * Item builders * ------------------------------------------------------------------ */ function rootItems(prefix: string): MongoCompletionItem[] { const snippets = ROOT_SNIPPETS.filter((snippet) => matchesFuzzyPrefix(snippet.label, prefix)).map((snippet) => ({ label: snippet.label, type: "snippet" as const, detail: snippet.detail, apply: snippet.apply, boost: ROOT_SNIPPET_BOOST[snippet.label], })); const methods = COLLECTION_METHODS.filter((method) => matchesFuzzyPrefix(method.label, prefix)).map((method) => ({ label: method.label, type: "function" as const, detail: method.detail, apply: method.apply, boost: COLLECTION_METHOD_BOOST[method.label], })); return dedupeAndSort([...snippets, ...methods]); } function collectionItems(prefix: string, collections: string[]): MongoCompletionItem[] { const names = collectionNameItems(prefix, collections); const methods = DATABASE_METHODS.filter((method) => matchesFuzzyPrefix(method.label, prefix)).map((method) => ({ label: method.label, type: "function" as const, detail: method.detail, apply: method.apply, boost: startsWithPrefix(method.label, prefix) ? 110 : 80, })); return [...names, ...methods]; } function collectionNameItems(prefix: string, collections: string[], boost = 120): MongoCompletionItem[] { return collections .filter((collection) => matchesFuzzyPrefix(collection, prefix)) .slice(0, 100) .map((collection) => ({ label: collection, type: "table" as const, detail: "collection", apply: needsGetCollectionSyntax(collection) ? `getCollection("${escapeDoubleQuoted(collection)}")` : collection, boost: startsWithPrefix(collection, prefix) ? boost : boost - 30, })); } function collectionOrMethodItems(prefix: string, collections: string[]): MongoCompletionItem[] { const dot = prefix.lastIndexOf("."); const collection = prefix.slice(0, dot); const methodPrefix = prefix.slice(dot + 1); const hasExactCollection = collections.includes(collection); const hasDottedCollectionCandidate = collections.some((item) => item.startsWith(`${collection}.`)); const collectionRef = needsGetCollectionSyntax(collection) && hasExactCollection ? `getCollection("${escapeDoubleQuoted(collection)}")` : collection; const methods = methodItems(methodPrefix).map((item) => ({ ...item, apply: `${collectionRef}.${item.apply}`, filterText: `${collection}.${item.label}`, boost: !hasExactCollection && hasDottedCollectionCandidate ? Math.min(item.boost, 110) : item.boost, })); return dedupeAndSort([...collectionNameItems(prefix, collections, 150), ...methods]); } function collectionRefItems(prefix: string, collections: string[]): MongoCompletionItem[] { return collections .filter((collection) => matchesFuzzyPrefix(collection, prefix)) .slice(0, 100) .map((collection) => { const apply = quoteMongoString(collection, prefix); return { label: collection, type: "table" as const, detail: "collection", apply, boost: startsWithPrefix(collection, prefix) ? 120 : 90, }; }); } function methodItems(prefix: string): MongoCompletionItem[] { return dedupeAndSort( COLLECTION_METHODS.filter((method) => matchesFuzzyPrefix(method.label, prefix)).map((method) => ({ label: method.label, type: "function" as const, detail: method.detail, apply: method.apply, boost: COLLECTION_METHOD_BOOST[method.label], })), ); } function cursorMethodItems(prefix: string, countable: boolean): MongoCompletionItem[] { const methods = countable ? [...CURSOR_METHODS, CURSOR_COUNT_METHOD] : [...CURSOR_METHODS]; return dedupeAndSort( methods .filter((method) => matchesFuzzyPrefix(method.label, prefix)) .map((method) => ({ label: method.label, type: "function" as const, detail: method.detail, apply: method.apply, boost: method.label === "limit" ? 150 : method.label === "sort" ? 140 : method.label === "skip" ? 130 : 120, })), ); } function fieldItems(prefix: string, fields: MongoCompletionField[]): MongoCompletionItem[] { const normalizedPrefix = normalizeMongoKeyPrefix(prefix); return dedupeAndSort( fields .filter((field) => matchesFuzzyPrefix(field.name, normalizedPrefix)) .slice(0, 100) .map((field) => ({ label: field.name, type: "column" as const, detail: describeField(field, "observed field"), apply: `${quoteMongoFieldName(field.name, prefix)}: `, boost: startsWithPrefix(field.name, normalizedPrefix) ? 120 : 85, })), ); } function fieldPathItems(prefix: string, fields: MongoCompletionField[]): MongoCompletionItem[] { const normalizedPrefix = normalizeMongoKeyPrefix(prefix); return dedupeAndSort( fields .filter((field) => matchesFuzzyPrefix(field.name, normalizedPrefix)) .slice(0, 100) .map((field) => ({ label: field.name, type: "column" as const, detail: describeField(field, "observed field"), apply: quoteMongoString(field.name, prefix), boost: startsWithPrefix(field.name, normalizedPrefix) ? 120 : 85, })), ); } function fieldRefItems(prefix: string, fields: MongoCompletionField[], baseBoost = 100): MongoCompletionItem[] { const normalizedPrefix = normalizeFieldRefPrefix(prefix); return dedupeAndSort( fields .filter((field) => matchesFuzzyPrefix(field.name, normalizedPrefix)) .slice(0, 100) .map((field) => ({ label: `$${field.name}`, type: "column" as const, detail: describeField(field, "field reference"), apply: quoteMongoString(`$${field.name}`, prefix), boost: startsWithPrefix(field.name, normalizedPrefix) ? baseBoost + 20 : baseBoost - 15, })), ); } function specItems(specs: readonly MongoOperatorSpec[], prefix: string, category: string, baseBoost: number): MongoCompletionItem[] { const normalizedPrefix = normalizeMongoKeyPrefix(prefix); return dedupeAndSort( specs .filter((spec) => matchesFuzzyPrefix(spec.label, normalizedPrefix)) .map((spec) => ({ label: spec.label, type: mongoOperatorItemType(spec.apply), detail: spec.detail, info: category, apply: spec.apply, boost: baseBoost + (startsWithPrefix(spec.label, normalizedPrefix) ? 20 : 0) + (COMMON_OPERATORS.has(spec.label) ? 10 : 0), })), ); } function describeField(field: MongoCompletionField, label: string): string { return field.type ? `${label} · ${field.type}` : label; } function finalizeQuotedMongoCompletionItems(context: MongoCompletionContext, items: MongoCompletionItem[]): MongoCompletionItem[] { const quote = context.prefix[0]; if (quote !== '"' && quote !== "'") return items; return items.map((item) => { let apply = item.apply; if (apply?.startsWith(`${item.label}:`)) { apply = `${quote}${item.label}${quote}${apply.slice(item.label.length)}`; } return { ...item, apply, filterText: apply ?? `${quote}${item.label}${quote}`, replaceClosingQuote: context.replaceClosingQuote, }; }); } /* ------------------------------------------------------------------ * * Text helpers * ------------------------------------------------------------------ */ function readPropertyPrefix(text: string, cursor: number): { prefix: string; from: number } { let from = cursor; while (from > 0 && /[\w_$.-]/.test(text[from - 1] ?? "")) from--; if (text[from - 1] === '"' || text[from - 1] === "'") from--; return { prefix: text.slice(from, cursor), from }; } function closingQuoteAtCursor(prefix: string, text: string, cursor: number): '"' | "'" | undefined { const quote = prefix[0]; return (quote === '"' || quote === "'") && text[cursor] === quote ? quote : undefined; } function readMethodPrefix(beforeCursor: string): { prefix: string; from: number } { const dot = beforeCursor.lastIndexOf("."); const from = dot >= 0 ? dot + 1 : beforeCursor.length; return { prefix: beforeCursor.slice(from), from }; } function matchDbCollectionPrefix(beforeCursor: string): { prefix: string; from: number } | null { const match = /(?:^|[\s;(])db\.([A-Za-z_][\w$-]*(?:\.[\w$-]*)*)$/.exec(beforeCursor); if (!match) return null; const prefix = match[1] ?? ""; return { prefix, from: beforeCursor.length - prefix.length }; } function matchGetCollectionPrefix(beforeCursor: string): { prefix: string; from: number } | null { const match = /(?:^|[\s;(])db\.getCollection\(\s*(["'][^"'\\]*)$/.exec(beforeCursor); if (!match) return null; const prefix = match[1] ?? ""; return { prefix, from: beforeCursor.length - prefix.length }; } function isAfterCollectionDot(beforeCursor: string): boolean { return /(?:^|[\s;(])db\.(?:[A-Za-z_][\w$-]*|getCollection\(["'][^"']+["']\))\.[\w$-]*$/.test(beforeCursor); } /** * `db.x.find(…).…` — and whether `count()` is still legal there, which it only * is while no other cursor method has been chained on. */ function matchCursorMethodDot(beforeCursor: string): { countable: boolean } | null { const collectionCall = /(?:^|[\s;(])db\.(?:[A-Za-z_][\w$-]*|getCollection\(["'][^"']+["']\))\.(find|aggregate)\s*\(/g; let lastMatch: RegExpExecArray | null = null; let match: RegExpExecArray | null; while ((match = collectionCall.exec(beforeCursor))) lastMatch = match; if (!lastMatch) return null; const openParen = beforeCursor.indexOf("(", lastMatch.index + lastMatch[0].length - 1); const closeParen = findMatchingParen(beforeCursor, openParen); if (closeParen < 0) return null; const chain = beforeCursor.slice(closeParen + 1); if (!/^(?:\s*\.\s*(?:sort|skip|limit)\s*\([^()]*\))*\s*\.\s*[\w$-]*$/.test(chain)) return null; return { countable: lastMatch[1] === "find" && /^\s*\.\s*[\w$-]*$/.test(chain) }; } function findMatchingParen(text: string, openIndex: number): number { if (openIndex < 0 || text[openIndex] !== "(") return -1; let depth = 0; let quote: string | null = null; for (let i = openIndex; i < text.length; i++) { const char = text[i]; if (quote) { if (char === "\\") i++; else if (char === quote) quote = null; continue; } if (char === '"' || char === "'") quote = char; else if (char === "(") depth++; else if (char === ")" && --depth === 0) return i; } return -1; } /** * If `text[i]` opens a string or comment, return the index just past its close * (clamped to `end`); otherwise return `i` unchanged. This is the single source * of truth for "what is a literal" — both the call locator and the argument * scanner defer to it so a method name, brace, or comma inside a string or * comment can never be mistaken for code. */ function skipMongoStringOrComment(text: string, i: number, end: number): number { const char = text[i]; if (char === '"' || char === "'") { for (let j = i + 1; j < end; j++) { if (text[j] === "\\") { j++; continue; } if (text[j] === char) return j + 1; } return end; // an unterminated string runs to the cursor } // The editor hosts Mongo in its SQL language mode, so `--` is a line comment // just like the shell's own `//`; both are recognised everywhere. if ((char === "/" && text[i + 1] === "/") || (char === "-" && text[i + 1] === "-")) { const newline = text.indexOf("\n", i + 2); return newline < 0 || newline >= end ? end : newline; // the newline itself is code again } if (char === "/" && text[i + 1] === "*") { const close = text.indexOf("*/", i + 2); return close < 0 || close + 2 > end ? end : close + 2; } return i; } /** Blank out string/comment CONTENT (preserving length, so offsets stay valid) before pattern matching. */ function maskMongoLiterals(text: string): string { const chars = [...text]; let i = 0; while (i < chars.length) { const skipped = skipMongoStringOrComment(text, i, text.length); if (skipped > i) { for (let j = i; j < skipped; j++) { if (chars[j] !== "\n") chars[j] = " "; } i = skipped; } else { i++; } } return chars.join(""); } function findInnermostMongoCall(beforeCursor: string): { method: string; openParenIndex: number } | null { // Match over masked text so `.aggregate(` inside a string value or comment is // not seen as a call; offsets are preserved so openParenIndex stays valid. const masked = maskMongoLiterals(beforeCursor); CALL_METHOD_PATTERN.lastIndex = 0; let match: RegExpExecArray | null; let result: { method: string; openParenIndex: number } | null = null; while ((match = CALL_METHOD_PATTERN.exec(masked))) { const method = match[1]; if (method) result = { method, openParenIndex: match.index + match[0].lastIndexOf("(") }; } return result; } /** * Whether the cursor sits inside a `//` or `/* … *\/` comment. Walks from the * start with the shared literal rules so a `/*` inside a string is not a comment * and a `//` inside a string is not either; an unterminated comment runs to the * cursor, so `db.users.find({ /* na` correctly suppresses completion. */ function isInsideMongoComment(text: string, cursor: number): boolean { let i = 0; while (i < cursor) { const char = text[i]; if (char === '"' || char === "'") { i = skipMongoStringOrComment(text, i, text.length); continue; } if ((char === "/" && text[i + 1] === "/") || (char === "-" && text[i + 1] === "-")) { const newline = text.indexOf("\n", i + 2); const end = newline < 0 ? text.length : newline; if (cursor <= end) return true; i = end; continue; } if (char === "/" && text[i + 1] === "*") { const close = text.indexOf("*/", i + 2); if (close < 0 || cursor <= close + 1) return true; i = close + 2; continue; } i++; } return false; } function extractActiveCollection(text: string, cursor: number): string | undefined { const before = text.slice(0, cursor); const getCollectionMatches = [...before.matchAll(/db\.getCollection\(["']([^"']+)["']\)/g)]; const directMatches = [...before.matchAll(/db\.([A-Za-z_][\w$-]*)\s*\./g)].filter((match) => match[1] !== "getCollection"); const lastGetCollection = getCollectionMatches[getCollectionMatches.length - 1]; const lastDirect = directMatches[directMatches.length - 1]; const getCollectionIndex = lastGetCollection?.index ?? -1; const directIndex = lastDirect?.index ?? -1; if (getCollectionIndex > directIndex) return lastGetCollection?.[1]; return lastDirect?.[1]; } function collectFieldTypes(value: unknown, prefix: string, out: Map>, depth: number) { if (depth > 4 || value == null || typeof value !== "object") return; // A wrapper such as {$oid: "..."} is one BSON value. Walking into it would offer // `_id.$oid`, a path that exists only in transport and matches nothing on the server. if (mongoExtendedJsonValueType(value)) return; if (Array.isArray(value)) { for (const item of value.slice(0, 3)) collectFieldTypes(item, prefix, out, depth + 1); return; } for (const [key, child] of Object.entries(value as Record)) { const path = prefix ? `${prefix}.${key}` : key; if (!out.has(path)) out.set(path, new Set()); out.get(path)?.add(describeMongoValueType(child)); collectFieldTypes(child, path, out, depth + 1); } } function describeMongoValueType(value: unknown): string { if (value == null) return "null"; if (Array.isArray(value)) return "array"; if (value instanceof Date) return "date"; return mongoExtendedJsonValueType(value) ?? (typeof value === "object" ? "object" : typeof value); } function quoteMongoFieldName(field: string, prefix: string): string { if (prefix.startsWith('"')) return `"${escapeDoubleQuoted(field)}"`; if (prefix.startsWith("'")) return `'${escapeSingleQuoted(field)}'`; return field; } /** Always quotes: these completions land in value position, where a bare word is invalid. */ function quoteMongoString(value: string, prefix: string): string { if (prefix.startsWith("'")) return `'${escapeSingleQuoted(value)}'`; return `"${escapeDoubleQuoted(value)}"`; } function normalizeMongoKeyPrefix(prefix: string): string { return prefix.replace(/^["']/, ""); } function normalizeFieldRefPrefix(prefix: string): string { return normalizeMongoKeyPrefix(prefix).replace(/^\$/, ""); } function needsGetCollectionSyntax(collection: string): boolean { return !/^[A-Za-z_][\w$]*$/.test(collection); } function escapeDoubleQuoted(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } function escapeSingleQuoted(value: string): string { return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); } function startsWithPrefix(value: string, prefix: string): boolean { return value.toLowerCase().startsWith(prefix.toLowerCase()); } function matchesFuzzyPrefix(value: string, prefix: string): boolean { const normalizedPrefix = normalizeMongoKeyPrefix(prefix).toLowerCase(); if (!normalizedPrefix) return true; return value.toLowerCase().includes(normalizedPrefix); } function dedupeAndSort(items: MongoCompletionItem[]): MongoCompletionItem[] { const seen = new Set(); const deduped: MongoCompletionItem[] = []; for (const item of items) { const key = `${item.type}:${item.label}`; if (seen.has(key)) continue; seen.add(key); deduped.push(item); } return deduped.sort((a, b) => b.boost - a.boost || a.label.localeCompare(b.label)); }