diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d9ef910d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +/out/ +/release/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..c16d57c5 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +/.vscode/ +/lib/ +/out/ +/src/ +/test/ +/release/dev/ +/gulpfile.js +/.npmignore diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1dc255b6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.trimTrailingWhitespace": true, + "search.exclude": { + "**/node_modules": true, + "**/release": true, + "**/out": true + } +} \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..f8a94f6e --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index bbeb0b9b..6a714ec0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,48 @@ -# monaco-html -HTML language plugin for the Monaco Editor. +# Monaco HTML + +HTML language plugin for the Monaco Editor. It provides the following features when editing HTML files: +* Code completion +* Formatting +* Document Highlights +* Link detection +* Syntax highlighting + +Internally the HTML plugin uses the [vscode-html-languageservice](https://github.com/Microsoft/vscode-html-languageservice) +node module, providing the implementation of the functionally listed above. The same module is also used +in [Visual Studio Code](https://github.com/Microsoft/vscode) to power the HTML editing experience. + +## Issues + +Please file issues concering `monaco-html` in the [`monaco-editor`-repository](https://github.com/Microsoft/monaco-editor/issues). + +## Installing + +This npm module is bundled and distributed in the [monaco-editor](https://www.npmjs.com/package/monaco-editor) npm module. + +* change to your favorite source folder (`/src/`) +* `git clone https://github.com/Microsoft/monaco-editor` (this will create `$/src/monaco-editor`) +* in folder `monaco-editor` run `npm install` and run `npm run simpleserver` +* open http://localhost:8080/monaco-editor/test/index.html#sample - html + +## Development + +### Dev: Running monaco-html from source +* change to your favorite source folder (`/src/`). +* if you haven't done so: `git clone https://github.com/Microsoft/monaco-editor` (this will create `$/src/monaco-editor`) +* `git clone https://github.com/Microsoft/monaco-html` (this will create `$/src/monaco-html`) +* Important: both monaco repositories must have the same parent folder. +* in folder `monaco-html` run `npm install` and run `npm run watch` +* in folder `monaco-editor` run `npm install` and run `npm run simpleserver` +* open http://localhost:8080/monaco-editor/test/?monaco-html=dev + +### [Optional] Running monaco-editor-core from source + +* this is only needed when you want to make changes also in `monaco-editor-core`. +* change to the same favorite source folder (`/src/`) that already contains `monaco-html` and `monaco-editor` +* `git clone https://github.com/Microsoft/vscode` (this will create `$/src/vscode/`) +* read [here](https://github.com/Microsoft/vscode/wiki/How-to-Contribute#installing-prerequisites) on how to initialize the VS Code source repository. +* in folder `vscode` run `gulp watch` +* open http://localhost:8080/monaco-editor/test/?monaco-html=dev&editor=dev + +## License +[MIT](https://github.com/Microsoft/monaco-html/blob/master/LICENSE.md) diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 00000000..6993e43d --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,246 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +var gulp = require('gulp'); +var tsb = require('gulp-tsb'); +var assign = require('object-assign'); +var fs = require('fs'); +var path = require('path'); +var merge = require('merge-stream'); +var rjs = require('gulp-requirejs'); +var uglify = require('gulp-uglify'); +var rimraf = require('rimraf'); +var es = require('event-stream'); + +gulp.task('clean-release', function(cb) { rimraf('release', { maxBusyTries: 1 }, cb); }); +gulp.task('release', ['clean-release','compile'], function() { + + var sha1 = getGitVersion(__dirname); + var semver = require('./package.json').version; + var headerVersion = semver + '(' + sha1 + ')'; + + var BUNDLED_FILE_HEADER = [ + '/*!-----------------------------------------------------------------------------', + ' * Copyright (c) Microsoft Corporation. All rights reserved.', + ' * monaco-html version: ' + headerVersion, + ' * Released under the MIT license', + ' * https://github.com/Microsoft/monaco-html/blob/master/LICENSE.md', + ' *-----------------------------------------------------------------------------*/', + '' + ].join('\n'); + + function getDependencyLocation(name, libLocation, container) { + var location = __dirname + '/node_modules/' + name + '/' + libLocation; + if (!fs.existsSync(location)) { + var oldLocation = __dirname + '/node_modules/' + container + '/node_modules/' + name + '/' + libLocation; + if (!fs.existsSync(oldLocation)) { + console.error('Unable to find ' + name + ' node module at ' + location + ' or ' + oldLocation); + return; + } + return oldLocation; + } + return location; + } + + var uriLocation = getDependencyLocation('vscode-uri', 'lib', 'vscode-html-languageservice'); + + function bundleOne(moduleId, exclude) { + + + return rjs({ + baseUrl: '/out/', + name: 'vs/language/html/' + moduleId, + out: moduleId + '.js', + exclude: exclude, + paths: { + 'vs/language/html': __dirname + '/out' + }, + packages: [{ + name: 'vscode-html-languageservice/lib/parser/htmlScanner', + location: __dirname + '/node_modules/vscode-html-languageservice/lib/parser', + main: 'htmlScanner' + }, { + name: 'vscode-html-languageservice', + location: __dirname + '/node_modules/vscode-html-languageservice/lib', + main: 'htmlLanguageService' + }, { + name: 'vscode-languageserver-types', + location: __dirname + '/node_modules/vscode-languageserver-types/lib', + main: 'main' + }, { + name: 'vscode-uri', + location: uriLocation, + main: 'index' + }, { + name: 'vscode-nls', + location: __dirname + '/out/fillers', + main: 'vscode-nls' + }] + }) + } + + return merge( + merge( + bundleOne('monaco.contribution', ['vs/language/html/htmlMode']), + bundleOne('htmlMode'), + bundleOne('htmlWorker') + ) + .pipe(es.through(function(data) { + data.contents = new Buffer( + BUNDLED_FILE_HEADER + + data.contents.toString() + ); + this.emit('data', data); + })) + .pipe(gulp.dest('./release/dev')) + .pipe(uglify({ + preserveComments: 'some' + })) + .pipe(gulp.dest('./release/min')), + gulp.src('src/monaco.d.ts').pipe(gulp.dest('./release/min')) + ); +}); + + +var compilation = tsb.create(assign({ verbose: true }, require('./src/tsconfig.json').compilerOptions)); + +var tsSources = 'src/**/*.ts'; + +function compileTask() { + return merge( + gulp.src(tsSources).pipe(compilation()) + ) + .pipe(gulp.dest('out')); +} + +gulp.task('clean-out', function(cb) { rimraf('out', { maxBusyTries: 1 }, cb); }); +gulp.task('compile', ['clean-out'], compileTask); +gulp.task('compile-without-clean', compileTask); +gulp.task('watch', ['compile'], function() { + gulp.watch(tsSources, ['compile-without-clean']); +}); + + +/** + * Escape text such that it can be used in a javascript string enclosed by double quotes (") + */ +function escapeText(text) { + // http://www.javascriptkit.com/jsref/escapesequence.shtml + // \b Backspace. + // \f Form feed. + // \n Newline. + // \O Nul character. + // \r Carriage return. + // \t Horizontal tab. + // \v Vertical tab. + // \' Single quote or apostrophe. + // \" Double quote. + // \\ Backslash. + // \ddd The Latin-1 character specified by the three octal digits between 0 and 377. ie, copyright symbol is \251. + // \xdd The Latin-1 character specified by the two hexadecimal digits dd between 00 and FF. ie, copyright symbol is \xA9. + // \udddd The Unicode character specified by the four hexadecimal digits dddd. ie, copyright symbol is \u00A9. + var _backspace = '\b'.charCodeAt(0); + var _formFeed = '\f'.charCodeAt(0); + var _newLine = '\n'.charCodeAt(0); + var _nullChar = 0; + var _carriageReturn = '\r'.charCodeAt(0); + var _tab = '\t'.charCodeAt(0); + var _verticalTab = '\v'.charCodeAt(0); + var _backslash = '\\'.charCodeAt(0); + var _doubleQuote = '"'.charCodeAt(0); + + var startPos = 0, chrCode, replaceWith = null, resultPieces = []; + + for (var i = 0, len = text.length; i < len; i++) { + chrCode = text.charCodeAt(i); + switch (chrCode) { + case _backspace: + replaceWith = '\\b'; + break; + case _formFeed: + replaceWith = '\\f'; + break; + case _newLine: + replaceWith = '\\n'; + break; + case _nullChar: + replaceWith = '\\0'; + break; + case _carriageReturn: + replaceWith = '\\r'; + break; + case _tab: + replaceWith = '\\t'; + break; + case _verticalTab: + replaceWith = '\\v'; + break; + case _backslash: + replaceWith = '\\\\'; + break; + case _doubleQuote: + replaceWith = '\\"'; + break; + } + if (replaceWith !== null) { + resultPieces.push(text.substring(startPos, i)); + resultPieces.push(replaceWith); + startPos = i + 1; + replaceWith = null; + } + } + resultPieces.push(text.substring(startPos, len)); + return resultPieces.join(''); +} + +function getGitVersion(repo) { + var git = path.join(repo, '.git'); + var headPath = path.join(git, 'HEAD'); + var head; + + try { + head = fs.readFileSync(headPath, 'utf8').trim(); + } catch (e) { + return void 0; + } + + if (/^[0-9a-f]{40}$/i.test(head)) { + return head; + } + + var refMatch = /^ref: (.*)$/.exec(head); + + if (!refMatch) { + return void 0; + } + + var ref = refMatch[1]; + var refPath = path.join(git, ref); + + try { + return fs.readFileSync(refPath, 'utf8').trim(); + } catch (e) { + // noop + } + + var packedRefsPath = path.join(git, 'packed-refs'); + var refsRaw; + + try { + refsRaw = fs.readFileSync(packedRefsPath, 'utf8').trim(); + } catch (e) { + return void 0; + } + + var refsRegex = /^([0-9a-f]{40})\s+(.+)$/gm; + var refsMatch; + var refs = {}; + + while (refsMatch = refsRegex.exec(refsRaw)) { + refs[refsMatch[2]] = refsMatch[1]; + } + + return refs[ref]; +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 00000000..bd3dff56 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "monaco-html", + "version": "1.0.0-next.1", + "description": "HTML plugin for the Monaco Editor", + "scripts": { + "compile": "gulp compile", + "watch": "gulp watch", + "prepublish": "gulp release", + "install-service-next": "npm install vscode-html-languageservice@next -f -D", + "install-service-local": "npm install ../vscode-html-languageservice -f -D" + }, + "author": "Microsoft Corporation", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/monaco-html" + }, + "bugs": { + "url": "https://github.com/Microsoft/monaco-editor/issues" + }, + "devDependencies": { + "event-stream": "^3.3.2", + "gulp": "^3.9.1", + "gulp-requirejs": "^0.1.3", + "gulp-tsb": "^1.10.4", + "gulp-uglify": "^1.5.3", + "merge-stream": "^1.0.0", + "monaco-editor-core": "0.6.0", + "object-assign": "^4.1.0", + "rimraf": "^2.5.2", + "typescript": "1.8.10", + "vscode-html-languageservice": "^1.0.0-next.2", + "vscode-languageserver-types": "^1.0.4-next.2" + } +} diff --git a/src/fillers/vscode-nls.ts b/src/fillers/vscode-nls.ts new file mode 100644 index 00000000..b9566a77 --- /dev/null +++ b/src/fillers/vscode-nls.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import {LocalizeInfo, LocalizeFunc, Options, LoadFunc} from 'vscode-nls'; + +export {LocalizeInfo, LocalizeFunc, Options, LoadFunc}; + +function format(message: string, args: any[]): string { + let result:string; + + if (args.length === 0) { + result = message; + } else { + result = message.replace(/\{(\d+)\}/g, (match, rest) => { + let index = rest[0]; + return typeof args[index] !== 'undefined' ? args[index] : match; + }); + } + return result; +} + +function localize(key: string | LocalizeInfo, message: string, ...args: any[]): string { + return format(message, args); +} + +export function loadMessageBundle(file?: string): LocalizeFunc { + return localize; +} + +export function config(opt?: Options | string): LoadFunc { + return loadMessageBundle; +} \ No newline at end of file diff --git a/src/htmlMode.ts b/src/htmlMode.ts new file mode 100644 index 00000000..e52428f8 --- /dev/null +++ b/src/htmlMode.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import {WorkerManager} from './workerManager'; +import {HTMLWorker} from './htmlWorker'; +import {LanguageServiceDefaultsImpl} from './monaco.contribution'; +import * as languageFeatures from './languageFeatures'; +import {createTokenizationSupport} from './tokenization'; + +import Promise = monaco.Promise; +import Uri = monaco.Uri; +import IDisposable = monaco.IDisposable; + +export function setupMode(defaults: LanguageServiceDefaultsImpl): void { + + let disposables: IDisposable[] = []; + + const client = new WorkerManager(defaults); + disposables.push(client); + + const worker: languageFeatures.WorkerAccessor = (...uris: Uri[]): Promise => { + return client.getLanguageServiceWorker(...uris); + }; + + let languageId = defaults.languageId; + + disposables.push(monaco.languages.registerCompletionItemProvider(languageId, new languageFeatures.CompletionAdapter(worker))); + disposables.push(monaco.languages.registerDocumentHighlightProvider(languageId, new languageFeatures.DocumentHighlightAdapter(worker))); + disposables.push(monaco.languages.registerDocumentFormattingEditProvider(languageId, new languageFeatures.DocumentFormattingEditProvider(worker))); + disposables.push(monaco.languages.registerDocumentRangeFormattingEditProvider(languageId, new languageFeatures.DocumentRangeFormattingEditProvider(worker))); + disposables.push(new languageFeatures.DiagnostcsAdapter(languageId, worker)); + disposables.push(monaco.languages.registerLinkProvider(languageId, new languageFeatures.DocumentLinkAdapter(worker))); + disposables.push(monaco.languages.setTokensProvider(languageId, createTokenizationSupport(true))); + disposables.push(monaco.languages.setLanguageConfiguration(languageId, richEditConfiguration)); +} + +const EMPTY_ELEMENTS:string[] = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr']; + +const richEditConfiguration: monaco.languages.LanguageConfiguration = { + wordPattern: /(-?\d*\.\d\w*)|([^\[\{\]\}\:\"\,\s]+)/g, + + comments: { + blockComment: [''] + }, + + brackets: [ + [''], + ['<', '>'], + ], + + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: '\'', close: '\'' } + ], + + surroundingPairs: [ + { open: '"', close: '"' }, + { open: '\'', close: '\'' } + ], + + onEnterRules: [ + { + beforeText: new RegExp(`<(?!(?:${EMPTY_ELEMENTS.join('|')}))([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$`, 'i'), + afterText: /^<\/([_:\w][_:\w-.\d]*)\s*>$/i, + action: { indentAction: monaco.languages.IndentAction.IndentOutdent } + }, + { + beforeText: new RegExp(`<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`, 'i'), + action: { indentAction: monaco.languages.IndentAction.Indent } + } + ], +}; + diff --git a/src/htmlWorker.ts b/src/htmlWorker.ts new file mode 100644 index 00000000..1db002ea --- /dev/null +++ b/src/htmlWorker.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import IWorkerContext = monaco.worker.IWorkerContext; + +import Thenable = monaco.Thenable; +import Promise = monaco.Promise; + +import * as htmlService from 'vscode-html-languageservice'; +import * as ls from 'vscode-languageserver-types'; + + +export class HTMLWorker { + + private _ctx:IWorkerContext; + private _languageService: htmlService.LanguageService; + private _languageSettings: monaco.languages.html.Options; + private _languageId: string; + + constructor(ctx:IWorkerContext, createData: ICreateData) { + this._ctx = ctx; + this._languageSettings = createData.languageSettings; + this._languageId = createData.languageId; + this._languageService = htmlService.getLanguageService(); + } + + doValidation(uri: string): Thenable { + // not yet suported + return Promise.as([]); + } + doComplete(uri: string, position: ls.Position): Thenable { + let document = this._getTextDocument(uri); + let htmlDocument = this._languageService.parseHTMLDocument(document); + return Promise.as(this._languageService.doComplete(document, position, htmlDocument)); + } + format(uri: string, range: ls.Range, options: ls.FormattingOptions): Thenable { + let document = this._getTextDocument(uri); + let textEdits = this._languageService.format(document, range, this._languageSettings && this._languageSettings.format); + return Promise.as(textEdits); + } + findDocumentHighlights(uri: string, position: ls.Position): Promise { + let document = this._getTextDocument(uri); + let htmlDocument = this._languageService.parseHTMLDocument(document); + let highlights = this._languageService.findDocumentHighlights(document, position, htmlDocument); + return Promise.as(highlights); + } + findDocumentLinks(uri: string, workspacePath: string): Promise { + let document = this._getTextDocument(uri); + let links = this._languageService.findDocumentLinks(document, workspacePath); + return Promise.as(links); + } + private _getTextDocument(uri: string): ls.TextDocument { + let models = this._ctx.getMirrorModels(); + for (let model of models) { + if (model.uri.toString() === uri) { + return ls.TextDocument.create(uri, this._languageId, model.version, model.getValue()); + } + } + return null; + } +} + +export interface ICreateData { + languageId: string; + languageSettings: monaco.languages.html.Options; +} + +export function create(ctx:IWorkerContext, createData: ICreateData): HTMLWorker { + return new HTMLWorker(ctx, createData); +} diff --git a/src/languageFeatures.ts b/src/languageFeatures.ts new file mode 100644 index 00000000..aeb8fb05 --- /dev/null +++ b/src/languageFeatures.ts @@ -0,0 +1,440 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import {LanguageServiceDefaultsImpl} from './monaco.contribution'; +import {HTMLWorker} from './htmlWorker'; + +import * as ls from 'vscode-languageserver-types'; + +import Uri = monaco.Uri; +import Position = monaco.Position; +import Range = monaco.Range; +import Thenable = monaco.Thenable; +import Promise = monaco.Promise; +import CancellationToken = monaco.CancellationToken; +import IDisposable = monaco.IDisposable; + + +export interface WorkerAccessor { + (...more: Uri[]): Thenable +} + +// --- diagnostics --- --- + +export class DiagnostcsAdapter { + + private _disposables: IDisposable[] = []; + private _listener: { [uri: string]: IDisposable } = Object.create(null); + + constructor(private _languageId: string, private _worker: WorkerAccessor) { + const onModelAdd = (model: monaco.editor.IModel): void => { + let modeId = model.getModeId(); + if (modeId !== this._languageId) { + return; + } + + let handle: number; + this._listener[model.uri.toString()] = model.onDidChangeContent(() => { + clearTimeout(handle); + handle = setTimeout(() => this._doValidate(model.uri, modeId), 500); + }); + + this._doValidate(model.uri, modeId); + }; + + const onModelRemoved = (model: monaco.editor.IModel): void => { + monaco.editor.setModelMarkers(model, this._languageId, []); + delete this._listener[model.uri.toString()]; + }; + + this._disposables.push(monaco.editor.onDidCreateModel(onModelAdd)); + this._disposables.push(monaco.editor.onWillDisposeModel(model => { + onModelRemoved(model); + })); + this._disposables.push(monaco.editor.onDidChangeModelLanguage(event => { + onModelRemoved(event.model); + onModelAdd(event.model); + })); + + this._disposables.push({ + dispose: () => { + for (let key in this._listener) { + this._listener[key].dispose(); + } + } + }); + + monaco.editor.getModels().forEach(onModelAdd); + } + + public dispose(): void { + this._disposables.forEach(d => d && d.dispose()); + this._disposables = []; + } + + private _doValidate(resource: Uri, languageId: string): void { + this._worker(resource).then(worker => { + return worker.doValidation(resource.toString()).then(diagnostics => { + const markers = diagnostics.map(d => toDiagnostics(resource, d)); + monaco.editor.setModelMarkers(monaco.editor.getModel(resource), languageId, markers); + }); + }).then(undefined, err => { + console.error(err); + }); + } +} + + +function toSeverity(lsSeverity: number): monaco.Severity { + switch (lsSeverity) { + case ls.DiagnosticSeverity.Error: return monaco.Severity.Error; + case ls.DiagnosticSeverity.Warning: return monaco.Severity.Warning; + case ls.DiagnosticSeverity.Information: + case ls.DiagnosticSeverity.Hint: + default: + return monaco.Severity.Info; + } +} + +function toDiagnostics(resource: Uri, diag: ls.Diagnostic): monaco.editor.IMarkerData { + let code = typeof diag.code === 'number' ? String(diag.code) : diag.code; + + return { + severity: toSeverity(diag.severity), + startLineNumber: diag.range.start.line + 1, + startColumn: diag.range.start.character + 1, + endLineNumber: diag.range.end.line + 1, + endColumn: diag.range.end.character + 1, + message: diag.message, + code: code, + source: diag.source + }; +} + +// --- completion ------ + +function fromPosition(position: Position): ls.Position { + if (!position) { + return void 0; + } + return { character: position.column - 1, line: position.lineNumber - 1 }; +} + +function fromRange(range: Range): ls.Range { + if (!range) { + return void 0; + } + return { start: fromPosition(range.getStartPosition()), end: fromPosition(range.getEndPosition()) }; +} + +function toRange(range: ls.Range): Range { + if (!range) { + return void 0; + } + return new Range(range.start.line + 1, range.start.character + 1, range.end.line + 1, range.end.character + 1); +} + +function toCompletionItemKind(kind: number): monaco.languages.CompletionItemKind { + let mItemKind = monaco.languages.CompletionItemKind; + + switch (kind) { + case ls.CompletionItemKind.Text: return mItemKind.Text; + case ls.CompletionItemKind.Method: return mItemKind.Method; + case ls.CompletionItemKind.Function: return mItemKind.Function; + case ls.CompletionItemKind.Constructor: return mItemKind.Constructor; + case ls.CompletionItemKind.Field: return mItemKind.Field; + case ls.CompletionItemKind.Variable: return mItemKind.Variable; + case ls.CompletionItemKind.Class: return mItemKind.Class; + case ls.CompletionItemKind.Interface: return mItemKind.Interface; + case ls.CompletionItemKind.Module: return mItemKind.Module; + case ls.CompletionItemKind.Property: return mItemKind.Property; + case ls.CompletionItemKind.Unit: return mItemKind.Unit; + case ls.CompletionItemKind.Value: return mItemKind.Value; + case ls.CompletionItemKind.Enum: return mItemKind.Enum; + case ls.CompletionItemKind.Keyword: return mItemKind.Keyword; + case ls.CompletionItemKind.Snippet: return mItemKind.Snippet; + case ls.CompletionItemKind.Color: return mItemKind.Color; + case ls.CompletionItemKind.File: return mItemKind.File; + case ls.CompletionItemKind.Reference: return mItemKind.Reference; + } + return mItemKind.Property; +} + +function fromCompletionItemKind(kind: monaco.languages.CompletionItemKind): ls.CompletionItemKind { + let mItemKind = monaco.languages.CompletionItemKind; + + switch (kind) { + case mItemKind.Text: return ls.CompletionItemKind.Text; + case mItemKind.Method: return ls.CompletionItemKind.Method; + case mItemKind.Function: return ls.CompletionItemKind.Function; + case mItemKind.Constructor: return ls.CompletionItemKind.Constructor; + case mItemKind.Field: return ls.CompletionItemKind.Field; + case mItemKind.Variable: return ls.CompletionItemKind.Variable; + case mItemKind.Class: return ls.CompletionItemKind.Class; + case mItemKind.Interface: return ls.CompletionItemKind.Interface; + case mItemKind.Module: return ls.CompletionItemKind.Module; + case mItemKind.Property: return ls.CompletionItemKind.Property; + case mItemKind.Unit: return ls.CompletionItemKind.Unit; + case mItemKind.Value: return ls.CompletionItemKind.Value; + case mItemKind.Enum: return ls.CompletionItemKind.Enum; + case mItemKind.Keyword: return ls.CompletionItemKind.Keyword; + case mItemKind.Snippet: return ls.CompletionItemKind.Snippet; + case mItemKind.Color: return ls.CompletionItemKind.Color; + case mItemKind.File: return ls.CompletionItemKind.File; + case mItemKind.Reference: return ls.CompletionItemKind.Reference; + } + return ls.CompletionItemKind.Property; +} + +function toTextEdit(textEdit: ls.TextEdit): monaco.editor.ISingleEditOperation { + if (!textEdit) { + return void 0; + } + return { + range: toRange(textEdit.range), + text: textEdit.newText + } +} + +function fromTextEdit(editOp: monaco.editor.ISingleEditOperation): ls.TextEdit { + if (!editOp) { + return void 0; + } + return { + range: fromRange(Range.lift(editOp.range)), + newText: editOp.text + } +} + +interface DataCompletionItem extends monaco.languages.CompletionItem { + data?: any; +} + +function toCompletionItem(entry: ls.CompletionItem): DataCompletionItem { + return { + label: entry.label, + insertText: entry.insertText, + sortText: entry.sortText, + filterText: entry.filterText, + documentation: entry.documentation, + detail: entry.detail, + kind: toCompletionItemKind(entry.kind), + textEdit: toTextEdit(entry.textEdit), + data: entry.data + }; +} + +function fromCompletionItem(entry: DataCompletionItem): ls.CompletionItem { + return { + label: entry.label, + insertText: entry.insertText, + sortText: entry.sortText, + filterText: entry.filterText, + documentation: entry.documentation, + detail: entry.detail, + kind: fromCompletionItemKind(entry.kind), + textEdit: fromTextEdit(entry.textEdit), + data: entry.data + }; +} + + +export class CompletionAdapter implements monaco.languages.CompletionItemProvider { + + constructor(private _worker: WorkerAccessor) { + } + + public get triggerCharacters(): string[] { + return ['.', ':', '<', '"', '=', '/']; + } + + provideCompletionItems(model: monaco.editor.IReadOnlyModel, position: Position, token: CancellationToken): Thenable { + const wordInfo = model.getWordUntilPosition(position); + const resource = model.uri; + + return wireCancellationToken(token, this._worker(resource).then(worker => { + return worker.doComplete(resource.toString(), fromPosition(position)).then(info => { + if (!info) { + return; + } + let items: monaco.languages.CompletionItem[] = info.items.map(entry => { + return { + label: entry.label, + insertText: entry.insertText, + sortText: entry.sortText, + filterText: entry.filterText, + documentation: entry.documentation, + detail: entry.detail, + kind: toCompletionItemKind(entry.kind), + textEdit: toTextEdit(entry.textEdit) + }; + }); + + return { + isIncomplete: info.isIncomplete, + items: items + }; + }); + })); + } +} + +function toMarkedStringArray(contents: ls.MarkedString | ls.MarkedString[]): monaco.MarkedString[] { + if (!contents) { + return void 0; + } + if (Array.isArray(contents)) { + return (contents); + } + return [contents]; +} + + +// --- definition ------ + +function toLocation(location: ls.Location): monaco.languages.Location { + return { + uri: Uri.parse(location.uri), + range: toRange(location.range) + }; +} + + +// --- document symbols ------ + +function toSymbolKind(kind: ls.SymbolKind): monaco.languages.SymbolKind { + let mKind = monaco.languages.SymbolKind; + + switch (kind) { + case ls.SymbolKind.File: return mKind.Array; + case ls.SymbolKind.Module: return mKind.Module; + case ls.SymbolKind.Namespace: return mKind.Namespace; + case ls.SymbolKind.Package: return mKind.Package; + case ls.SymbolKind.Class: return mKind.Class; + case ls.SymbolKind.Method: return mKind.Method; + case ls.SymbolKind.Property: return mKind.Property; + case ls.SymbolKind.Field: return mKind.Field; + case ls.SymbolKind.Constructor: return mKind.Constructor; + case ls.SymbolKind.Enum: return mKind.Enum; + case ls.SymbolKind.Interface: return mKind.Interface; + case ls.SymbolKind.Function: return mKind.Function; + case ls.SymbolKind.Variable: return mKind.Variable; + case ls.SymbolKind.Constant: return mKind.Constant; + case ls.SymbolKind.String: return mKind.String; + case ls.SymbolKind.Number: return mKind.Number; + case ls.SymbolKind.Boolean: return mKind.Boolean; + case ls.SymbolKind.Array: return mKind.Array; + } + return mKind.Function; +} + +function toHighlighKind(kind: ls.DocumentHighlightKind): monaco.languages.DocumentHighlightKind { + let mKind = monaco.languages.DocumentHighlightKind; + + switch (kind) { + case ls.DocumentHighlightKind.Read: return mKind.Read; + case ls.DocumentHighlightKind.Write: return mKind.Write; + case ls.DocumentHighlightKind.Text: return mKind.Text; + } + return mKind.Text; +} + + +export class DocumentHighlightAdapter implements monaco.languages.DocumentHighlightProvider { + + constructor(private _worker: WorkerAccessor) { + } + + public provideDocumentHighlights(model: monaco.editor.IReadOnlyModel, position: Position, token: CancellationToken): Thenable { + const resource = model.uri; + + return wireCancellationToken(token, this._worker(resource).then(worker => worker.findDocumentHighlights(resource.toString(), fromPosition(position))).then(items => { + if (!items) { + return; + } + return items.map(item => ({ + range: toRange(item.range), + kind: toHighlighKind(item.kind) + })); + })); + } +} + +export class DocumentLinkAdapter implements monaco.languages.LinkProvider { + + constructor(private _worker: WorkerAccessor) { + } + + public provideLinks(model: monaco.editor.IReadOnlyModel, token: CancellationToken): Thenable { + const resource = model.uri; + + return wireCancellationToken(token, this._worker(resource).then(worker => worker.findDocumentLinks(resource.toString(), null)).then(items => { + if (!items) { + return; + } + return items.map(item => ({ + range: toRange(item.range), + url: item.target + })); + })); + } +} + + +function fromFormattingOptions(options: monaco.languages.FormattingOptions): ls.FormattingOptions { + return { + tabSize: options.tabSize, + insertSpaces: options.insertSpaces + }; +} + +export class DocumentFormattingEditProvider implements monaco.languages.DocumentFormattingEditProvider { + + constructor(private _worker: WorkerAccessor) { + } + + public provideDocumentFormattingEdits(model: monaco.editor.IReadOnlyModel, options: monaco.languages.FormattingOptions, token: CancellationToken): Thenable { + const resource = model.uri; + + return wireCancellationToken(token, this._worker(resource).then(worker => { + return worker.format(resource.toString(), null, fromFormattingOptions(options)).then(edits => { + if (!edits || edits.length === 0) { + return; + } + return edits.map(toTextEdit); + }); + })); + } +} + +export class DocumentRangeFormattingEditProvider implements monaco.languages.DocumentRangeFormattingEditProvider { + + constructor(private _worker: WorkerAccessor) { + } + + public provideDocumentRangeFormattingEdits(model: monaco.editor.IReadOnlyModel, range: Range, options: monaco.languages.FormattingOptions, token: CancellationToken): Thenable { + const resource = model.uri; + + return wireCancellationToken(token, this._worker(resource).then(worker => { + return worker.format(resource.toString(), fromRange(range), fromFormattingOptions(options)).then(edits => { + if (!edits || edits.length === 0) { + return; + } + return edits.map(toTextEdit); + }); + })); + } +} + +/** + * Hook a cancellation token to a WinJS Promise + */ +function wireCancellationToken(token: CancellationToken, promise: Thenable): Thenable { + if ((>promise).cancel) { + token.onCancellationRequested(() => (>promise).cancel()); + } + return promise; +} diff --git a/src/monaco.contribution.ts b/src/monaco.contribution.ts new file mode 100644 index 00000000..b1134ba4 --- /dev/null +++ b/src/monaco.contribution.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as mode from './htmlMode'; + +import Emitter = monaco.Emitter; +import IEvent = monaco.IEvent; +import IDisposable = monaco.IDisposable; + +declare var require: (moduleId: [string], callback: (module: T) => void) => void; + +// --- HTML configuration and defaults --------- + +export class LanguageServiceDefaultsImpl implements monaco.languages.html.LanguageServiceDefaults { + + private _onDidChange = new Emitter(); + private _options: monaco.languages.html.Options; + private _languageId: string; + + constructor(languageId: string, options: monaco.languages.html.Options) { + this._languageId = languageId; + this.setOptions(options); + } + + get onDidChange(): IEvent { + return this._onDidChange.event; + } + + get languageId(): string { + return this._languageId; + } + + get options(): monaco.languages.html.Options { + return this._options; + } + + setOptions(options: monaco.languages.html.Options): void { + this._options = options || Object.create(null); + this._onDidChange.fire(this); + } +} + +const formatDefaults: monaco.languages.html.HTMLFormatConfiguration = { + tabSize: 4, + insertSpaces: false, + wrapLineLength: 120, + unformatted: 'a, abbr, acronym, b, bdo, big, br, button, cite, code, dfn, em, i, img, input, kbd, label, map, object, q, samp, script, select, small, span, strong, sub, sup, textarea, tt, var', + indentInnerHtml: false, + preserveNewLines: true, + maxPreserveNewLines: null, + indentHandlebars: false, + endWithNewline: false, + extraLiners: 'head, body, /html' +}; + +const htmlOptionsDefault: monaco.languages.html.Options = { + format: formatDefaults, + suggest: { html5: true, angular1: true, ionic: true } +} + +const handlebarOptionsDefault: monaco.languages.html.Options = { + format: formatDefaults, + suggest: { html5: true } +} + +const razorOptionsDefault: monaco.languages.html.Options = { + format: formatDefaults, + suggest: { html5: true, razor: true } +} + +const htmlLanguageId = 'html'; +const handlebarsLanguageId = 'handlebars'; +const razorLanguageId = 'razor'; + +const htmlDefaults = new LanguageServiceDefaultsImpl(htmlLanguageId, htmlOptionsDefault); +const handlebarDefaults = new LanguageServiceDefaultsImpl(handlebarsLanguageId, handlebarOptionsDefault); +const razorDefaults = new LanguageServiceDefaultsImpl(razorLanguageId, razorOptionsDefault); + +// Export API +function createAPI(): typeof monaco.languages.html { + return { + htmlDefaults: htmlDefaults, + razorDefaults: handlebarDefaults, + handlebarDefaults: razorDefaults + } +} +monaco.languages.html = createAPI(); + +// --- Registration to monaco editor --- + +function withMode(callback: (module: typeof mode) => void): void { + require(['vs/language/html/htmlMode'], callback); +} + +monaco.languages.register({ + id: htmlLanguageId, + extensions: ['.html', '.htm', '.shtml', '.xhtml', '.mdoc', '.jsp', '.asp', '.aspx', '.jshtm'], + aliases: ['HTML', 'htm', 'html', 'xhtml'], + mimetypes: ['text/html', 'text/x-jshtm', 'text/template', 'text/ng-template'] +}); +monaco.languages.register({ + id: handlebarsLanguageId, + extensions: ['.handlebars', '.hbs'], + aliases: ['Handlebars', 'handlebars'], + mimetypes: ['text/x-handlebars-template'] +}); +monaco.languages.register({ + id: razorLanguageId, + extensions: ['.cshtml'], + aliases: ['Razor', 'razor'], + mimetypes: ['text/x-cshtml'] +}); +monaco.languages.onLanguage(htmlLanguageId, () => { + withMode(mode => mode.setupMode(htmlDefaults)); +}); +monaco.languages.onLanguage(handlebarsLanguageId, () => { + withMode(mode => mode.setupMode(handlebarDefaults)); +}); +monaco.languages.onLanguage(razorLanguageId, () => { + withMode(mode => mode.setupMode(razorDefaults)); +}); diff --git a/src/monaco.d.ts b/src/monaco.d.ts new file mode 100644 index 00000000..9b2e6b85 --- /dev/null +++ b/src/monaco.d.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module monaco.languages.html { + export interface HTMLFormatConfiguration { + tabSize: number; + insertSpaces: boolean; + wrapLineLength: number; + unformatted: string; + indentInnerHtml: boolean; + preserveNewLines: boolean; + maxPreserveNewLines: number; + indentHandlebars: boolean; + endWithNewline: boolean; + extraLiners: string; + } + + export interface CompletionConfiguration { + [provider: string]: boolean; + } + + export interface Options { + /** + * If set, comments are tolerated. If set to false, syntax errors will be emmited for comments. + */ + format?: HTMLFormatConfiguration; + /** + * A list of known schemas and/or associations of schemas to file names. + */ + suggest?: CompletionConfiguration; + } + + export interface LanguageServiceDefaults { + onDidChange: IEvent; + options: Options; + setOptions(options: Options): void; + } + + export var htmlDefaults: LanguageServiceDefaults; + export var handlebarDefaults: LanguageServiceDefaults; + export var razorDefaults: LanguageServiceDefaults; +} \ No newline at end of file diff --git a/src/tokenization.ts b/src/tokenization.ts new file mode 100644 index 00000000..4683c7ca --- /dev/null +++ b/src/tokenization.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import {Scanner, ScannerState, TokenType, createScanner} from 'vscode-html-languageservice/lib/parser/htmlScanner'; + + +export function createTokenizationSupport(supportComments: boolean): monaco.languages.TokensProvider { + return { + getInitialState: () => new HTMLState(null, ScannerState.WithinContent), + tokenize: (line, state, offsetDelta?, stopAtOffset?) => tokenize(line, state, offsetDelta, stopAtOffset) + }; +} + +const DELIM_END = 'punctuation.definition.meta.tag.end.html'; +const DELIM_START = 'punctuation.definition.meta.tag.begin.html'; +const DELIM_ASSIGN = 'meta.tag.assign.html'; +const ATTRIB_NAME = 'entity.other.attribute-name.html'; +const ATTRIB_VALUE = 'string.html'; +const COMMENT = 'comment.html.content'; +const DELIM_COMMENT = 'comment.html'; +const DOCTYPE = 'entity.other.attribute-name.html'; +const DELIM_DOCTYPE = 'entity.name.tag.html'; + +function getTag(name: string) { + return TAG_PREFIX + name; +} + +const TAG_PREFIX = 'entity.name.tag.tag-'; + +class HTMLState implements monaco.languages.IState { + + private _state: monaco.languages.IState; + + public scannerState: ScannerState; + + constructor(state: monaco.languages.IState, scannerState: ScannerState) { + this._state = state; + this.scannerState = scannerState; + } + + public clone(): HTMLState { + return new HTMLState(this._state, this.scannerState); + } + + public equals(other: monaco.languages.IState): boolean { + if (other === this) { + return true; + } + if (!other || !(other instanceof HTMLState)) { + return false; + } + return this.scannerState === (other).scannerState; + } + + public getStateData(): monaco.languages.IState { + return this._state; + } + + public setStateData(state: monaco.languages.IState): void { + this._state = state; + } +} + + +function tokenize(line: string, state: HTMLState, offsetDelta: number = 0, stopAtOffset = line.length): monaco.languages.ILineTokens { + + let scanner = createScanner(line, 0, state && state.scannerState); + let tokenType = scanner.scan(); + let ret = { + tokens: [], + endState: state.clone() + }; + let position = -1; + while (tokenType !== TokenType.EOS && scanner.getTokenOffset() < stopAtOffset) { + let scope; + switch (tokenType) { + case TokenType.AttributeName: + scope = ATTRIB_NAME; + break; + case TokenType.AttributeValue: + scope = ATTRIB_VALUE; + break; + case TokenType.StartTag: + case TokenType.EndTag: + scope = getTag(scanner.getTokenText()); + break; + case TokenType.DelimiterAssign: + scope = DELIM_ASSIGN; + break; + case TokenType.StartTagOpen: + case TokenType.StartTagClose: + case TokenType.StartTagSelfClose: + scope = DELIM_START; + break; + case TokenType.EndTagOpen: + case TokenType.EndTagClose: + scope = DELIM_END; + break; + case TokenType.Doctype: + scope = DOCTYPE; + break; + case TokenType.StartDoctypeTag: + case TokenType.EndDoctypeTag: + scope = DELIM_DOCTYPE; + break; + case TokenType.Comment: + scope = COMMENT; + break; + case TokenType.StartCommentTag: + case TokenType.EndCommentTag: + scope = DELIM_COMMENT; + break; + default: + scope = ''; + break; + + } + if (position < scanner.getTokenOffset()) { + ret.tokens.push({ + startIndex: scanner.getTokenOffset() + offsetDelta, + scopes: scope + }); + } else { + throw new Error('Scanner did not advance, next 3 characters are: ' + line.substr(scanner.getTokenOffset(), 3)); + } + position = scanner.getTokenOffset(); + + tokenType = scanner.scan(); + } + ret.endState = new HTMLState(state.getStateData(), scanner.getScannerState()); + + return ret; +} \ No newline at end of file diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 00000000..6bd5e44b --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "umd", + "moduleResolution": "node", + "outDir": "../out", + "target": "es5" + } +} \ No newline at end of file diff --git a/src/typings/refs.d.ts b/src/typings/refs.d.ts new file mode 100644 index 00000000..8c3ed26d --- /dev/null +++ b/src/typings/refs.d.ts @@ -0,0 +1,5 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/// \ No newline at end of file diff --git a/src/workerManager.ts b/src/workerManager.ts new file mode 100644 index 00000000..1a3c81f1 --- /dev/null +++ b/src/workerManager.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import {LanguageServiceDefaultsImpl} from './monaco.contribution'; +import {HTMLWorker} from './htmlWorker'; + +import Promise = monaco.Promise; +import IDisposable = monaco.IDisposable; +import Uri = monaco.Uri; + +const STOP_WHEN_IDLE_FOR = 2 * 60 * 1000; // 2min + +export class WorkerManager { + + private _defaults: LanguageServiceDefaultsImpl; + private _idleCheckInterval: number; + private _lastUsedTime: number; + private _configChangeListener: IDisposable; + + private _worker: monaco.editor.MonacoWebWorker; + private _client: Promise; + + constructor(defaults: LanguageServiceDefaultsImpl) { + this._defaults = defaults; + this._worker = null; + this._idleCheckInterval = setInterval(() => this._checkIfIdle(), 30 * 1000); + this._lastUsedTime = 0; + this._configChangeListener = this._defaults.onDidChange(() => this._stopWorker()); + } + + private _stopWorker(): void { + if (this._worker) { + this._worker.dispose(); + this._worker = null; + } + this._client = null; + } + + dispose(): void { + clearInterval(this._idleCheckInterval); + this._configChangeListener.dispose(); + this._stopWorker(); + } + + private _checkIfIdle(): void { + if (!this._worker) { + return; + } + let timePassedSinceLastUsed = Date.now() - this._lastUsedTime; + if (timePassedSinceLastUsed > STOP_WHEN_IDLE_FOR) { + this._stopWorker(); + } + } + + private _getClient(): Promise { + this._lastUsedTime = Date.now(); + + if (!this._client) { + this._worker = monaco.editor.createWebWorker({ + + // module that exports the create() method and returns a `HTMLWorker` instance + moduleId: 'vs/language/html/htmlWorker', + + // passed in to the create() method + createData: { + languageSettings: this._defaults.options, + languageId: this._defaults.languageId + } + }); + + this._client = this._worker.getProxy(); + } + + return this._client; + } + + getLanguageServiceWorker(...resources: Uri[]): Promise { + let _client: HTMLWorker; + return toShallowCancelPromise( + this._getClient().then((client) => { + _client = client + }).then(_ => { + return this._worker.withSyncedResources(resources) + }).then(_ => _client) + ); + } +} + +function toShallowCancelPromise(p: Promise): Promise { + let completeCallback: (value: T) => void; + let errorCallback: (err: any) => void; + + let r = new Promise((c, e) => { + completeCallback = c; + errorCallback = e; + }, () => { }); + + p.then(completeCallback, errorCallback); + + return r; +}