diff --git a/package-lock.json b/package-lock.json index adc48854e5..a9677cccf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6539,6 +6539,19 @@ "tar": "^6.1.15" } }, + "node_modules/@mongodb-js/mongodb-ts-autocomplete": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@mongodb-js/mongodb-ts-autocomplete/-/mongodb-ts-autocomplete-0.2.2.tgz", + "integrity": "sha512-5GwS2zm8OKJeWFK25PalpstMimIrnif1T07Ur+HECOCFhndTQmIV7VJve86GBwrnmM5Cy4P4Pv7FrjwvfE222A==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/ts-autocomplete": "^0.3.1", + "@mongosh/shell-api": "^3.11.0", + "mongodb-schema": "^12.6.2", + "node-cache": "^5.1.2", + "typescript": "^5.0.4" + } + }, "node_modules/@mongodb-js/monorepo-tools": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/@mongodb-js/monorepo-tools/-/monorepo-tools-1.1.16.tgz", @@ -6865,6 +6878,40 @@ "node": ">=0.10.0" } }, + "node_modules/@mongodb-js/ts-autocomplete": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/ts-autocomplete/-/ts-autocomplete-0.3.1.tgz", + "integrity": "sha512-2ui9y88PM+PIad/3htoGn/8kiNK8V4vVTrqicgAt1Bozt0AwCUqJFUfnpqf40eJVD20XbPWfeKPjPMPkA7SruQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "lodash": "^4.17.21", + "typescript": "^5.0.4" + } + }, + "node_modules/@mongodb-js/ts-autocomplete/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@mongodb-js/ts-autocomplete/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/@mongodb-js/tsconfig-mongosh": { "resolved": "configs/tsconfig-mongosh", "link": true @@ -14720,6 +14767,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-table": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", + "integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==", + "optional": true, + "dependencies": { + "colors": "1.0.3" + }, + "engines": { + "node": ">= 0.2.0" + } + }, "node_modules/cli-width": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", @@ -14753,6 +14812,15 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -14853,6 +14921,16 @@ "dev": true, "license": "MIT" }, + "node_modules/colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/columnify": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/columnify/-/columnify-1.6.0.tgz", @@ -21251,6 +21329,13 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isnumber": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isnumber/-/isnumber-1.0.0.tgz", + "integrity": "sha512-JLiSz/zsZcGFXPrB4I/AGBvtStkt+8QmksyZBZnVXnnK9XdTEyz0tX8CRYljtwYDuIuZzih6DpHQdi+3Q6zHPw==", + "license": "MIT", + "optional": true + }, "node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", @@ -23576,7 +23661,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "devOptional": true, "license": "MIT" }, "node_modules/lodash.clonedeep": { @@ -24978,6 +25062,13 @@ "bson": "6.x" } }, + "node_modules/mongodb-ns": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/mongodb-ns/-/mongodb-ns-2.4.2.tgz", + "integrity": "sha512-gYJjEYG4v4a1WSXgUf81OBoBRlj+Z1SlnQVO392fC/4a1CN7CLWDITajZWPFTPh/yRozYk6sHHtZwZmQhodBEA==", + "license": "MIT", + "optional": true + }, "node_modules/mongodb-runner": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/mongodb-runner/-/mongodb-runner-5.7.1.tgz", @@ -25040,6 +25131,93 @@ "node": ">=12" } }, + "node_modules/mongodb-schema": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/mongodb-schema/-/mongodb-schema-12.6.2.tgz", + "integrity": "sha512-uKjkTAx6MqJi0Xj0aeYRjvYr3O7LrUQgXH1c0WQCOByPoYbNG9RAhWoc4IwriIqTHyBw1RJn0C/p7DISOPYpMg==", + "license": "Apache-2.0", + "dependencies": { + "reservoir": "^0.1.2" + }, + "bin": { + "mongodb-schema": "bin/mongodb-schema" + }, + "optionalDependencies": { + "bson": "^6.7.0", + "cli-table": "^0.3.4", + "js-yaml": "^4.0.0", + "mongodb": "^6.6.1", + "mongodb-ns": "^2.4.0", + "numeral": "^2.0.6", + "progress": "^2.0.3", + "stats-lite": "^2.0.0", + "yargs": "^17.6.2" + } + }, + "node_modules/mongodb-schema/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0", + "optional": true + }, + "node_modules/mongodb-schema/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-schema/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "optional": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/mongodb-schema/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-schema/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, "node_modules/mongosh": { "resolved": "packages/mongosh", "link": true @@ -25366,6 +25544,18 @@ "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", "license": "MIT" }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -29304,6 +29494,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reservoir": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/reservoir/-/reservoir-0.1.2.tgz", + "integrity": "sha512-ysyw95gLBhMAzqIVrOHJ2yMrRQHAS+h97bS9r89Z7Ou10Jhl2k5KOsyjPqrxL+WfEanov0o5bAMVzQ7AKyENHA==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -30760,6 +30956,19 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-lite": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stats-lite/-/stats-lite-2.2.0.tgz", + "integrity": "sha512-/Kz55rgUIv2KP2MKphwYT/NCuSfAlbbMRv2ZWw7wyXayu230zdtzhxxuXXcvsc6EmmhS8bSJl3uS1wmMHFumbA==", + "license": "MIT", + "optional": true, + "dependencies": { + "isnumber": "~1.0.0" + }, + "engines": { + "node": ">=2.0.0" + } + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -34113,6 +34322,7 @@ "license": "Apache-2.0", "dependencies": { "@mongodb-js/devtools-proxy-support": "^0.4.2", + "@mongodb-js/mongodb-ts-autocomplete": "^0.2.2", "@mongosh/arg-parser": "^3.10.3", "@mongosh/autocomplete": "^3.11.0", "@mongosh/editor": "^3.11.0", @@ -34882,11 +35092,13 @@ "@mongosh/history": "2.4.6", "@mongosh/i18n": "^2.13.1", "@mongosh/service-provider-core": "3.3.3", - "mongodb-redact": "^1.1.5" + "mongodb-redact": "^1.1.5", + "mongodb-schema": "^12.6.2" }, "devDependencies": { "@microsoft/api-extractor": "^7.39.3", "@mongodb-js/eslint-config-mongosh": "^1.0.0", + "@mongodb-js/mongodb-ts-autocomplete": "^0.2.2", "@mongodb-js/prettier-config-devtools": "^1.0.1", "@mongodb-js/tsconfig-mongosh": "^1.0.0", "@mongosh/types": "3.6.2", diff --git a/packages/browser-runtime-core/src/open-context-runtime.ts b/packages/browser-runtime-core/src/open-context-runtime.ts index 50d5ea97cd..65686b503e 100644 --- a/packages/browser-runtime-core/src/open-context-runtime.ts +++ b/packages/browser-runtime-core/src/open-context-runtime.ts @@ -29,6 +29,7 @@ export interface InterpreterEnvironment { */ export class OpenContextRuntime implements Runtime { private interpreterEnvironment: InterpreterEnvironment; + // TODO(MONGOSH-2205): we have to also port this to the new autocomplete private autocompleter: ShellApiAutocompleter | null = null; private shellEvaluator: ShellEvaluator; private instanceState: ShellInstanceState; diff --git a/packages/cli-repl/package.json b/packages/cli-repl/package.json index 2fcf36b518..be39d217d7 100644 --- a/packages/cli-repl/package.json +++ b/packages/cli-repl/package.json @@ -77,6 +77,7 @@ "@mongosh/shell-evaluator": "^3.11.0", "@mongosh/snippet-manager": "^3.11.0", "@mongosh/types": "3.6.2", + "@mongodb-js/mongodb-ts-autocomplete": "^0.2.2", "@segment/analytics-node": "^1.3.0", "ansi-escape-sequences": "^5.1.2", "askcharacter": "^2.0.4", diff --git a/packages/cli-repl/src/mongosh-repl.spec.ts b/packages/cli-repl/src/mongosh-repl.spec.ts index 54cd324fd8..02d6ac3b54 100644 --- a/packages/cli-repl/src/mongosh-repl.spec.ts +++ b/packages/cli-repl/src/mongosh-repl.spec.ts @@ -1,6 +1,9 @@ /* eslint-disable no-control-regex */ import { MongoshCommandFailed } from '@mongosh/errors'; -import type { ServiceProvider } from '@mongosh/service-provider-core'; +import type { + AggregationCursor, + ServiceProvider, +} from '@mongosh/service-provider-core'; import { bson } from '@mongosh/service-provider-core'; import { ADMIN_DB } from '../../shell-api/lib/enums'; import { CliUserConfig } from '@mongosh/types'; @@ -17,6 +20,7 @@ import { tick, useTmpdir, waitEval, + waitMongoshCompletionResults, } from '../test/repl-helpers'; import type { MongoshIOProvider, MongoshNodeReplOptions } from './mongosh-repl'; import MongoshNodeRepl from './mongosh-repl'; @@ -89,6 +93,14 @@ describe('MongoshNodeRepl', function () { }, }); sp.runCommandWithCheck.resolves({ ok: 1 }); + + if (process.env.USE_NEW_AUTOCOMPLETE) { + sp.listCollections.resolves([{ name: 'coll' }]); + const aggCursor = stubInterface(); + aggCursor.toArray.resolves([{ foo: 1, bar: 2 }]); + sp.aggregate.returns(aggCursor); + } + serviceProvider = sp; calledServiceProviderFunctions = () => Object.fromEntries( @@ -352,6 +364,9 @@ describe('MongoshNodeRepl', function () { }; const tabtab = async () => { await tab(); + if (process.env.USE_NEW_AUTOCOMPLETE) { + await waitMongoshCompletionResults(bus); + } await tab(); }; @@ -379,19 +394,28 @@ describe('MongoshNodeRepl', function () { expect(output).to.include('65537'); }); - it('does not stop input when autocompleting during .editor', async function () { - input.write('.editor\n'); - await tick(); - expect(output).to.include('Entering editor mode'); - output = ''; - input.write('db.'); - await tabtab(); - await tick(); - input.write('version()\n'); - input.write('\u0004'); // Ctrl+D - await waitEval(bus); - expect(output).to.include('Error running command serverBuildInfo'); - }); + context( + `autocompleting during .editor [${ + process.env.USE_NEW_AUTOCOMPLETE ? 'new' : 'old' + }]`, + function () { + it('does not stop input when autocompleting during .editor', async function () { + input.write('.editor\n'); + await tick(); + expect(output).to.include('Entering editor mode'); + output = ''; + input.write('db.'); + await tabtab(); + await tick(); + input.write('version()\n'); + input.write('\u0004'); // Ctrl+D + await waitEval(bus); + expect(output, output).to.include( + 'Error running command serverBuildInfo' + ); + }); + } + ); it('can enter multiline code', async function () { for (const line of multilineCode.split('\n')) { @@ -449,73 +473,86 @@ describe('MongoshNodeRepl', function () { expect(code).to.equal(undefined); }); - context('autocompletion', function () { - it('autocompletes collection methods', async function () { - input.write('db.coll.'); - await tabtab(); - await tick(); - expect(output).to.include('db.coll.updateOne'); - }); - it('autocompletes shell-api methods (once)', async function () { - input.write('vers'); - await tabtab(); - await tick(); - expect(output).to.include('version'); - expect(output).to.not.match(/version[ \t]+version/); - }); - it('autocompletes async shell api methods', async function () { - input.write('db.coll.find().'); - await tabtab(); - await tick(); - expect(output).to.include('db.coll.find().close'); - }); - it('autocompletes local variables', async function () { - input.write('let somelongvariable = 0\n'); - await waitEval(bus); - output = ''; - input.write('somelong'); - await tabtab(); - await tick(); - expect(output).to.include('somelongvariable'); - }); - it('autocompletes partial repl commands', async function () { - input.write('.e'); - await tabtab(); - await tick(); - expect(output).to.include('editor'); - expect(output).to.include('exit'); - }); - it('autocompletes full repl commands', async function () { - input.write('.ed'); - await tabtab(); - await tick(); - expect(output).to.include('.editor'); - expect(output).not.to.include('exit'); - }); - it('autocompletion during .editor does not reset the prompt', async function () { - input.write('.editor\n'); - await tick(); - output = ''; - expect((mongoshRepl.runtimeState().repl as any)._prompt).to.equal(''); - input.write('db.'); - await tabtab(); - await tick(); - input.write('foo\nbar\n'); - expect((mongoshRepl.runtimeState().repl as any)._prompt).to.equal(''); - input.write('\u0003'); // Ctrl+C for abort - await tick(); - expect((mongoshRepl.runtimeState().repl as any)._prompt).to.equal( - 'test> ' - ); - expect(stripAnsi(output)).to.equal('db.foo\r\nbar\r\n\r\ntest> '); - }); - it('does not autocomplete tab-indented code', async function () { - output = ''; - input.write('\t\tfoo'); - await tick(); - expect(output).to.equal('\t\tfoo'); - }); - }); + context( + `autocompletion [${process.env.USE_NEW_AUTOCOMPLETE ? 'new' : 'old'}]`, + function () { + it('autocompletes collection methods', async function () { + input.write('db.coll.'); + await tabtab(); + await tick(); + expect(output, output).to.include('db.coll.updateOne'); + }); + it('autocompletes collection schema fields', async function () { + if (!process.env.USE_NEW_AUTOCOMPLETE) { + // not supported in the old autocomplete + this.skip(); + } + input.write('db.coll.find({'); + await tabtab(); + await tick(); + expect(output, output).to.include('db.coll.find({foo'); + }); + it('autocompletes shell-api methods (once)', async function () { + input.write('vers'); + await tabtab(); + await tick(); + expect(output, output).to.include('version'); + expect(output, output).to.not.match(/version[ \t]+version/); + }); + it('autocompletes async shell api methods', async function () { + input.write('db.coll.find().'); + await tabtab(); + await tick(); + expect(output, output).to.include('db.coll.find().toArray'); + }); + it('autocompletes local variables', async function () { + input.write('let somelongvariable = 0\n'); + await waitEval(bus); + output = ''; + input.write('somelong'); + await tabtab(); + await tick(); + expect(output, output).to.include('somelongvariable'); + }); + it('autocompletes partial repl commands', async function () { + input.write('.e'); + await tabtab(); + await tick(); + expect(output, output).to.include('editor'); + expect(output, output).to.include('exit'); + }); + it('autocompletes full repl commands', async function () { + input.write('.ed'); + await tabtab(); + await tick(); + expect(output, output).to.include('.editor'); + expect(output, output).not.to.include('exit'); + }); + it('autocompletion during .editor does not reset the prompt', async function () { + input.write('.editor\n'); + await tick(); + output = ''; + expect((mongoshRepl.runtimeState().repl as any)._prompt).to.equal(''); + input.write('db.'); + await tabtab(); + await tick(); + input.write('foo\nbar\n'); + expect((mongoshRepl.runtimeState().repl as any)._prompt).to.equal(''); + input.write('\u0003'); // Ctrl+C for abort + await tick(); + expect((mongoshRepl.runtimeState().repl as any)._prompt).to.equal( + 'test> ' + ); + expect(stripAnsi(output)).to.equal('db.foo\r\nbar\r\n\r\ntest> '); + }); + it('does not autocomplete tab-indented code', async function () { + output = ''; + input.write('\t\tfoo'); + await tick(); + expect(output, output).to.equal('\t\tfoo'); + }); + } + ); context('history support', function () { const arrowUp = '\x1b[A'; diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts index 4d9f9598ef..b9c9f96ef9 100644 --- a/packages/cli-repl/src/mongosh-repl.ts +++ b/packages/cli-repl/src/mongosh-repl.ts @@ -49,6 +49,8 @@ import { Script, createContext, runInContext } from 'vm'; import { installPasteSupport } from './repl-paste-support'; import util from 'util'; +import { MongoDBAutocompleter } from '@mongodb-js/mongodb-ts-autocomplete'; + declare const __non_webpack_require__: any; /** @@ -131,6 +133,13 @@ type Mutable = { -readonly [P in keyof T]: T[P]; }; +function transformAutocompleteResults( + line: string, + results: { result: string }[] +): [string[], string] { + return [results.map((result) => result.result), line]; +} + /** * An instance of a `mongosh` REPL, without any of the actual I/O. * Specifically, code called by this class should not do any @@ -430,10 +439,21 @@ class MongoshNodeRepl implements EvaluationListener { this.outputFinishString += installPasteSupport(repl); const origReplCompleter = promisify(repl.completer.bind(repl)); // repl.completer is callback-style - const mongoshCompleter = completer.bind( - null, - instanceState.getAutocompleteParameters() - ); + let newMongoshCompleter: MongoDBAutocompleter; + let oldMongoshCompleter: ( + line: string + ) => Promise<[string[], string, 'exclusive'] | [string[], string]>; + if (process.env.USE_NEW_AUTOCOMPLETE) { + const autocompletionContext = instanceState.getAutocompletionContext(); + newMongoshCompleter = new MongoDBAutocompleter({ + context: autocompletionContext, + }); + } else { + oldMongoshCompleter = completer.bind( + null, + instanceState.getAutocompleteParameters() + ); + } const innerCompleter = async ( text: string ): Promise<[string[], string]> => { @@ -442,10 +462,25 @@ class MongoshNodeRepl implements EvaluationListener { [replResults, replOrig], [mongoshResults, , mongoshResultsExclusive], ] = await Promise.all([ - (async () => (await origReplCompleter(text)) || [[]])(), - (async () => await mongoshCompleter(text))(), + (async () => { + const nodeResults = (await origReplCompleter(text)) || [[]]; + return nodeResults; + })(), + (async () => { + if (process.env.USE_NEW_AUTOCOMPLETE) { + const results = await newMongoshCompleter.autocomplete(text); + const transformed = transformAutocompleteResults(text, results); + return transformed; + } else { + return oldMongoshCompleter(text); + } + })(), ]); - this.bus.emit('mongosh:autocompletion-complete'); // For testing. + this.bus.emit( + 'mongosh:autocompletion-complete', + replResults, + mongoshResults + ); // For testing. // Sometimes the mongosh completion knows that what it is doing is right, // and that autocompletion based on inspecting the actual objects that diff --git a/packages/cli-repl/test/repl-helpers.ts b/packages/cli-repl/test/repl-helpers.ts index 87bedb63ec..5cd5f50334 100644 --- a/packages/cli-repl/test/repl-helpers.ts +++ b/packages/cli-repl/test/repl-helpers.ts @@ -75,6 +75,25 @@ async function waitCompletion(bus: MongoshBus) { await tick(); } +async function waitMongoshCompletionResults(bus: MongoshBus) { + // Waiting for the completion results can "time out" if an async action such + // as listing the databases or collections or loading the schema takes longer + // than 200ms (at the time of writing), but by the next try or at least + // eventually the action should complete and then the next autocomplete call + // will return the cached result. + let found = false; + while (!found) { + const [, mongoshResults] = await waitBus( + bus, + 'mongosh:autocompletion-complete' + ); + if (mongoshResults.length === 0) { + found = true; + } + } + await tick(); +} + const fakeTTYProps: Partial = { isTTY: true, isRaw: true, @@ -106,6 +125,7 @@ export { waitBus, waitEval, waitCompletion, + waitMongoshCompletionResults, fakeTTYProps, readReplLogFile, }; diff --git a/packages/e2e-tests/test/e2e-snippet.spec.ts b/packages/e2e-tests/test/e2e-snippet.spec.ts index 239e818625..bf4a843073 100644 --- a/packages/e2e-tests/test/e2e-snippet.spec.ts +++ b/packages/e2e-tests/test/e2e-snippet.spec.ts @@ -72,6 +72,7 @@ describe('snippet integration tests', function () { shell.assertNoErrors(); }); + // TODO(MONGOSH-2205): port to the new autocomplete it('autocompletes snippet commands', async function () { if (process.arch === 's390x') { return this.skip(); // https://jira.mongodb.org/browse/MONGOSH-746 diff --git a/packages/shell-api/package.json b/packages/shell-api/package.json index 302c897adb..170acc1d2f 100644 --- a/packages/shell-api/package.json +++ b/packages/shell-api/package.json @@ -58,11 +58,13 @@ "@mongosh/history": "2.4.6", "@mongosh/i18n": "^2.13.1", "@mongosh/service-provider-core": "3.3.3", - "mongodb-redact": "^1.1.5" + "mongodb-redact": "^1.1.5", + "mongodb-schema": "^12.6.2" }, "devDependencies": { "@microsoft/api-extractor": "^7.39.3", "@mongodb-js/eslint-config-mongosh": "^1.0.0", + "@mongodb-js/mongodb-ts-autocomplete": "^0.2.2", "@mongodb-js/prettier-config-devtools": "^1.0.1", "@mongodb-js/tsconfig-mongosh": "^1.0.0", "@mongosh/types": "3.6.2", diff --git a/packages/shell-api/src/collection.ts b/packages/shell-api/src/collection.ts index b8825e3548..115b6e7eca 100644 --- a/packages/shell-api/src/collection.ts +++ b/packages/shell-api/src/collection.ts @@ -122,7 +122,7 @@ export class Collection< _mongo: Mongo; _database: DatabaseWithSchema; _name: N; - + _cachedSampleDocs: Document[] = []; constructor( mongo: Mongo, database: DatabaseWithSchema | Database, @@ -2389,7 +2389,7 @@ export class Collection< ): Promise { this._emitCollectionApiCall('checkMetadataConsistency', { options }); - return this._database._runCursorCommand({ + return await this._database._runCursorCommand({ checkMetadataConsistency: this._name, }); } @@ -2528,6 +2528,35 @@ export class Collection< definition ); } + + async _getSampleDocs(): Promise { + this._cachedSampleDocs = await ( + await this.aggregate([{ $sample: { size: 10 } }], { + allowDiskUse: true, + maxTimeMS: 1000, + readPreference: 'secondaryPreferred', + }) + ).toArray(); + return this._cachedSampleDocs; + } + + async _getSampleDocsForCompletion(): Promise { + return await Promise.race([ + (async () => { + return await this._getSampleDocs(); + })(), + (async () => { + // 200ms should be a good compromise between giving the server a chance + // to reply and responsiveness for human perception. It's not the end + // of the world if we end up using the cached results; usually, they + // are not going to differ from fresh ones, and even if they do, a + // subsequent autocompletion request will almost certainly have at least + // the new cached results. + await new Promise((resolve) => setTimeout(resolve, 200)?.unref?.()); + return this._cachedSampleDocs; + })(), + ]); + } } export type GetShardDistributionResult = { diff --git a/packages/shell-api/src/integration.spec.ts b/packages/shell-api/src/integration.spec.ts index 41eddfc286..98e3d7d9e8 100644 --- a/packages/shell-api/src/integration.spec.ts +++ b/packages/shell-api/src/integration.spec.ts @@ -2772,6 +2772,44 @@ describe('Shell API (integration)', function () { }); }); + describe('getAutocompletionContext', function () { + beforeEach(async function () { + // Make sure the collection is present so it is included in autocompletion. + await collection.insertOne({}); + // Make sure 'database' is the current db in the eyes of the instance state object. + instanceState.setDbFunc(database); + }); + + it('returns information for autocomplete', async function () { + const context = instanceState.getAutocompletionContext(); + const { connectionId, databaseName } = + context.currentDatabaseAndConnection(); + const databaseNames = await context.databasesForConnection( + connectionId + ); + expect(databaseNames).to.include(database.getName()); + const collectionNames = await context.collectionsForDatabase( + connectionId, + databaseName + ); + expect(collectionNames).to.include(collection.getName()); + const schema = await context.schemaInformationForCollection( + connectionId, + database.getName(), + collection.getName() + ); + expect(schema).to.deep.equal({ + bsonType: 'object', + properties: { + _id: { + bsonType: 'objectId', + }, + }, + required: ['_id'], + }); + }); + }); + describe('getAutocompleteParameters', function () { let connectionString: string; beforeEach(async function () { diff --git a/packages/shell-api/src/mongo.ts b/packages/shell-api/src/mongo.ts index efaf5ab047..9af7198b77 100644 --- a/packages/shell-api/src/mongo.ts +++ b/packages/shell-api/src/mongo.ts @@ -73,6 +73,8 @@ type Mutable = { -readonly [P in keyof T]: T[P]; }; +let nextId = 1; + @shellApiClassDefault @classPlatforms(['CLI']) export default class Mongo< @@ -81,6 +83,7 @@ export default class Mongo< private __serviceProvider: ServiceProvider | null = null; public readonly _databases: Record, DatabaseWithSchema> = Object.create(null); + private _connectionId: number; public _instanceState: ShellInstanceState; public _connectionInfo: ConnectionInfo; private _explicitEncryptionOnly = false; @@ -97,6 +100,7 @@ export default class Mongo< sp?: ServiceProvider ) { super(); + this._connectionId = nextId++; this._instanceState = instanceState; if (sp) { this.__serviceProvider = sp; @@ -299,6 +303,10 @@ export default class Mongo< ) as CollectionWithSchema; } + _getConnectionId(): string { + return `connection_${this._connectionId}`; + } + getURI(): string { return this._uri; } diff --git a/packages/shell-api/src/shell-instance-state.ts b/packages/shell-api/src/shell-instance-state.ts index 4d34899899..eb67f8a2b0 100644 --- a/packages/shell-api/src/shell-instance-state.ts +++ b/packages/shell-api/src/shell-instance-state.ts @@ -37,6 +37,10 @@ import constructShellBson from './shell-bson'; import { Streams } from './streams'; import { ShellLog } from './shell-log'; +import type { AutocompletionContext } from '@mongodb-js/mongodb-ts-autocomplete'; +import type { JSONSchema } from 'mongodb-schema'; +import { analyzeDocuments } from 'mongodb-schema'; + /** * The subset of CLI options that is relevant for the shell API's behavior itself. */ @@ -402,6 +406,82 @@ export class ShellInstanceState { this.evaluationListener = listener; } + public getMongoByConnectionId(connectionId: string): Mongo { + for (const mongo of this.mongos) { + if (mongo._getConnectionId() === connectionId) { + return mongo; + } + } + throw new Error(`mongo with connection id ${connectionId} not found`); + } + + public getAutocompletionContext(): AutocompletionContext { + return { + currentDatabaseAndConnection: () => { + return { + connectionId: this.currentDb.getMongo()._getConnectionId(), + databaseName: this.currentDb.getName(), + }; + }, + databasesForConnection: async ( + connectionId: string + ): Promise => { + const mongo = this.getMongoByConnectionId(connectionId); + try { + const dbNames = await mongo._getDatabaseNamesForCompletion(); + return dbNames.filter( + (name: string) => !CONTROL_CHAR_REGEXP.test(name) + ); + } catch (err: any) { + if ( + err?.code === ShellApiErrors.NotConnected || + err?.codeName === 'Unauthorized' + ) { + return []; + } + throw err; + } + }, + collectionsForDatabase: async ( + connectionId: string, + databaseName: string + ): Promise => { + const mongo = this.getMongoByConnectionId(connectionId); + try { + const collectionNames = await mongo + ._getDb(databaseName) + ._getCollectionNamesForCompletion(); + return collectionNames.filter( + (name: string) => !CONTROL_CHAR_REGEXP.test(name) + ); + } catch (err: any) { + if ( + err?.code === ShellApiErrors.NotConnected || + err?.codeName === 'Unauthorized' + ) { + return []; + } + throw err; + } + }, + schemaInformationForCollection: async ( + connectionId: string, + databaseName: string, + collectionName: string + ): Promise => { + const mongo = this.getMongoByConnectionId(connectionId); + const docs = await mongo + ._getDb(databaseName) + .getCollection(collectionName) + ._getSampleDocsForCompletion(); + const schemaAccessor = await analyzeDocuments(docs); + + const schema = await schemaAccessor.getMongoDBJsonSchema(); + return schema; + }, + }; + } + public getAutocompleteParameters(): AutocompleteParameters { return { topology: () => { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index cc53bf6fcc..cd5a29e3df 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -335,7 +335,10 @@ export interface MongoshBusEventsMap extends ConnectEventMap { * Signals the completion of the autocomplete suggestion providers. * _ONLY AVAILABLE FOR TESTING._ */ - 'mongosh:autocompletion-complete': () => void; + 'mongosh:autocompletion-complete': ( + resplResults: string[], + mongoshResults: string[] + ) => void; /** * Signals the completion of the asynchronous interrupt handler in MongoshRepl. Not fired for interrupts of _synchronous_ code. * _ONLY AVAILABLE FOR TESTING._