From 72faadd30366d50efd6fb454f8825a908ab11f21 Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 8 Jan 2019 15:17:16 +0000 Subject: [PATCH] [plugin] fix #3972: basic support of snippets Signed-off-by: Anton Kosyakov --- .vscode/launch.json | 3 +- examples/browser/package.json | 2 +- ...onaco-frontend-application-contribution.ts | 6 ++ .../src/browser/monaco-frontend-module.ts | 14 ++-- .../monaco-snippet-suggest-provider.ts | 84 +++++++++++++++++++ packages/monaco/src/typings/monaco/index.d.ts | 2 + .../plugin-ext/src/common/plugin-protocol.ts | 23 +++++ .../src/hosted/node/scanners/scanner-theia.ts | 82 ++++++++++++++++-- .../browser/plugin-contribution-handler.ts | 7 ++ .../resolvers/plugin-local-dir-resolver.ts | 5 +- 10 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 packages/monaco/src/browser/monaco-snippet-suggest-provider.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 15487642dae12..573753853bb5e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -61,7 +61,8 @@ "--no-app-auto-install" ], "env": { - "NODE_ENV": "development" + "NODE_ENV": "development", + "THEIA_DEFAULT_PLUGINS": "local-dir:plugins" }, "sourceMaps": true, "outFiles": [ diff --git a/examples/browser/package.json b/examples/browser/package.json index 6596b1343b8b8..d46df94521090 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -59,7 +59,7 @@ "clean": "theia clean && rimraf errorShots", "build": "theia build --mode development", "watch": "yarn build --watch", - "start": "theia start", + "start": "export THEIA_DEFAULT_PLUGINS=local-dir:../../plugins && theia start", "start:debug": "yarn start --log-level=debug", "test": "wdio wdio.conf.js", "test-non-headless": "wdio wdio-non-headless.conf.js", diff --git a/packages/monaco/src/browser/monaco-frontend-application-contribution.ts b/packages/monaco/src/browser/monaco-frontend-application-contribution.ts index 1b218fee56026..419294f9d5fb6 100644 --- a/packages/monaco/src/browser/monaco-frontend-application-contribution.ts +++ b/packages/monaco/src/browser/monaco-frontend-application-contribution.ts @@ -17,6 +17,7 @@ import { injectable, inject } from 'inversify'; import { FrontendApplicationContribution } from '@theia/core/lib/browser'; import { ThemeService } from '@theia/core/lib/browser/theming'; +import { MonacoSnippetSuggestProvider } from './monaco-snippet-suggest-provider'; @injectable() export class MonacoFrontendApplicationContribution implements FrontendApplicationContribution { @@ -24,10 +25,15 @@ export class MonacoFrontendApplicationContribution implements FrontendApplicatio @inject(ThemeService) protected readonly themeService: ThemeService; + @inject(MonacoSnippetSuggestProvider) + protected readonly snippetSuggestProvider: MonacoSnippetSuggestProvider; + async initialize() { const currentTheme = this.themeService.getCurrentTheme(); this.changeTheme(currentTheme.editorTheme); this.themeService.onThemeChange(event => this.changeTheme(event.newTheme.editorTheme)); + + monaco.suggest.setSnippetSuggestSupport(this.snippetSuggestProvider); } protected changeTheme(editorTheme: string | undefined) { diff --git a/packages/monaco/src/browser/monaco-frontend-module.ts b/packages/monaco/src/browser/monaco-frontend-module.ts index ad54af6d277f2..ecabd08e36a64 100644 --- a/packages/monaco/src/browser/monaco-frontend-module.ts +++ b/packages/monaco/src/browser/monaco-frontend-module.ts @@ -14,6 +14,10 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import '../../src/browser/style/index.css'; +import '../../src/browser/style/symbol-sprite.svg'; +import '../../src/browser/style/symbol-icons.css'; + import { ContainerModule, decorate, injectable } from 'inversify'; import { MenuContribution, CommandContribution } from '@theia/core/lib/common'; import { QuickOpenService, FrontendApplicationContribution, KeybindingContribution } from '@theia/core/lib/browser'; @@ -43,17 +47,15 @@ import MonacoTextmateModuleBinder from './textmate/monaco-textmate-frontend-bind import { MonacoSemanticHighlightingService } from './monaco-semantic-highlighting-service'; import { SemanticHighlightingService } from '@theia/editor/lib/browser/semantic-highlight/semantic-highlighting-service'; import { MonacoBulkEditService } from './monaco-bulk-edit-service'; +import { MonacoOutlineDecorator } from './monaco-outline-decorator'; +import { OutlineTreeDecorator } from '@theia/outline-view/lib/browser/outline-decorator-service'; +import { MonacoSnippetSuggestProvider } from './monaco-snippet-suggest-provider'; decorate(injectable(), MonacoToProtocolConverter); decorate(injectable(), ProtocolToMonacoConverter); -import '../../src/browser/style/index.css'; -import '../../src/browser/style/symbol-sprite.svg'; -import '../../src/browser/style/symbol-icons.css'; -import { MonacoOutlineDecorator } from './monaco-outline-decorator'; -import { OutlineTreeDecorator } from '@theia/outline-view/lib/browser/outline-decorator-service'; - export default new ContainerModule((bind, unbind, isBound, rebind) => { + bind(MonacoSnippetSuggestProvider).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).to(MonacoFrontendApplicationContribution).inSingletonScope(); bind(MonacoToProtocolConverter).toSelf().inSingletonScope(); diff --git a/packages/monaco/src/browser/monaco-snippet-suggest-provider.ts b/packages/monaco/src/browser/monaco-snippet-suggest-provider.ts new file mode 100644 index 0000000000000..240b21143ff03 --- /dev/null +++ b/packages/monaco/src/browser/monaco-snippet-suggest-provider.ts @@ -0,0 +1,84 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { injectable } from 'inversify'; + +@injectable() +export class MonacoSnippetSuggestProvider implements monaco.modes.ISuggestSupport { + + protected readonly snippets = new Map(); + + push(...snippets: Snippet[]): void { + for (const snippet of snippets) { + for (const scope of snippet.scopes) { + const languageSnippets = this.snippets.get(scope) || []; + languageSnippets.push(new MonacoSnippetSuggestion(snippet)); + this.snippets.set(scope, languageSnippets); + } + } + } + + async provideCompletionItems(model: monaco.editor.ITextModel): Promise { + const languageId = model.getModeId(); // TODO: look up a language id at the position + const suggestions = this.snippets.get(languageId) || []; + return { suggestions }; + } + + resolveCompletionItem(_: monaco.editor.ITextModel, __: monaco.Position, item: monaco.modes.ISuggestion): monaco.modes.ISuggestion { + return item instanceof MonacoSnippetSuggestion ? item.resolve() : item; + } + +} + +export interface Snippet { + readonly scopes: string[] + readonly name: string + readonly prefix: string + readonly description: string + readonly body: string + readonly source: string +} + +export class MonacoSnippetSuggestion implements monaco.modes.ISuggestion { + + readonly label: string; + readonly detail: string; + readonly sortText: string; + readonly noAutoAccept = true; + readonly type: 'snippet' = 'snippet'; + readonly snippetType: 'textmate' = 'textmate'; + + insertText: string; + documentation?: monaco.IMarkdownString; + + constructor(protected readonly snippet: Snippet) { + this.label = snippet.prefix; + this.detail = `${snippet.description || snippet.name} (${snippet.source})"`; + this.insertText = snippet.body; + this.sortText = `z-${snippet.prefix}`; + } + + protected resolved = false; + resolve(): MonacoSnippetSuggestion { + if (!this.resolved) { + const codeSnippet = new monaco.snippetParser.SnippetParser().parse(this.snippet.body).toString(); + this.insertText = codeSnippet; + this.documentation = { value: '```\n' + codeSnippet + '```' }; + this.resolved = true; + } + return this; + } + +} diff --git a/packages/monaco/src/typings/monaco/index.d.ts b/packages/monaco/src/typings/monaco/index.d.ts index a70158708f662..e1ef121f89abf 100644 --- a/packages/monaco/src/typings/monaco/index.d.ts +++ b/packages/monaco/src/typings/monaco/index.d.ts @@ -903,6 +903,8 @@ declare module monaco.suggest { token?: monaco.CancellationToken ): Promise; + export function setSnippetSuggestSupport(support: monaco.modes.ISuggestSupport): monaco.modes.ISuggestSupport; + } declare module monaco.suggestController { diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index c43d027ee2681..1efbb2ad01f02 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -21,6 +21,7 @@ import { CharacterPair, CommentRule, PluginAPIFactory, Plugin } from '../api/plu import { PreferenceSchema } from '@theia/core/lib/browser/preferences'; import { ExtPluginApi } from './plugin-ext-api-contribution'; import { IJSONSchema, IJSONSchemaSnippet } from '@theia/core/lib/common/json-schema'; +import { Snippet } from '@theia/monaco/lib/browser/monaco-snippet-suggest-provider'; export const hostedServicePath = '/services/hostedPlugin'; @@ -62,6 +63,7 @@ export interface PluginPackageContribution { menus?: { [location: string]: PluginPackageMenu[] }; keybindings?: PluginPackageKeybinding[]; debuggers?: PluginPackageDebuggersContribution[]; + snippets: PluginPackageSnippetsContribution[]; } export interface PluginPackageViewContainer { @@ -100,6 +102,26 @@ export interface ScopeMap { [scopeName: string]: string; } +export interface PluginPackageSnippetsContribution { + language?: string; + path?: string; +} + +export interface JsonSerializedSnippets { + [name: string]: JsonSerializedSnippet | { [name: string]: JsonSerializedSnippet }; +} +export interface JsonSerializedSnippet { + body: string | string[]; + scope: string; + prefix: string; + description: string; +} +export namespace JsonSerializedSnippet { + export function is(obj: Object | undefined): obj is JsonSerializedSnippet { + return typeof obj === 'object' && 'body' in obj && 'prefix' in obj; + } +} + export interface PlatformSpecificAdapterContribution { program?: string; args?: string[]; @@ -337,6 +359,7 @@ export interface PluginContribution { menus?: { [location: string]: Menu[] }; keybindings?: Keybinding[]; debuggers?: DebuggerContribution[]; + snippets?: Snippet[]; } export interface GrammarsContribution { diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index e4593709adafb..2ac7c6b174060 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -38,7 +38,9 @@ import { Menu, PluginPackageMenu, PluginPackageDebuggersContribution, - DebuggerContribution + DebuggerContribution, + JsonSerializedSnippets, + JsonSerializedSnippet } from '../../../common/plugin-protocol'; import * as fs from 'fs'; import * as path from 'path'; @@ -48,6 +50,7 @@ import { CharacterPair } from '../../../api/plugin-api'; import * as jsoncparser from 'jsonc-parser'; import { IJSONSchema } from '@theia/core/lib/common/json-schema'; import { deepClone } from '@theia/core/lib/common/objects'; +import { Snippet } from '@theia/monaco/lib/browser/monaco-snippet-suggest-provider'; namespace nls { export function localize(key: string, _default: string) { @@ -168,9 +171,79 @@ export class TheiaPluginScanner implements PluginScanner { contributions.debuggers = debuggers; } + contributions.snippets = this.readSnippets(rawPlugin); return contributions; } + protected readSnippets(pck: PluginPackage): Snippet[] | undefined { + if (!pck.contributes || !pck.contributes.snippets) { + return undefined; + } + const result: Snippet[] = []; + for (const contribution of pck.contributes.snippets) { + if (contribution.path) { + // TODO: load files lazy on snippet suggest + const snippets = this.readJson(path.join(pck.packagePath, contribution.path)); + this.parseSnippets(snippets, (name, snippet) => { + let { prefix, body, description } = snippet; + if (Array.isArray(body)) { + body = body.join('\n'); + } + if (typeof prefix !== 'string' || typeof body !== 'string') { + return; + } + const scopes: string[] = []; + if (contribution.language) { + scopes.push(contribution.language); + } else if (typeof snippet.scope === 'string') { + for (const rawScope of snippet.scope.split(',')) { + const scope = rawScope.trim(); + if (scope) { + scopes.push(scope); + } + } + } + const source = pck.displayName || pck.name; + result.push({ + scopes, + name, + prefix, + description, + body, + source + }); + }); + } + } + return result; + } + protected parseSnippets(snippets: JsonSerializedSnippets | undefined, accept: (name: string, snippet: JsonSerializedSnippet) => void): void { + if (typeof snippets === 'object') { + // tslint:disable-next-line:forin + for (const name in snippets) { + const scopeOrTemplate = snippets[name]; + if (JsonSerializedSnippet.is(scopeOrTemplate)) { + accept(name, scopeOrTemplate); + } else { + this.parseSnippets(scopeOrTemplate, accept); + } + } + } + } + + protected readJson(filePath: string): T | undefined { + const content = this.readFileSync(filePath); + return content ? jsoncparser.parse(content, undefined, { disallowComments: false }) : undefined; + } + protected readFileSync(filePath: string): string { + try { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''; + } catch (e) { + console.error(e); + return ''; + } + } + // tslint:disable-next-line:no-any private readConfiguration(rawConfiguration: any, pluginPath: string): any { return { @@ -244,11 +317,8 @@ export class TheiaPluginScanner implements PluginScanner { mimetypes: rawLang.mimetypes }; if (rawLang.configuration) { - const conf = fs.readFileSync(path.resolve(pluginPath, rawLang.configuration), 'utf8'); - if (conf) { - const strippedContent = jsoncparser.stripComments(conf); - const rawConfiguration: PluginPackageLanguageContributionConfiguration = jsoncparser.parse(strippedContent); - + const rawConfiguration = this.readJson(path.resolve(pluginPath, rawLang.configuration)); + if (rawConfiguration) { const configuration: LanguageConfiguration = { brackets: rawConfiguration.brackets, comments: rawConfiguration.comments, diff --git a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts index 9cffb1e743f86..01fc1fb56e998 100644 --- a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts @@ -23,6 +23,7 @@ import { PluginContribution, IndentationRules, FoldingRules, ScopeMap } from '.. import { PreferenceSchemaProvider } from '@theia/core/lib/browser'; import { PreferenceSchema } from '@theia/core/lib/browser/preferences'; import { KeybindingsContributionPointHandler } from './keybindings/keybindings-contribution-handler'; +import { MonacoSnippetSuggestProvider } from '@theia/monaco/lib/browser/monaco-snippet-suggest-provider'; @injectable() export class PluginContributionHandler { @@ -47,6 +48,9 @@ export class PluginContributionHandler { @inject(KeybindingsContributionPointHandler) private readonly keybindingsContributionHandler: KeybindingsContributionPointHandler; + @inject(MonacoSnippetSuggestProvider) + protected readonly snippetSuggestProvider: MonacoSnippetSuggestProvider; + handleContributions(contributions: PluginContribution): void { if (contributions.configuration) { this.updateConfigurationSchema(contributions.configuration); @@ -131,6 +135,9 @@ export class PluginContributionHandler { this.menusContributionHandler.handle(contributions); this.keybindingsContributionHandler.handle(contributions); + if (contributions.snippets) { + this.snippetSuggestProvider.push(...contributions.snippets); + } } private updateConfigurationSchema(schema: PreferenceSchema): void { diff --git a/packages/plugin-ext/src/main/node/resolvers/plugin-local-dir-resolver.ts b/packages/plugin-ext/src/main/node/resolvers/plugin-local-dir-resolver.ts index 05bee15ea17fb..d20de1a3cbb11 100644 --- a/packages/plugin-ext/src/main/node/resolvers/plugin-local-dir-resolver.ts +++ b/packages/plugin-ext/src/main/node/resolvers/plugin-local-dir-resolver.ts @@ -35,7 +35,10 @@ export class LocalDirectoryPluginDeployerResolver implements PluginDeployerResol return; } // remove prefix - const dirPath = localDirSetting.substring('local-dir'.length + 1); + let dirPath = localDirSetting.substring('local-dir'.length + 1); + if (!path.isAbsolute(dirPath)) { + dirPath = path.resolve(process.cwd(), dirPath); + } // check directory exists if (!fs.existsSync(dirPath)) {