From cbdddca5ed143ee5c1399fdff304d96ff1fc8c27 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 18 Nov 2016 16:56:06 +0100 Subject: [PATCH] [html] Format embedded JavaScript --- extensions/html/.vscode/launch.json | 8 ++ extensions/html/server/npm-shrinkwrap.json | 4 +- extensions/html/server/package.json | 2 +- extensions/html/server/src/htmlServerMain.ts | 32 ++++-- .../html/server/src/modes/embeddedSupport.ts | 50 +++++++- .../html/server/src/modes/javascriptMode.ts | 64 ++++++++--- .../html/server/src/modes/languageModes.ts | 16 ++- .../html/server/src/test/formatting.test.ts | 108 ++++++++++++++++++ 8 files changed, 251 insertions(+), 33 deletions(-) create mode 100644 extensions/html/server/src/test/formatting.test.ts diff --git a/extensions/html/.vscode/launch.json b/extensions/html/.vscode/launch.json index 7a654d67e8d..3b0e212f68e 100644 --- a/extensions/html/.vscode/launch.json +++ b/extensions/html/.vscode/launch.json @@ -24,6 +24,14 @@ "sourceMaps": true, "outDir": "${workspaceRoot}/client/out/test", "preLaunchTask": "npm" + }, + { + "name": "Attach Language Server", + "type": "node", + "request": "attach", + "port": 6004, + "sourceMaps": true, + "outDir": "${workspaceRoot}/server/out" } ] } \ No newline at end of file diff --git a/extensions/html/server/npm-shrinkwrap.json b/extensions/html/server/npm-shrinkwrap.json index 811b781c29d..a1f7d09a5e9 100644 --- a/extensions/html/server/npm-shrinkwrap.json +++ b/extensions/html/server/npm-shrinkwrap.json @@ -8,9 +8,9 @@ "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-2.0.0-next.3.tgz" }, "vscode-html-languageservice": { - "version": "1.0.1-next.2", + "version": "1.0.1-next.3", "from": "vscode-html-languageservice@next", - "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-1.0.1-next.2.tgz" + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-1.0.1-next.3.tgz" }, "vscode-jsonrpc": { "version": "2.4.0", diff --git a/extensions/html/server/package.json b/extensions/html/server/package.json index ccda47decda..8dbee89cebb 100644 --- a/extensions/html/server/package.json +++ b/extensions/html/server/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "vscode-css-languageservice": "^2.0.0-next.3", - "vscode-html-languageservice": "^1.0.1-next.2", + "vscode-html-languageservice": "^1.0.1-next.3", "vscode-languageserver": "^2.6.2-next.1", "vscode-nls": "^1.0.4", "vscode-uri": "^1.0.0" diff --git a/extensions/html/server/src/htmlServerMain.ts b/extensions/html/server/src/htmlServerMain.ts index c4adb4a7633..796b7b8ae78 100644 --- a/extensions/html/server/src/htmlServerMain.ts +++ b/extensions/html/server/src/htmlServerMain.ts @@ -5,7 +5,7 @@ 'use strict'; import { createConnection, IConnection, TextDocuments, InitializeParams, InitializeResult, RequestType } from 'vscode-languageserver'; -import { DocumentContext, TextDocument, Diagnostic, DocumentLink, Range } from 'vscode-html-languageservice'; +import { DocumentContext, TextDocument, Diagnostic, DocumentLink, Range, TextEdit } from 'vscode-html-languageservice'; import { getLanguageModes, LanguageModes } from './modes/languageModes'; import * as url from 'url'; @@ -112,12 +112,20 @@ function validateTextDocument(textDocument: TextDocument): void { let diagnostics: Diagnostic[] = []; languageModes.getAllModesInDocument(textDocument).forEach(mode => { if (mode.doValidation) { - diagnostics = diagnostics.concat(mode.doValidation(textDocument)); + pushAll(diagnostics, mode.doValidation(textDocument)); } }); connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); } +function pushAll(to: T[], from: T[]) { + if (from) { + for (var i = 0; i < from.length; i++) { + to.push(from[i]); + } + } +} + connection.onCompletion(textDocumentPosition => { let document = documents.get(textDocumentPosition.textDocument.uri); let mode = languageModes.getModeAtPosition(document, textDocumentPosition.position); @@ -186,12 +194,16 @@ connection.onSignatureHelp(signatureHelpParms => { connection.onDocumentRangeFormatting(formatParams => { let document = documents.get(formatParams.textDocument.uri); - let startMode = languageModes.getModeAtPosition(document, formatParams.range.start); - let endMode = languageModes.getModeAtPosition(document, formatParams.range.end); - if (startMode && startMode === endMode && startMode.format) { - return startMode.format(document, formatParams.range, formatParams.options); - } - return null; + let ranges = languageModes.getModesInRange(document, formatParams.range); + let result: TextEdit[] = []; + ranges.forEach(r => { + let mode = r.mode; + if (mode && mode.format) { + let edits = mode.format(document, r, formatParams.options); + pushAll(result, edits); + } + }); + return result; }); connection.onDocumentLinks(documentLinkParam => { @@ -207,7 +219,7 @@ connection.onDocumentLinks(documentLinkParam => { let links: DocumentLink[] = []; languageModes.getAllModesInDocument(document).forEach(m => { if (m.findDocumentLinks) { - links = links.concat(m.findDocumentLinks(document, documentContext)); + pushAll(links, m.findDocumentLinks(document, documentContext)); } }); return links; @@ -219,7 +231,7 @@ connection.onRequest(ColorSymbolRequest.type, uri => { if (document) { languageModes.getAllModesInDocument(document).forEach(m => { if (m.findColorSymbols) { - ranges = ranges.concat(m.findColorSymbols(document)); + pushAll(ranges, m.findColorSymbols(document)); } }); } diff --git a/extensions/html/server/src/modes/embeddedSupport.ts b/extensions/html/server/src/modes/embeddedSupport.ts index e982527a89e..9608696b271 100644 --- a/extensions/html/server/src/modes/embeddedSupport.ts +++ b/extensions/html/server/src/modes/embeddedSupport.ts @@ -5,7 +5,11 @@ 'use strict'; -import { TextDocument, Position, HTMLDocument, Node, LanguageService, TokenType } from 'vscode-html-languageservice'; +import { TextDocument, Position, HTMLDocument, Node, LanguageService, TokenType, Range } from 'vscode-html-languageservice'; + +export interface LanguageRange extends Range { + languageId: string; +} export function getLanguageAtPosition(languageService: LanguageService, document: TextDocument, htmlDocument: HTMLDocument, position: Position): string { let offset = document.offsetAt(position); @@ -33,6 +37,50 @@ export function getLanguagesInContent(languageService: LanguageService, document return Object.keys(embeddedLanguageIds); } +export function getLanguagesInRange(languageService: LanguageService, document: TextDocument, htmlDocument: HTMLDocument, range: Range): LanguageRange[] { + let ranges: LanguageRange[] = []; + let currentPos = range.start; + let currentOffset = document.offsetAt(currentPos); + let rangeEndOffset = document.offsetAt(range.end); + function collectEmbeddedNodes(node: Node): void { + if (node.start < rangeEndOffset && node.end > currentOffset) { + let c = getEmbeddedContentForNode(languageService, document, node); + if (c && c.start < rangeEndOffset) { + let startPos = document.positionAt(c.start); + if (currentOffset < c.start) { + ranges.push({ + start: currentPos, + end: startPos, + languageId: 'html' + }); + } + let end = Math.min(c.end, rangeEndOffset); + let endPos = document.positionAt(end); + if (end > c.start) { + ranges.push({ + start: startPos, + end: endPos, + languageId: c.languageId + }); + } + currentOffset = end; + currentPos = endPos; + } + } + node.children.forEach(collectEmbeddedNodes); + } + + htmlDocument.roots.forEach(collectEmbeddedNodes); + if (currentOffset < rangeEndOffset) { + ranges.push({ + start: currentPos, + end: range.end, + languageId: 'html' + }); + } + return ranges; +} + export function getEmbeddedDocument(languageService: LanguageService, document: TextDocument, htmlDocument: HTMLDocument, languageId: string): TextDocument { let contents = []; function collectEmbeddedNodes(node: Node): void { diff --git a/extensions/html/server/src/modes/javascriptMode.ts b/extensions/html/server/src/modes/javascriptMode.ts index 19edf0a0d5a..957db80b184 100644 --- a/extensions/html/server/src/modes/javascriptMode.ts +++ b/extensions/html/server/src/modes/javascriptMode.ts @@ -54,7 +54,7 @@ export function getJavascriptMode(htmlLanguageService: HTMLLanguageService, html return { configure(options: any) { - settings = options && options.html; + settings = options && options.javascript; }, doValidation(document: TextDocument): Diagnostic[] { currentTextDocument = jsDocuments.get(document); @@ -194,15 +194,22 @@ export function getJavascriptMode(htmlLanguageService: HTMLLanguageService, html }, format(document: TextDocument, range: Range, formatParams: FormattingOptions): TextEdit[] { currentTextDocument = jsDocuments.get(document); - let formatSettings = convertOptions(formatParams, settings && settings.format); + let initialIndentLevel = computeInitialIndent(document, range, formatParams) + 1; + let formatSettings = convertOptions(formatParams, settings && settings.format, initialIndentLevel); let start = currentTextDocument.offsetAt(range.start); let end = currentTextDocument.offsetAt(range.end); let edits = jsLanguageService.getFormattingEditsForRange(FILE_NAME, start, end, formatSettings); if (edits) { - return edits.map(e => ({ - range: convertRange(currentTextDocument, e.span), - newText: e.newText - })); + let result = []; + for (let edit of edits) { + if (edit.span.start >= start && edit.span.start + edit.span.length <= end) { + result.push({ + range: convertRange(currentTextDocument, edit.span), + newText: edit.newText + }); + } + } + return result; } return null; }, @@ -255,23 +262,44 @@ function convertKind(kind: string): CompletionItemKind { return CompletionItemKind.Property; } -function convertOptions(options: FormattingOptions, formatSettings?: any): ts.FormatCodeOptions { +function convertOptions(options: FormattingOptions, formatSettings: any, initialIndentLevel: number): ts.FormatCodeOptions { return { ConvertTabsToSpaces: options.insertSpaces, TabSize: options.tabSize, IndentSize: options.tabSize, IndentStyle: ts.IndentStyle.Smart, NewLineCharacter: '\n', - BaseIndentSize: 1, // - InsertSpaceAfterCommaDelimiter: !formatSettings || formatSettings.insertSpaceAfterCommaDelimiter, - InsertSpaceAfterSemicolonInForStatements: !formatSettings || formatSettings.insertSpaceAfterSemicolonInForStatements, - InsertSpaceBeforeAndAfterBinaryOperators: !formatSettings || formatSettings.insertSpaceBeforeAndAfterBinaryOperators, - InsertSpaceAfterKeywordsInControlFlowStatements: !formatSettings || formatSettings.insertSpaceAfterKeywordsInControlFlowStatements, - InsertSpaceAfterFunctionKeywordForAnonymousFunctions: !formatSettings || formatSettings.insertSpaceAfterFunctionKeywordForAnonymousFunctions, - InsertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: formatSettings && formatSettings.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis, - InsertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: formatSettings && formatSettings.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets, - InsertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: formatSettings && formatSettings.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces, - PlaceOpenBraceOnNewLineForControlBlocks: formatSettings && formatSettings.placeOpenBraceOnNewLineForFunctions, - PlaceOpenBraceOnNewLineForFunctions: formatSettings && formatSettings.placeOpenBraceOnNewLineForControlBlocks + BaseIndentSize: options.tabSize * initialIndentLevel, + InsertSpaceAfterCommaDelimiter: Boolean(!formatSettings || formatSettings.insertSpaceAfterCommaDelimiter), + InsertSpaceAfterSemicolonInForStatements: Boolean(!formatSettings || formatSettings.insertSpaceAfterSemicolonInForStatements), + InsertSpaceBeforeAndAfterBinaryOperators: Boolean(!formatSettings || formatSettings.insertSpaceBeforeAndAfterBinaryOperators), + InsertSpaceAfterKeywordsInControlFlowStatements: Boolean(!formatSettings || formatSettings.insertSpaceAfterKeywordsInControlFlowStatements), + InsertSpaceAfterFunctionKeywordForAnonymousFunctions: Boolean(!formatSettings || formatSettings.insertSpaceAfterFunctionKeywordForAnonymousFunctions), + InsertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: Boolean(formatSettings && formatSettings.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis), + InsertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: Boolean(formatSettings && formatSettings.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets), + InsertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: Boolean(formatSettings && formatSettings.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces), + PlaceOpenBraceOnNewLineForControlBlocks: Boolean(formatSettings && formatSettings.placeOpenBraceOnNewLineForFunctions), + PlaceOpenBraceOnNewLineForFunctions: Boolean(formatSettings && formatSettings.placeOpenBraceOnNewLineForControlBlocks) }; +} + +function computeInitialIndent(document: TextDocument, range: Range, options: FormattingOptions) { + let lineStart = document.offsetAt(Position.create(range.start.line, 0)); + let content = document.getText(); + + let i = lineStart; + let nChars = 0; + let tabSize = options.tabSize || 4; + while (i < content.length) { + let ch = content.charAt(i); + if (ch === ' ') { + nChars++; + } else if (ch === '\t') { + nChars += tabSize; + } else { + break; + } + i++; + } + return Math.floor(nChars / tabSize); } \ No newline at end of file diff --git a/extensions/html/server/src/modes/languageModes.ts b/extensions/html/server/src/modes/languageModes.ts index 1e52531b1e1..4e788b7bf51 100644 --- a/extensions/html/server/src/modes/languageModes.ts +++ b/extensions/html/server/src/modes/languageModes.ts @@ -11,7 +11,7 @@ import { } from 'vscode-languageserver-types'; import { getLanguageModelCache } from '../languageModelCache'; -import { getLanguageAtPosition, getLanguagesInContent } from './embeddedSupport'; +import { getLanguageAtPosition, getLanguagesInContent, getLanguagesInRange } from './embeddedSupport'; import { getCSSMode } from './cssMode'; import { getJavascriptMode } from './javascriptMode'; import { getHTMLMode } from './htmlMode'; @@ -35,11 +35,16 @@ export interface LanguageMode { export interface LanguageModes { getModeAtPosition(document: TextDocument, position: Position): LanguageMode; + getModesInRange(document: TextDocument, range: Range): LanguageModeRange[]; getAllModesInDocument(document: TextDocument): LanguageMode[]; getAllModes(): LanguageMode[]; getMode(languageId: string): LanguageMode; } +export interface LanguageModeRange extends Range { + mode: LanguageMode; +} + export function getLanguageModes(supportedLanguages: { [languageId: string]: boolean; }): LanguageModes { var htmlLanguageService = getHTMLLanguageService(); @@ -69,6 +74,15 @@ export function getLanguageModes(supportedLanguages: { [languageId: string]: boo } return result; }, + getModesInRange(document: TextDocument, range: Range): LanguageModeRange[] { + return getLanguagesInRange(htmlLanguageService, document, htmlDocuments.get(document), range).map(r => { + return { + start: r.start, + end: r.end, + mode: modes[r.languageId] + }; + }); + }, getAllModes(): LanguageMode[] { let result = []; for (let languageId in modes) { diff --git a/extensions/html/server/src/test/formatting.test.ts b/extensions/html/server/src/test/formatting.test.ts new file mode 100644 index 00000000000..1aaf6075848 --- /dev/null +++ b/extensions/html/server/src/test/formatting.test.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getLanguageModes } from '../modes/languageModes'; +import { TextDocument, Range, TextEdit, FormattingOptions } from 'vscode-languageserver-types'; + +suite('HTML Embedded Formatting', () => { + + function assertFormat(value: string, expected: string, options?: any): void { + var languageModes = getLanguageModes({ css: true, javascript: true }); + if (options) { + languageModes.getAllModes().forEach(m => m.configure(options)); + } + + let rangeStartOffset = value.indexOf('|'); + let rangeEndOffset; + if (rangeStartOffset !== -1) { + value = value.substr(0, rangeStartOffset) + value.substr(rangeStartOffset + 1); + + rangeEndOffset = value.indexOf('|'); + value = value.substr(0, rangeEndOffset) + value.substr(rangeEndOffset + 1); + } else { + rangeStartOffset = 0; + rangeEndOffset = value.length; + } + let document = TextDocument.create('test://test/test.html', 'html', 0, value); + let range = Range.create(document.positionAt(rangeStartOffset), document.positionAt(rangeEndOffset)); + let formatOptions = FormattingOptions.create(2, true); + + let ranges = languageModes.getModesInRange(document, range); + let result: TextEdit[] = []; + ranges.forEach(r => { + let mode = r.mode; + if (mode && mode.format) { + let edits = mode.format(document, r, formatOptions); + pushAll(result, edits); + } + }); + let actual = applyEdits(document, result); + assert.equal(actual, expected); + } + + test('HTML only', function (): any { + assertFormat('

Hello

', '\n\n\n

Hello

\n\n\n'); + assertFormat('|

Hello

|', '\n\n\n

Hello

\n\n\n'); + assertFormat('|

Hello

|', '\n

Hello

\n'); + }); + + test('HTML & Scripts', function (): any { + assertFormat('', '\n\n\n \n\n\n'); + assertFormat('', '\n\n\n \n\n\n'); + assertFormat('', '\n\n\n \n\n\n'); + assertFormat('\n ', '\n\n\n \n\n\n'); + assertFormat('\n ', '\n\n\n \n\n\n'); + + assertFormat('\n ||', '\n '); + assertFormat('\n ', '\n '); + }); + + test('HTML & Multiple Scripts', function (): any { + assertFormat('\n', '\n\n\n \n\n\n\n'); + }); + + test('HTML & Styles', function (): any { + assertFormat('\n', '\n\n\n \n\n\n'); + }); + + test('EndWithNewline', function (): any { + let options = { + html: { + format: { + endWithNewline : true + } + } + }; + assertFormat('

Hello

', '\n\n\n

Hello

\n\n\n\n', options); + assertFormat('|

Hello

|', '\n

Hello

\n', options); + assertFormat('', '\n\n\n \n\n\n\n', options); + }); + +}); + +function pushAll(to: T[], from: T[]) { + if (from) { + for (var i = 0; i < from.length; i++) { + to.push(from[i]); + } + } +} + +function applyEdits(document: TextDocument, edits: TextEdit[]): string { + let text = document.getText(); + let sortedEdits = edits.sort((a, b) => document.offsetAt(b.range.start) - document.offsetAt(a.range.start)); + let lastOffset = text.length; + sortedEdits.forEach(e => { + let startOffset = document.offsetAt(e.range.start); + let endOffset = document.offsetAt(e.range.end); + assert.ok(startOffset <= endOffset); + assert.ok(endOffset <= lastOffset); + text = text.substring(0, startOffset) + e.newText + text.substring(endOffset, text.length); + lastOffset = startOffset; + }); + return text; +} \ No newline at end of file