diff --git a/.vscodeignore b/.vscodeignore index acf3d8d7f..d0aa10e62 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -16,3 +16,4 @@ webpack.test.config.js .eslintrc .eslintignore playgrounds/** +!global.d.ts diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 000000000..9c34ab034 --- /dev/null +++ b/global.d.ts @@ -0,0 +1,44 @@ +import type { + FindCursor, + AggregationCursor, +} from '@mongosh/service-provider-core'; +import { + Document, + FindOptions, + ExplainVerbosityLike, +} from '@mongosh/service-provider-core'; + +declare global { + let use: (dbName: string) => void; + + enum Stages { + match = '$match', + } + + let db: { + getCollection(coll: string): { + find( + query?: Document, + projection?: Document, + options?: FindOptions + ): Promise; + aggregate( + pipeline: [{ [key in Stages]: Document }], + options: Document & { + explain?: never; + } + ): Promise; + aggregate( + pipeline: [{ [key in Stages]: Document }], + options: Document & { + explain: ExplainVerbosityLike; + } + ): Promise; + aggregate( + ...stages: [{ [key in Stages]: Document }] + ): Promise; + }; + }; +} + +export {}; diff --git a/package-lock.json b/package-lock.json index 14959edbc..c75a69e84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,7 +126,7 @@ "engines": { "node": "^16.16.0", "npm": "^8.15.1", - "vscode": "^1.77.0" + "vscode": "^1.77.3" } }, "node_modules/@ampproject/remapping": { diff --git a/package.json b/package.json index c015c9083..0589bd07f 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "reformat": "prettier --write ." }, "engines": { - "vscode": "^1.77.0", + "vscode": "^1.77.3", "node": "^16.16.0", "npm": "^8.15.1" }, diff --git a/scripts/check-vsix-size.ts b/scripts/check-vsix-size.ts index 02bd717e6..657fe2588 100644 --- a/scripts/check-vsix-size.ts +++ b/scripts/check-vsix-size.ts @@ -12,7 +12,7 @@ const vsixFileName = path.resolve( ); const size = fs.statSync(vsixFileName).size; -const maxSize = 7 * 1000000; // 7 MB +const maxSize = 9 * 1000000; // 9 MB if (size >= maxSize) { throw new Error( diff --git a/src/language/convertKind.ts b/src/language/convertKind.ts new file mode 100644 index 000000000..39c86c29c --- /dev/null +++ b/src/language/convertKind.ts @@ -0,0 +1,98 @@ +import { CompletionItemKind } from 'vscode-languageserver/node'; + +const enum Kind { + alias = 'alias', + callSignature = 'call', + class = 'class', + const = 'const', + constructorImplementation = 'constructor', + constructSignature = 'construct', + directory = 'directory', + enum = 'enum', + enumMember = 'enum member', + externalModuleName = 'external module name', + function = 'function', + indexSignature = 'index', + interface = 'interface', + keyword = 'keyword', + let = 'let', + localFunction = 'local function', + localVariable = 'local var', + method = 'method', + memberGetAccessor = 'getter', + memberSetAccessor = 'setter', + memberVariable = 'property', + module = 'module', + primitiveType = 'primitive type', + script = 'script', + type = 'type', + variable = 'var', + warning = 'warning', + string = 'string', + parameter = 'parameter', + typeParameter = 'type parameter', +} + +// eslint-disable-next-line complexity +export const convertKind = (kind: string): CompletionItemKind => { + switch (kind) { + case Kind.primitiveType: + case Kind.keyword: + return CompletionItemKind.Keyword; + + case Kind.const: + case Kind.let: + case Kind.variable: + case Kind.localVariable: + case Kind.alias: + case Kind.parameter: + return CompletionItemKind.Variable; + + case Kind.memberVariable: + case Kind.memberGetAccessor: + case Kind.memberSetAccessor: + return CompletionItemKind.Field; + + case Kind.function: + case Kind.localFunction: + return CompletionItemKind.Function; + + case Kind.method: + case Kind.constructSignature: + case Kind.callSignature: + case Kind.indexSignature: + return CompletionItemKind.Method; + + case Kind.enum: + return CompletionItemKind.Enum; + + case Kind.enumMember: + return CompletionItemKind.EnumMember; + + case Kind.module: + case Kind.externalModuleName: + return CompletionItemKind.Module; + + case Kind.class: + case Kind.type: + return CompletionItemKind.Class; + + case Kind.interface: + return CompletionItemKind.Interface; + + case Kind.warning: + return CompletionItemKind.Text; + + case Kind.script: + return CompletionItemKind.File; + + case Kind.directory: + return CompletionItemKind.Folder; + + case Kind.string: + return CompletionItemKind.Constant; + + default: + return CompletionItemKind.Property; + } +}; diff --git a/src/language/loadLibrary.ts b/src/language/loadLibrary.ts new file mode 100644 index 000000000..f0704aa5e --- /dev/null +++ b/src/language/loadLibrary.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +export const GLOBAL_CONFIG_LIBRARY_NAME = 'global.d.ts'; + +const contents: { [name: string]: string } = Object.create(null); + +/** + * Load files related to the language features. + */ +export const loadLibrary = ({ + libraryName, + extensionPath, +}: { + libraryName: string; + extensionPath?: string; +}) => { + if (!extensionPath) { + console.error( + `Unable to load library ${libraryName}: extensionPath is undefined` + ); + return ''; + } + + let libraryPath; + + if (libraryName === GLOBAL_CONFIG_LIBRARY_NAME) { + libraryPath = join(extensionPath, libraryName); + } + + let content = contents[libraryName]; + + if (typeof content !== 'string' && libraryPath) { + try { + content = readFileSync(libraryPath, 'utf8'); + } catch (e) { + console.error(`Unable to load library ${libraryName} at ${libraryPath}`); + content = ''; + } + + contents[libraryName] = content; + } + + return content; +}; diff --git a/src/language/server.ts b/src/language/server.ts index 73caa8b46..f6bc124e7 100644 --- a/src/language/server.ts +++ b/src/language/server.ts @@ -13,6 +13,7 @@ import { import { TextDocument } from 'vscode-languageserver-textdocument'; import MongoDBService from './mongoDBService'; +import TypeScriptService from './tsLanguageService'; import { ServerCommands } from './serverCommands'; import { @@ -31,6 +32,9 @@ const documents: TextDocuments = new TextDocuments(TextDocument); // MongoDB language service. const mongoDBService = new MongoDBService(connection); +// TypeScript language service. +const typeScriptService = new TypeScriptService(); + let hasConfigurationCapability = false; // let hasWorkspaceFolderCapability = false; // let hasDiagnosticRelatedInformationCapability = false; @@ -67,6 +71,11 @@ connection.onInitialize((params: InitializeParams) => { resolveProvider: true, triggerCharacters: ['.'], }, + // Tell the client that the server supports help signatures. + signatureHelpProvider: { + resolveProvider: true, + triggerCharacters: [',', '('], + }, // documentFormattingProvider: true, // documentRangeFormattingProvider: true, // codeLensProvider: { @@ -159,9 +168,10 @@ connection.onRequest( } ); -// Pass the extension path to the MongoDB service. +// Pass the extension path to the MongoDB and TypeScript services. connection.onRequest(ServerCommands.SET_EXTENSION_PATH, (extensionPath) => { mongoDBService.setExtensionPath(extensionPath); + typeScriptService.setExtensionPath(extensionPath); }); // Connect the MongoDB language service to CliServiceProvider @@ -204,6 +214,15 @@ connection.onRequest( connection.onCompletion((params: TextDocumentPositionParams) => { const document = documents.get(params.textDocument.uri); + /* const document = documents.get(params.textDocument.uri); + if (!document) { + return Promise.resolve([]); + } + return typeScriptService.doComplete({ + document, + position: params.position, + }); */ + return mongoDBService.provideCompletionItems({ document, position: params.position, @@ -226,6 +245,21 @@ connection.onCompletionResolve((item: CompletionItem): CompletionItem => { return item; }); +// Provide MongoDB signature help. +connection.onSignatureHelp((signatureHelpParms) => { + const document = documents.get(signatureHelpParms.textDocument.uri); + + if (!document) { + return Promise.resolve(null); + } + + // Provide MongoDB or TypeScript help signatures. + return typeScriptService.doSignatureHelp({ + document, + position: signatureHelpParms.position, + }); +}); + connection.onRequest('textDocument/rangeFormatting', (event) => { // connection.console.log( // `textDocument/rangeFormatting: ${JSON.stringify({ event })}` diff --git a/src/language/tsLanguageService.ts b/src/language/tsLanguageService.ts new file mode 100644 index 000000000..0453ab2ee --- /dev/null +++ b/src/language/tsLanguageService.ts @@ -0,0 +1,217 @@ +/* --------------------------------------------------------------------------------------------- + * See the bundled extension of VSCode as an example: + * https://github.com/microsoft/vscode/blob/main/extensions/html-language-features/server/src/modes/javascriptMode.ts + *-------------------------------------------------------------------------------------------- */ +import ts from 'typescript'; +import type { + SignatureHelp, + SignatureInformation, + ParameterInformation, + CompletionItem, +} from 'vscode-languageserver/node'; +import { TextDocument, Position } from 'vscode-languageserver-textdocument'; + +import { loadLibrary, GLOBAL_CONFIG_LIBRARY_NAME } from './loadLibrary'; +import { convertKind } from './convertKind'; + +type TypeScriptServiceHost = { + getLanguageService(jsDocument: TextDocument): ts.LanguageService; + getCompilationSettings(): ts.CompilerOptions; + dispose(): void; +}; + +export default class TypeScriptService { + _host: TypeScriptServiceHost; + _extensionPath?: string; + + constructor() { + this._host = this._getTypeScriptServiceHost(); + } + + /** + * The absolute file path of the directory containing the extension. + */ + setExtensionPath(extensionPath: string): void { + this._extensionPath = extensionPath; + } + + /** + * Create a TypeScript service host. + */ + _getTypeScriptServiceHost(): TypeScriptServiceHost { + const compilerOptions = { + allowNonTsExtensions: true, + allowJs: true, + target: ts.ScriptTarget.Latest, + moduleResolution: ts.ModuleResolutionKind.Classic, + experimentalDecorators: false, + }; + let currentTextDocument = TextDocument.create('init', 'javascript', 1, ''); + + const host: ts.LanguageServiceHost = { + getCompilationSettings: () => compilerOptions, + getScriptFileNames: () => [ + currentTextDocument.uri, + GLOBAL_CONFIG_LIBRARY_NAME, + ], + getScriptKind: () => ts.ScriptKind.JS, + getScriptVersion: (fileName: string) => { + if (fileName === currentTextDocument.uri) { + return String(currentTextDocument.version); + } + return '1'; + }, + getScriptSnapshot: (libraryName: string) => { + let text = ''; + if (libraryName === currentTextDocument.uri) { + text = currentTextDocument.getText(); + } else { + text = loadLibrary({ + libraryName, + extensionPath: this._extensionPath, + }); + } + return { + getText: (start, end) => text.substring(start, end), + getLength: () => text.length, + getChangeRange: () => undefined, + }; + }, + getCurrentDirectory: () => '', + getDefaultLibFileName: () => GLOBAL_CONFIG_LIBRARY_NAME, + readFile: (): string | undefined => undefined, + fileExists: (): boolean => false, + directoryExists: (): boolean => false, + }; + + // Create the language service files. + const languageService = ts.createLanguageService(host); + + return { + // Return a language service instance for a document. + getLanguageService(jsDocument: TextDocument): ts.LanguageService { + currentTextDocument = jsDocument; + return languageService; + }, + getCompilationSettings() { + return compilerOptions; + }, + dispose() { + languageService.dispose(); + }, + }; + } + + /** + * Provide MongoDB signature help. + */ + doSignatureHelp({ + document, + position, + }: { + document: TextDocument; + position: Position; + }): Promise { + const jsDocument = TextDocument.create( + document.uri, + 'javascript', + document.version, + document.getText() + ); + const languageService = this._host.getLanguageService(jsDocument); + const signHelp = languageService.getSignatureHelpItems( + jsDocument.uri, + jsDocument.offsetAt(position), + undefined + ); + + if (signHelp) { + const ret: SignatureHelp = { + activeSignature: signHelp.selectedItemIndex, + activeParameter: signHelp.argumentIndex, + signatures: [], + }; + signHelp.items.forEach((item) => { + const signature: SignatureInformation = { + label: '', + documentation: undefined, + parameters: [], + }; + + signature.label += ts.displayPartsToString(item.prefixDisplayParts); + item.parameters.forEach((p, i, a) => { + const label = ts.displayPartsToString(p.displayParts); + const parameter: ParameterInformation = { + label: label, + documentation: ts.displayPartsToString(p.documentation), + }; + signature.label += label; + signature.parameters?.push(parameter); + if (i < a.length - 1) { + signature.label += ts.displayPartsToString( + item.separatorDisplayParts + ); + } + }); + signature.label += ts.displayPartsToString(item.suffixDisplayParts); + ret.signatures.push(signature); + }); + return Promise.resolve(ret); + } + return Promise.resolve(null); + } + + /** + * Provide MongoDB completions. + * This is a draft method that can replace completions currently provided by the MongoDBService. + * + * TODO: + * - Provide the full completion list. + * - Use a proper icon. + * - Display description. + * - Include a link to the documentation. + */ + doComplete({ + document, + position, + }: { + document: TextDocument; + position: Position; + }): CompletionItem[] { + const jsDocument = TextDocument.create( + document.uri, + 'javascript', + document.version, + document.getText() + ); + const languageService = this._host.getLanguageService(jsDocument); + const offset = jsDocument.offsetAt(position); + const jsCompletion = languageService.getCompletionsAtPosition( + jsDocument.uri, + offset, + { + includeExternalModuleExports: false, + includeInsertTextCompletions: false, + } + ); + + return ( + jsCompletion?.entries.map((entry) => { + // Data used for resolving item details (see 'doResolve'). + const data = { + languageId: 'javascript', + uri: document.uri, + offset: offset, + }; + return { + uri: document.uri, + position: position, + label: entry.name, + sortText: entry.sortText, + kind: convertKind(entry.kind), + data, + }; + }) || [] + ); + } +}