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: "getSiblingDB", detail: "Run the next command against another database", apply: 'getSiblingDB("${database}")' }, { label: "stats", detail: "Show database statistics", apply: "stats()" }, { label: "serverStatus", detail: "Show server status", apply: "serverStatus()" }, { label: "createCollection", detail: "Create a collection", apply: 'createCollection("${name}")' }, { label: "dropDatabase", detail: "Drop the current database", apply: "dropDatabase()" }, ] 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 } = readMongoPropertyPrefix(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; } /* ------------------------------------------------------------------ * * Standalone document inputs * ------------------------------------------------------------------ */ /** * Which bare document an input holds, and therefore how its keys read: the * document browser's filter bar is a query document, its sort bar a key map. */ export type MongoDocumentQueryKind = "filter" | "sortKeys"; /** * Completion for an input holding one bare document rather than a shell * command — the document browser's filter and sort bars. * * There is no `db.coll.find(…)` here to locate the cursor within, so the text is * scanned as if it *were* that call's argument list: `{ na` lands in the query * document's root object exactly as it would inside `find({ na`. Everything the * shell path reaches by walking the `db.…` chain (collection names, helper * methods, cursor methods) is unreachable by construction, which is what we * want — none of it could be pasted into a filter bar. */ export function getMongoDocumentQueryCompletionContext(text: string, cursor: number, kind: MongoDocumentQueryKind): MongoCompletionContext { const safeCursor = Math.max(0, Math.min(cursor, text.length)); const nothing: MongoCompletionContext = { mode: "none", prefix: "", from: safeCursor }; if (isInsideMongoComment(text, safeCursor)) return nothing; // Scanning from 0 treats the input as the argument list itself. A `}` that // closes more than the text opened returns null — the cursor has left the // document, e.g. it trails a finished `{ a: 1 }`. const scan = scanMongoCallArguments(text, 0, safeCursor); if (!scan) return nothing; const mode = kind === "filter" ? classifyFilter(scan, 0) : classifyKeyMap(scan, 0); if (mode === "none") return nothing; const { prefix, from } = readMongoPropertyPrefix(text, safeCursor); return { mode, prefix, from, replaceClosingQuote: closingQuoteAtCursor(prefix, text, safeCursor) }; } /** Text to splice into a plain input for a chosen completion, and where to leave the selection. */ export interface MongoPlainCompletionInsertion { text: string; /** Start of the selection within `text`, relative to its first character. */ selectionStart: number; /** End of that selection; equal to the start when the placeholder was empty. */ selectionEnd: number; } /** * Renders an item's `apply` string for a plain ``/`