UNPKG

@theia/monaco

Version:
307 lines (274 loc) • 12.9 kB
// ***************************************************************************** // 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-only WITH Classpath-exception-2.0 // ***************************************************************************** /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as jsoncparser from 'jsonc-parser'; import { injectable, inject } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileOperationError } from '@theia/filesystem/lib/common/files'; import * as monaco from '@theia/monaco-editor-core'; import { SnippetParser } from '@theia/monaco-editor-core/esm/vs/editor/contrib/snippet/browser/snippetParser'; import { isObject } from '@theia/core/lib/common'; @injectable() export class MonacoSnippetSuggestProvider implements monaco.languages.CompletionItemProvider { private static readonly _maxPrefix = 10000; @inject(FileService) protected readonly fileService: FileService; protected readonly snippets = new Map<string, Snippet[]>(); protected readonly pendingSnippets = new Map<string, Promise<void>[]>(); async provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position, context: monaco.languages.CompletionContext): Promise<monaco.languages.CompletionList | undefined> { // copied and modified from https://github.com/microsoft/vscode/blob/master/src/vs/workbench/contrib/snippets/browser/snippetCompletionProvider.ts if (position.column >= MonacoSnippetSuggestProvider._maxPrefix) { return undefined; } if (context.triggerKind === monaco.languages.CompletionTriggerKind.TriggerCharacter && context.triggerCharacter === ' ') { // no snippets when suggestions have been triggered by space return undefined; } const languageId = model.getLanguageId(); // TODO: look up a language id at the position await this.loadSnippets(languageId); const snippetsForLanguage = this.snippets.get(languageId) || []; const pos = { lineNumber: position.lineNumber, column: 1 }; const lineOffsets: number[] = []; const linePrefixLow = model.getLineContent(position.lineNumber).substring(0, position.column - 1).toLowerCase(); const endsInWhitespace = linePrefixLow.match(/\s$/); while (pos.column < position.column) { const word = model.getWordAtPosition(pos); if (word) { // at a word lineOffsets.push(word.startColumn - 1); pos.column = word.endColumn + 1; if (word.endColumn - 1 < linePrefixLow.length && !/\s/.test(linePrefixLow[word.endColumn - 1])) { lineOffsets.push(word.endColumn - 1); } } else if (!/\s/.test(linePrefixLow[pos.column - 1])) { // at a none-whitespace character lineOffsets.push(pos.column - 1); pos.column += 1; } else { // always advance! pos.column += 1; } } const availableSnippets = new Set<Snippet>(); snippetsForLanguage.forEach(availableSnippets.add, availableSnippets); const suggestions: MonacoSnippetSuggestion[] = []; for (const start of lineOffsets) { availableSnippets.forEach(snippet => { if (this.isPatternInWord(linePrefixLow, start, linePrefixLow.length, snippet.prefix.toLowerCase(), 0, snippet.prefix.length)) { suggestions.push(new MonacoSnippetSuggestion(snippet, monaco.Range.fromPositions(position.delta(0, -(linePrefixLow.length - start)), position))); availableSnippets.delete(snippet); } }); } if (endsInWhitespace || lineOffsets.length === 0) { // add remaining snippets when the current prefix ends in whitespace or when no // interesting positions have been found availableSnippets.forEach(snippet => { suggestions.push(new MonacoSnippetSuggestion(snippet, monaco.Range.fromPositions(position))); }); } // disambiguate suggestions with same labels suggestions.sort(MonacoSnippetSuggestion.compareByLabel); return { suggestions }; } resolveCompletionItem?(item: monaco.languages.CompletionItem, token: monaco.CancellationToken): monaco.languages.CompletionItem { return item instanceof MonacoSnippetSuggestion ? item.resolve() : item; } protected async loadSnippets(scope: string): Promise<void> { const pending: Promise<void>[] = []; pending.push(...(this.pendingSnippets.get(scope) || [])); pending.push(...(this.pendingSnippets.get('*') || [])); if (pending.length) { await Promise.all(pending); } } fromURI(uri: string | URI, options: SnippetLoadOptions): Disposable { const toDispose = new DisposableCollection(Disposable.create(() => { /* mark as not disposed */ })); const pending = this.loadURI(uri, options, toDispose); const { language } = options; const scopes = Array.isArray(language) ? language : !!language ? [language] : ['*']; for (const scope of scopes) { const pendingSnippets = this.pendingSnippets.get(scope) || []; pendingSnippets.push(pending); this.pendingSnippets.set(scope, pendingSnippets); toDispose.push(Disposable.create(() => { const index = pendingSnippets.indexOf(pending); if (index !== -1) { pendingSnippets.splice(index, 1); } })); } return toDispose; } /** * should NOT throw to prevent load errors on suggest */ protected async loadURI(uri: string | URI, options: SnippetLoadOptions, toDispose: DisposableCollection): Promise<void> { try { const resource = typeof uri === 'string' ? new URI(uri) : uri; const { value } = await this.fileService.read(resource); if (toDispose.disposed) { return; } const snippets = value && jsoncparser.parse(value, undefined, { disallowComments: false }); toDispose.push(this.fromJSON(snippets, options)); } catch (e) { if (!(e instanceof FileOperationError)) { console.error(e); } } } fromJSON(snippets: JsonSerializedSnippets | undefined, { language, source }: SnippetLoadOptions): Disposable { const toDispose = new DisposableCollection(); this.parseSnippets(snippets, (name, snippet) => { const { isFileTemplate, prefix, body, description } = snippet; const parsedBody = Array.isArray(body) ? body.join('\n') : body; const parsedPrefixes = !prefix ? [''] : Array.isArray(prefix) ? prefix : [prefix]; if (typeof parsedBody !== 'string') { return; } const scopes: string[] = []; if (language) { if (Array.isArray(language)) { scopes.push(...language); } else { scopes.push(language); } } else if (typeof snippet.scope === 'string') { for (const rawScope of snippet.scope.split(',')) { const scope = rawScope.trim(); if (scope) { scopes.push(scope); } } } parsedPrefixes.forEach(parsedPrefix => toDispose.push(this.push({ isFileTemplate: Boolean(isFileTemplate), scopes, name, prefix: parsedPrefix, description, body: parsedBody, source }))); }); return toDispose; } protected parseSnippets(snippets: JsonSerializedSnippets | undefined, accept: (name: string, snippet: JsonSerializedSnippet) => void): void { for (const [name, scopeOrTemplate] of Object.entries(snippets ?? {})) { if (JsonSerializedSnippet.is(scopeOrTemplate)) { accept(name, scopeOrTemplate); } else { // eslint-disable-next-line @typescript-eslint/no-shadow for (const [name, template] of Object.entries(scopeOrTemplate)) { accept(name, template); } } } } push(...snippets: Snippet[]): Disposable { const toDispose = new DisposableCollection(); for (const snippet of snippets) { for (const scope of snippet.scopes) { const languageSnippets = this.snippets.get(scope) || []; languageSnippets.push(snippet); this.snippets.set(scope, languageSnippets); toDispose.push(Disposable.create(() => { const index = languageSnippets.indexOf(snippet); if (index !== -1) { languageSnippets.splice(index, 1); } })); } } return toDispose; } protected isPatternInWord(patternLow: string, patternPos: number, patternLen: number, wordLow: string, wordPos: number, wordLen: number): boolean { while (patternPos < patternLen && wordPos < wordLen) { if (patternLow[patternPos] === wordLow[wordPos]) { patternPos += 1; } wordPos += 1; } return patternPos === patternLen; // pattern must be exhausted } } export interface SnippetLoadOptions { language?: string | string[] source: string } export interface JsonSerializedSnippets { [name: string]: JsonSerializedSnippet | { [name: string]: JsonSerializedSnippet }; } export interface JsonSerializedSnippet { isFileTemplate?: boolean; body: string | string[]; scope?: string; prefix?: string | string[]; description: string; } export namespace JsonSerializedSnippet { export function is(obj: unknown): obj is JsonSerializedSnippet { return isObject(obj) && 'body' in obj; } } export interface Snippet { readonly isFileTemplate: boolean readonly scopes: string[] readonly name: string readonly prefix: string readonly description: string readonly body: string readonly source: string } export class MonacoSnippetSuggestion implements monaco.languages.CompletionItem { readonly label: string; readonly detail: string; readonly sortText: string; readonly noAutoAccept = true; readonly kind = monaco.languages.CompletionItemKind.Snippet; readonly insertTextRules = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet; insertText: string; documentation?: monaco.IMarkdownString; constructor( protected readonly snippet: Snippet, readonly range: monaco.Range ) { this.label = snippet.prefix; this.detail = `${snippet.description || snippet.name} (${snippet.source})`; this.insertText = snippet.body; this.sortText = `z-${snippet.prefix}`; this.range = range; } protected resolved = false; resolve(): MonacoSnippetSuggestion { if (!this.resolved) { const codeSnippet = new SnippetParser().parse(this.snippet.body).toString(); this.documentation = { value: '```\n' + codeSnippet + '```' }; this.resolved = true; } return this; } static compareByLabel(a: MonacoSnippetSuggestion, b: MonacoSnippetSuggestion): number { return a.label > b.label ? 1 : a.label < b.label ? -1 : 0; } }