diff --git a/apps/desktop/src/lib/mongo/mongoCompletion.ts b/apps/desktop/src/lib/mongo/mongoCompletion.ts index ca2c4c8063..d072237aaa 100644 --- a/apps/desktop/src/lib/mongo/mongoCompletion.ts +++ b/apps/desktop/src/lib/mongo/mongoCompletion.ts @@ -432,10 +432,10 @@ export function getMongoCompletionContext(text: string, cursor: number): MongoCo // Top-level snippets belong at the start of a command. Inside an argument list — of a method // this engine does not model (`limit(`, `drop(`, `renameCollection(`, `runCommand({`, …) or after a // `use` — they are noise: `db.collection.find` is not something you can type there. - if (!call) return isInsideCallArguments(beforeCursor) || isAfterUseKeyword(beforeCursor) || isAfterShowKeyword(beforeCursor) ? at("none") : at("root"); + if (!call) return isInsideCallArguments(beforeCursor) || isAfterUseKeyword(beforeCursor) || isAfterShowKeyword(beforeCursor) || isAfterCallResultDot(beforeCursor) ? at("none") : at("root"); const scan = scanMongoCallArguments(text, call.openParenIndex + 1, safeCursor); - if (!scan) return isInsideCallArguments(beforeCursor) || isAfterUseKeyword(beforeCursor) || isAfterShowKeyword(beforeCursor) ? at("none") : at("root"); + if (!scan) return isInsideCallArguments(beforeCursor) || isAfterUseKeyword(beforeCursor) || isAfterShowKeyword(beforeCursor) || isAfterCallResultDot(beforeCursor) ? at("none") : at("root"); const classified = classifyCursorInCall(call.method, scan); const variables = classified.mode === "fieldRef" || classified.mode === "expression" ? collectScopeVariables(scan) : undefined; @@ -599,7 +599,10 @@ export function shouldAutoOpenMongoCompletion(text: string, cursor: number): boo if (text.slice(0, cursor).endsWith("db.")) return true; // `use ` and `show ` name a database or subcommand next; open the list as soon as the space is typed. if (previousChar === " " && (matchUseDatabasePrefix(text.slice(0, cursor)) || matchShowSubcommandPrefix(text.slice(0, cursor)))) return true; - if (previousChar === "$" || previousChar === "." || previousChar === '"' || previousChar === "'") return true; + if (previousChar === ".") { + return getMongoCompletionContext(text, cursor).mode !== "none"; + } + if (previousChar === "$" || previousChar === '"' || previousChar === "'") return true; if (/[{,[:]/.test(previousChar) || /[{,[:]\s+$/.test(text.slice(0, cursor))) { return getMongoCompletionContext(text, cursor).mode !== "none"; } @@ -2158,6 +2161,15 @@ function isAfterShowKeyword(beforeCursor: string): boolean { return /(?:^|[\s;])show\s+[\w$-]*(?:\s+[\w$-]*)*$/i.test(currentLine); } +/** After a closing parenthesis and a dot on a call chain that is not a modeled cursor chain. */ +function isAfterCallResultDot(beforeCursor: string): boolean { + const masked = maskMongoLiterals(beforeCursor); + if (!/\)\s*\.\s*[\w$-]*$/.test(masked)) return false; + if (/(?:getCollection|getSiblingDB)\s*\([^()]*\)\s*\.\s*[\w$-]*$/.test(masked)) return false; + const cursorChain = matchCursorMethodDot(beforeCursor); + return !cursorChain || cursorChain.terminal === true; +} + /** Blank out string/comment CONTENT (preserving length, so offsets stay valid) before pattern matching. */ function maskMongoLiterals(text: string): string { const chars = [...text]; diff --git a/packages/app-tests/mongoCompletion.test.ts b/packages/app-tests/mongoCompletion.test.ts index 702cfe5cc8..5981e16807 100644 --- a/packages/app-tests/mongoCompletion.test.ts +++ b/packages/app-tests/mongoCompletion.test.ts @@ -2070,3 +2070,100 @@ test("keeps the find chain completable when a comment holds an unbalanced parent assert.deepEqual(labels(lineComment, { fields }), ["limit"]); assert.deepEqual(labels('db.users.find({ note: "(x" }).li', { fields }), ["limit"]); }); + +test("suppresses completion after a dot following findOne", () => { + const text = "db.users.findOne({})."; + const context = getMongoCompletionContext(text, text.length); + assert.equal(context.mode, "none"); + assert.deepEqual(labels(text, { collections, fields }), []); + assert.equal(shouldAutoOpenMongoCompletion(text, text.length), false); +}); + +test("suppresses completion after a dot following countDocuments", () => { + const text = "db.users.countDocuments({})."; + const context = getMongoCompletionContext(text, text.length); + assert.equal(context.mode, "none"); + assert.deepEqual(labels(text, { collections, fields }), []); + assert.equal(shouldAutoOpenMongoCompletion(text, text.length), false); +}); + +test("suppresses completion after a dot following explain", () => { + const text = "db.users.explain()."; + const context = getMongoCompletionContext(text, text.length); + assert.equal(context.mode, "none"); + assert.deepEqual(labels(text, { collections, fields }), []); + assert.equal(shouldAutoOpenMongoCompletion(text, text.length), false); +}); + +test("suppresses completion after a dot and typed prefix following findOne", () => { + const text = "db.users.findOne({}).na"; + const context = getMongoCompletionContext(text, text.length); + assert.equal(context.mode, "none"); + assert.deepEqual(labels(text, { collections, fields }), []); +}); + +test("suppresses completion after a dot following a non-cursor call with masked parens in literals", () => { + for (const text of ['db.users.findOne({ note: "(x)" }).', "db.users.findOne({ /* ) */ }).", "db.users.findOne({}) ."]) { + const context = getMongoCompletionContext(text, text.length); + assert.equal(context.mode, "none", text); + assert.deepEqual(labels(text, { collections, fields }), [], text); + assert.equal(shouldAutoOpenMongoCompletion(text, text.length), false, text); + } + const typed = "db.users.findOne({}) .na"; + assert.equal(getMongoCompletionContext(typed, typed.length).mode, "none"); + assert.deepEqual(labels(typed, { collections, fields }), []); +}); + +test("preserves completion behavior for cursor chains, root triggers, and multiline statements", () => { + // cursor methods + assert.equal(getMongoCompletionContext("db.users.find({}).", "db.users.find({}).".length).mode, "cursorMethod"); + assert.ok(labels("db.users.find({}).", { fields }).includes("sort")); + + assert.equal(getMongoCompletionContext("db.users.find({}).sort({ name: 1 }).", "db.users.find({}).sort({ name: 1 }).".length).mode, "cursorMethod"); + assert.ok(labels("db.users.find({}).sort({ name: 1 }).", { fields }).includes("limit")); + + // aggregate cursor methods + assert.equal(getMongoCompletionContext("db.users.aggregate([]).", "db.users.aggregate([]).".length).mode, "cursorMethod"); + assert.deepEqual(labels("db.users.aggregate([]).").sort(), ["pretty", "toArray"]); + + // terminal cursor method count() + assert.equal(getMongoCompletionContext("db.users.find({}).count().", "db.users.find({}).count().".length).mode, "none"); + assert.deepEqual(labels("db.users.find({}).count()."), []); + + // db root dot / collection prefix / use / show / sh / empty doc + assert.equal(getMongoCompletionContext("db.", "db.".length).mode, "collection"); + assert.ok(labels("db.", { collections }).includes("users")); + + assert.equal(getMongoCompletionContext("db.us", "db.us".length).mode, "collection"); + assert.ok(labels("db.us", { collections }).includes("users")); + assert.ok(labels("db.us", { collections }).includes("user_events")); + + assert.equal(getMongoCompletionContext("use ", "use ".length).mode, "database"); + assert.equal(getMongoCompletionContext("show ", "show ".length).mode, "showSubcommand"); + assert.equal(getMongoCompletionContext("sh", "sh".length).mode, "root"); + assert.equal(getMongoCompletionContext("", 0).mode, "root"); + + assert.equal(getMongoCompletionContext("db.users.find({ name: 'x' });\ndb.", "db.users.find({ name: 'x' });\ndb.".length).mode, "collection"); + assert.ok(labels("db.users.find({ name: 'x' });\ndb.", { collections }).includes("users")); + + // statement on a new line after a finished statement + assert.equal(getMongoCompletionContext("db.users.findOne({})\ndb.", "db.users.findOne({})\ndb.".length).mode, "collection"); + assert.ok(labels("db.users.findOne({})\ndb.", { collections }).includes("users")); + + assert.equal(getMongoCompletionContext("db.users.findOne({})\nfi", "db.users.findOne({})\nfi".length).mode, "root"); + assert.ok(labels("db.users.findOne({})\nfi").includes("find")); +}); + +test("preserves auto-trigger and completion for getCollection and getSiblingDB dot chains", () => { + const getCollectionDot = 'db.getCollection("users").'; + assert.equal(getMongoCompletionContext(getCollectionDot, getCollectionDot.length).mode, "method"); + assert.equal(shouldAutoOpenMongoCompletion(getCollectionDot, getCollectionDot.length), true); + + const siblingDbDot = 'db.getSiblingDB("shop").'; + assert.equal(getMongoCompletionContext(siblingDbDot, siblingDbDot.length).mode, "collection"); + assert.equal(shouldAutoOpenMongoCompletion(siblingDbDot, siblingDbDot.length), true); + + const siblingGetCollectionDot = 'db.getSiblingDB("shop").getCollection("a").'; + assert.equal(getMongoCompletionContext(siblingGetCollectionDot, siblingGetCollectionDot.length).mode, "method"); + assert.equal(shouldAutoOpenMongoCompletion(siblingGetCollectionDot, siblingGetCollectionDot.length), true); +});