diff --git a/extensions/html/server/src/service/parser/htmlScanner.ts b/extensions/html/server/src/service/parser/htmlScanner.ts index 9c49bc52b0c..8d1f565b088 100644 --- a/extensions/html/server/src/service/parser/htmlScanner.ts +++ b/extensions/html/server/src/service/parser/htmlScanner.ts @@ -4,6 +4,9 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; +import * as nls from 'vscode-nls'; +let localize = nls.loadMessageBundle(); + export enum TokenType { StartCommentTag, Comment, @@ -184,6 +187,7 @@ export enum ScannerState { AfterOpeningEndTag, WithinDoctype, WithinTag, + WithinEndTag, WithinComment, WithinScriptContent, WithinStyleContent, @@ -198,6 +202,7 @@ export interface Scanner { getTokenLength(): number; getTokenEnd(): number; getTokenText(): string; + getTokenError(): string; getScannerState(): ScannerState; } @@ -207,6 +212,7 @@ export function createScanner(input: string, initialOffset = 0, initialState: Sc let state = initialState; let tokenOffset: number = 0; let tokenType: number = void 0; + let tokenError: string; let hasSpaceAfterTag: boolean; let lastTag: string; @@ -219,9 +225,10 @@ export function createScanner(input: string, initialOffset = 0, initialState: Sc return stream.advanceIfRegExp(/^[^\s"'>/=\x00-\x0F\x7F\x80-\x9F]*/).toLowerCase(); } - function finishToken(offset: number, type: TokenType): TokenType { + function finishToken(offset: number, type: TokenType, errorMessage?: string): TokenType { tokenType = type; tokenOffset = offset; + tokenError = errorMessage; return type; } @@ -230,6 +237,7 @@ export function createScanner(input: string, initialOffset = 0, initialState: Sc if (stream.eos()) { return finishToken(offset, TokenType.EOS); } + let errorMessage; switch (state) { case ScannerState.WithinComment: @@ -270,13 +278,24 @@ export function createScanner(input: string, initialOffset = 0, initialState: Sc case ScannerState.AfterOpeningEndTag: let tagName = nextElementName(); if (tagName.length > 0) { + state = ScannerState.WithinEndTag; return finishToken(offset, TokenType.EndTag); - } else if (stream.advanceIfChar(_RAN)) { // > + } + if (stream.skipWhitespace()) { // white space is not valid here + return finishToken(offset, TokenType.Whitespace, localize('error.unexpectedWhitespace', 'Tag name must directly follow the open bracket.')); + } + stream.advanceUntilChar(_RAN); + return finishToken(offset, TokenType.Unknown, localize('error.endTagNameExpected', 'End tag name expected.')); + case ScannerState.WithinEndTag: + if (stream.skipWhitespace()) { // white space is valid here + return finishToken(offset, TokenType.Whitespace); + } + if (stream.advanceIfChar(_RAN)) { // > state = ScannerState.WithinContent; return finishToken(offset, TokenType.EndTagClose); } - stream.advanceUntilChar(_RAN); - return finishToken(offset, TokenType.Whitespace); + errorMessage = localize('error.tagNameExpected', 'Closing bracket expected.'); + break; case ScannerState.AfterOpeningStartTag: lastTag = nextElementName(); if (lastTag.length > 0) { @@ -284,7 +303,11 @@ export function createScanner(input: string, initialOffset = 0, initialState: Sc state = ScannerState.WithinTag; return finishToken(offset, TokenType.StartTag); } - break; + if (stream.skipWhitespace()) { // white space is not valid here + return finishToken(offset, TokenType.Whitespace, localize('error.unexpectedWhitespace', 'Tag name must directly follow the open bracket.')); + } + stream.advanceUntilChar(_RAN); + return finishToken(offset, TokenType.Unknown, localize('error.startTagNameExpected', 'Start tag name expected.')); case ScannerState.WithinTag: if (stream.skipWhitespace()) { hasSpaceAfterTag = true; // remember that we have seen a whitespace @@ -313,7 +336,7 @@ export function createScanner(input: string, initialOffset = 0, initialState: Sc return finishToken(offset, TokenType.StartTagClose); } stream.advance(1); - return finishToken(offset, TokenType.Unknown); + return finishToken(offset, TokenType.Unknown, localize('error.unexpectedCharacterInTag', 'Unexpected character in tag.')); case ScannerState.AfterAttributeName: if (stream.skipWhitespace()) { hasSpaceAfterTag = true; @@ -392,7 +415,7 @@ export function createScanner(input: string, initialOffset = 0, initialState: Sc stream.advance(1); state = ScannerState.WithinContent; - return finishToken(offset, TokenType.Unknown); + return finishToken(offset, TokenType.Unknown, errorMessage); } return { scan, @@ -401,6 +424,7 @@ export function createScanner(input: string, initialOffset = 0, initialState: Sc getTokenLength: () => stream.pos() - tokenOffset, getTokenEnd: () => stream.pos(), getTokenText: () => stream.getSource().substring(tokenOffset, stream.pos()), - getScannerState: () => state + getScannerState: () => state, + getTokenError: () => tokenError }; } diff --git a/extensions/html/server/src/service/services/htmlCompletion.ts b/extensions/html/server/src/service/services/htmlCompletion.ts index e9d3069ac37..1712f507219 100644 --- a/extensions/html/server/src/service/services/htmlCompletion.ts +++ b/extensions/html/server/src/service/services/htmlCompletion.ts @@ -10,7 +10,7 @@ import { TokenType, createScanner, ScannerState } from '../parser/htmlScanner'; import { IHTMLTagProvider, getHTML5TagProvider, getAngularTagProvider, getIonicTagProvider } from '../parser/htmlTags'; import { startsWith } from '../utils/strings'; -let tagProviders: IHTMLTagProvider[]; +let tagProviders: IHTMLTagProvider[] = []; tagProviders.push(getHTML5TagProvider()); tagProviders.push(getAngularTagProvider()); tagProviders.push(getIonicTagProvider()); @@ -48,10 +48,24 @@ export function doComplete(document: TextDocument, position: Position, doc: HTML return result; } - function collectCloseTagSuggestions(afterOpenBracket: number) : CompletionList { + function collectCloseTagSuggestions(afterOpenBracket: number, matchingOnly: boolean) : CompletionList { let range : Range = { start: document.positionAt(afterOpenBracket), end: document.positionAt(offset)}; let contentAfter = document.getText().substr(offset); let closeTag = isWhiteSpace(contentAfter) || startsWith(contentAfter, '<') ? '>' : ''; + if (node.parent && node.parent.tag) { + let tag = node.parent.tag; + result.items.push({ + label: '/' + tag, + kind: CompletionItemKind.Property, + filterText: '/' + tag + closeTag, + textEdit: { newText: '/' + tag + closeTag, range: range } + }); + return; + } + if (matchingOnly) { + return; + } + tagProviders.forEach((provider) => { provider.collectTags((tag, label) => { result.items.push({ @@ -68,7 +82,7 @@ export function doComplete(document: TextDocument, position: Position, doc: HTML function collectTagSuggestions(tagStart: number) : CompletionList { collectOpenTagSuggestions(tagStart); - collectCloseTagSuggestions(tagStart); + collectCloseTagSuggestions(tagStart, true); return result; } @@ -134,31 +148,33 @@ export function doComplete(document: TextDocument, position: Position, doc: HTML break; case TokenType.AttributeValue: if (scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd()) { - return collectAttributeValueSuggestions(scanner.getTokenEnd()); + return collectAttributeValueSuggestions(scanner.getTokenOffset()); } break; case TokenType.Whitespace: - case TokenType.StartTagClose: - case TokenType.StartTagSelfClose: - if (offset <= scanner.getTokenOffset()) { + case TokenType.Unknown: + if (offset <= scanner.getTokenEnd()) { switch (scanner.getScannerState()) { + case ScannerState.AfterOpeningStartTag: + return collectTagSuggestions(scanner.getTokenOffset()); case ScannerState.WithinTag: case ScannerState.AfterAttributeName: - return collectAttributeNameSuggestions(scanner.getTokenOffset()); + return collectAttributeNameSuggestions(scanner.getTokenEnd()); case ScannerState.BeforeAttributeValue: - return collectAttributeValueSuggestions(scanner.getTokenOffset()); + return collectAttributeValueSuggestions(scanner.getTokenEnd()); } } break; case TokenType.EndTagOpen: if (offset <= scanner.getTokenEnd()) { - return collectCloseTagSuggestions(scanner.getTokenOffset() + 1); + return collectCloseTagSuggestions(scanner.getTokenOffset() + 1, false); } break; + } - + token = scanner.scan(); } - return null; + return result; } function isWhiteSpace(s:string) : boolean { diff --git a/extensions/html/server/src/service/test/completion.test.ts b/extensions/html/server/src/service/test/completion.test.ts new file mode 100644 index 00000000000..141f2060a71 --- /dev/null +++ b/extensions/html/server/src/service/test/completion.test.ts @@ -0,0 +1,414 @@ +/*--------------------------------------------------------------------------------------------- + * 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 assert from 'assert'; +import * as htmlLanguageService from '../htmlLanguageService'; + +import {CompletionList, TextDocument, TextEdit, Position, CompletionItemKind} from 'vscode-languageserver-types'; +import {applyEdits} from './textEditSupport'; + +export interface ItemDescription { + label: string; + documentation?: string; + kind?: CompletionItemKind; + insertText?: string; + overwriteBefore?: number; + resultText?: string; +} + +function asPromise(result: T): Promise { + return Promise.resolve(result); +} + +export let assertCompletion = function (completions: CompletionList, expected: ItemDescription, document: TextDocument, offset: number) { + let matches = completions.items.filter(completion => { + return completion.label === expected.label; + }); + assert.equal(matches.length, 1, expected.label + " should only existing once: Actual: " + completions.items.map(c => c.label).join(', ')); + if (expected.documentation) { + assert.equal(matches[0].documentation, expected.documentation); + } + if (expected.kind) { + assert.equal(matches[0].kind, expected.kind); + } + if (expected.insertText) { + assert.equal(matches[0].insertText || matches[0].textEdit.newText, expected.insertText); + } + if (expected.resultText) { + assert.equal(applyEdits(document, [matches[0].textEdit]), expected.resultText); + } + if (expected.insertText && typeof expected.overwriteBefore === 'number' && matches[0].textEdit) { + let text = document.getText(); + let expectedText = text.substr(0, offset - expected.overwriteBefore) + expected.insertText + text.substr(offset); + assert.equal(applyEdits(document, [matches[0].textEdit]), expectedText); + } +}; + +let testCompletionFor = function (value: string, expected: { count?: number, items?: ItemDescription[] }): Thenable { + let offset = value.indexOf('|'); + value = value.substr(0, offset) + value.substr(offset + 1); + + let ls = htmlLanguageService.getLanguageService(); + + let document = TextDocument.create('test://test/test.html', 'html', 0, value); + let position = Position.create(0, offset); + let jsonDoc = ls.parseHTMLDocument(document); + return asPromise(ls.doComplete(document, position, jsonDoc)).then(list => { + try { + if (expected.count) { + assert.equal(list.items, expected.count); + } + if (expected.items) { + for (let item of expected.items) { + assertCompletion(list, item, document, offset); + } + } + } catch (e) { + return Promise.reject(e); + } + + }); +}; +function run(tests: Thenable[], testDone) { + Promise.all(tests).then(() => { + testDone(); + }, (error) => { + testDone(error); + }); +} + +suite('HTML Completion', () => { + + test('Intellisense', function (testDone): any { + run([ + testCompletionFor('<|', { + items: [ + { label: 'iframe', resultText: '', { + // items: [ + // { label: 'ltr', resultText: 'ltr' }, + // { label: 'rtl', resultText: 'rtl' }, + // ] + // }), + // testCompletionFor('