1169 lines
46 KiB
TypeScript
1169 lines
46 KiB
TypeScript
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<string, readonly MongoArgRole[]> = {
|
|
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<MongoCompletionMode>(["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<string, Record<string, MongoCompletionMode>> = {
|
|
$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<string, Set<string>>();
|
|
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 `{ <op>: { 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<string, Set<string>>, 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<string, unknown>)) {
|
|
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<string>();
|
|
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));
|
|
}
|