diff --git a/extensions/html/server/package.json b/extensions/html/server/package.json index 6569de18027..f885a65adc3 100644 --- a/extensions/html/server/package.json +++ b/extensions/html/server/package.json @@ -9,7 +9,8 @@ }, "dependencies": { "vscode-languageserver": "^2.4.0-next.12", - "vscode-nls": "^1.0.4" + "vscode-nls": "^1.0.4", + "vscode-uri": "^0.0.7" }, "scripts": { "compile": "gulp compile-extension:json-server", diff --git a/extensions/html/server/src/service/htmlLanguageService.ts b/extensions/html/server/src/service/htmlLanguageService.ts index fb7efc32619..5c31e9985ae 100644 --- a/extensions/html/server/src/service/htmlLanguageService.ts +++ b/extensions/html/server/src/service/htmlLanguageService.ts @@ -6,12 +6,28 @@ import {parse} from './parser/htmlParser'; import {doComplete} from './services/htmlCompletion'; import {format} from './services/htmlFormatter'; +import {provideLinks} from './services/htmlLinks'; import {findDocumentHighlights} from './services/htmlHighlighting'; import {TextDocument, Position, CompletionItem, CompletionList, Hover, Range, SymbolInformation, Diagnostic, TextEdit, DocumentHighlight, FormattingOptions, MarkedString } from 'vscode-languageserver-types'; export {TextDocument, Position, CompletionItem, CompletionList, Hover, Range, SymbolInformation, Diagnostic, TextEdit, DocumentHighlight, FormattingOptions, MarkedString }; +export class DocumentLink { + + /** + * The range this link applies to. + */ + range: Range; + + /** + * The uri this link points to. + */ + target: string; + +} + + export interface HTMLFormatConfiguration { tabSize: number; insertSpaces: boolean; @@ -38,8 +54,8 @@ export interface LanguageService { doValidation(document: TextDocument, htmlDocument: HTMLDocument): Diagnostic[]; findDocumentHighlights(document: TextDocument, position: Position, htmlDocument: HTMLDocument): DocumentHighlight[]; doComplete(document: TextDocument, position: Position, htmlDocument: HTMLDocument): CompletionList; -// doHover(document: TextDocument, position: Position, doc: HTMLDocument): Hover; format(document: TextDocument, range: Range, options: HTMLFormatConfiguration): TextEdit[]; + provideLinks(document: TextDocument, workspacePath:string): DocumentLink[]; } export function getLanguageService() : LanguageService { @@ -49,6 +65,7 @@ export function getLanguageService() : LanguageService { parseHTMLDocument: (document) => parse(document.getText()), doComplete, format, - findDocumentHighlights + findDocumentHighlights, + provideLinks }; } \ No newline at end of file diff --git a/extensions/html/server/src/service/services/htmlLinks.ts b/extensions/html/server/src/service/services/htmlLinks.ts new file mode 100644 index 00000000000..e5051bdd60f --- /dev/null +++ b/extensions/html/server/src/service/services/htmlLinks.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * 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 {TokenType, createScanner} from '../parser/htmlScanner'; +import {TextDocument, Range} from 'vscode-languageserver-types'; +import * as paths from '../utils/paths'; +import * as strings from '../utils/strings'; +import Uri from 'vscode-uri'; + +import {DocumentLink} from '../htmlLanguageService'; + +function _stripQuotes(url: string): string { + return url + .replace(/^'([^']+)'$/,(substr, match1) => match1) + .replace(/^"([^"]+)"$/,(substr, match1) => match1); +} + +export function _getWorkspaceUrl(modelAbsoluteUri: Uri, rootAbsoluteUrlStr: string, tokenContent: string): string { + tokenContent = _stripQuotes(tokenContent); + + if (/^\s*javascript\:/i.test(tokenContent) || /^\s*\#/i.test(tokenContent)) { + return null; + } + + if (/^\s*https?:\/\//i.test(tokenContent) || /^\s*file:\/\//i.test(tokenContent)) { + // Absolute link that needs no treatment + return tokenContent.replace(/^\s*/g, ''); + } + + if (/^\s*\/\//i.test(tokenContent)) { + // Absolute link (that does not name the protocol) + let pickedScheme = 'http'; + if (modelAbsoluteUri.scheme === 'https') { + pickedScheme = 'https'; + } + return pickedScheme + ':' + tokenContent.replace(/^\s*/g, ''); + } + + let modelPath = paths.dirname(modelAbsoluteUri.path); + let alternativeResultPath: string = null; + if (tokenContent.length > 0 && tokenContent.charAt(0) === '/') { + alternativeResultPath = tokenContent; + } else { + alternativeResultPath = paths.join(modelPath, tokenContent); + alternativeResultPath = alternativeResultPath.replace(/^(\/\.\.)+/, ''); + } + let potentialResult = modelAbsoluteUri.with({ path: alternativeResultPath }).toString(); + + if (rootAbsoluteUrlStr && strings.startsWith(modelAbsoluteUri.toString(), rootAbsoluteUrlStr)) { + // The `rootAbsoluteUrl` is set and matches our current model + // We need to ensure that this `potentialResult` does not escape `rootAbsoluteUrl` + + let commonPrefixLength = strings.commonPrefixLength(rootAbsoluteUrlStr, potentialResult); + if (strings.endsWith(rootAbsoluteUrlStr, '/')) { + commonPrefixLength = potentialResult.lastIndexOf('/', commonPrefixLength) + 1; + } + return rootAbsoluteUrlStr + potentialResult.substr(commonPrefixLength); + } + + return potentialResult; +} + +function createLink(document: TextDocument, rootAbsoluteUrl: string, tokenContent: string, startOffset: number, endOffset: number): DocumentLink { + let documentUri = Uri.parse(document.uri); + let workspaceUrl = _getWorkspaceUrl(documentUri, rootAbsoluteUrl, tokenContent); + if (!workspaceUrl) { + return null; + } + return { + range: Range.create(document.positionAt(startOffset), document.positionAt(endOffset)), + target: workspaceUrl + }; +} + +export function provideLinks(document: TextDocument, workspacePath:string): DocumentLink[] { + let newLinks: DocumentLink[] = []; + + let rootAbsoluteUrl: string = null; + if (workspacePath) { + // The workspace can be null in the no folder opened case + let strRootAbsoluteUrl = workspacePath; + if (strRootAbsoluteUrl.charAt(strRootAbsoluteUrl.length - 1) === '/') { + rootAbsoluteUrl = strRootAbsoluteUrl; + } else { + rootAbsoluteUrl = strRootAbsoluteUrl + '/'; + } + } + + let scanner = createScanner(document.getText(), 0); + let token = scanner.scan(); + let afterHrefOrSrc = false; + while (token !== TokenType.EOS) { + switch (token) { + case TokenType.AttributeName: + let tokenContent = scanner.getTokenText(); + afterHrefOrSrc = tokenContent === 'src' || tokenContent === 'href'; + break; + case TokenType.AttributeValue: + if (afterHrefOrSrc) { + let tokenContent = scanner.getTokenText(); + let link = createLink(document, rootAbsoluteUrl, tokenContent, scanner.getTokenOffset(), scanner.getTokenEnd()); + if (link) { + newLinks.push(link); + } + afterHrefOrSrc = false; + } + break; + } + } + return newLinks; +} \ No newline at end of file diff --git a/extensions/html/server/src/service/test/links.test.ts b/extensions/html/server/src/service/test/links.test.ts new file mode 100644 index 00000000000..a41b020006d --- /dev/null +++ b/extensions/html/server/src/service/test/links.test.ts @@ -0,0 +1,71 @@ + +/*--------------------------------------------------------------------------------------------- + * 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 htmlLinks from '../services/htmlLinks'; +import {CompletionList, TextDocument, TextEdit, Position, CompletionItemKind} from 'vscode-languageserver-types'; +import Uri from 'vscode-uri'; + +suite('HTML Link Detection', () => { + + function testLinkCreation(modelUrl:string, rootUrl:string, tokenContent:string, expected:string): void { + var _modelUrl = Uri.parse(modelUrl); + var actual = htmlLinks._getWorkspaceUrl(_modelUrl, rootUrl, tokenContent); + assert.equal(actual, expected); + } + + test('Link creation', () => { + testLinkCreation('inmemory://model/1', null, 'javascript:void;', null); + testLinkCreation('inmemory://model/1', null, ' \tjavascript:alert(7);', null); + testLinkCreation('inmemory://model/1', null, ' #relative', null); + testLinkCreation('inmemory://model/1', null, 'file:///C:\\Alex\\src\\path\\to\\file.txt', 'file:///C:\\Alex\\src\\path\\to\\file.txt'); + testLinkCreation('inmemory://model/1', null, 'http://www.microsoft.com/', 'http://www.microsoft.com/'); + testLinkCreation('inmemory://model/1', null, 'https://www.microsoft.com/', 'https://www.microsoft.com/'); + testLinkCreation('inmemory://model/1', null, '//www.microsoft.com/', 'http://www.microsoft.com/'); + testLinkCreation('inmemory://model/1', null, '../../a.js', 'inmemory://model/a.js'); + + testLinkCreation('inmemory://model/1', 'inmemory://model/', 'javascript:void;', null); + testLinkCreation('inmemory://model/1', 'inmemory://model/', ' \tjavascript:alert(7);', null); + testLinkCreation('inmemory://model/1', 'inmemory://model/', ' #relative', null); + testLinkCreation('inmemory://model/1', 'inmemory://model/', 'file:///C:\\Alex\\src\\path\\to\\file.txt', 'file:///C:\\Alex\\src\\path\\to\\file.txt'); + testLinkCreation('inmemory://model/1', 'inmemory://model/', 'http://www.microsoft.com/', 'http://www.microsoft.com/'); + testLinkCreation('inmemory://model/1', 'inmemory://model/', 'https://www.microsoft.com/', 'https://www.microsoft.com/'); + testLinkCreation('inmemory://model/1', 'inmemory://model/', ' //www.microsoft.com/', 'http://www.microsoft.com/'); + testLinkCreation('inmemory://model/1', 'inmemory://model/', '../../a.js', 'inmemory://model/a.js'); + + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, 'javascript:void;', null); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, ' \tjavascript:alert(7);', null); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, ' #relative', null); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, 'file:///C:\\Alex\\src\\path\\to\\file.txt', 'file:///C:\\Alex\\src\\path\\to\\file.txt'); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, 'http://www.microsoft.com/', 'http://www.microsoft.com/'); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, 'https://www.microsoft.com/', 'https://www.microsoft.com/'); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, ' //www.microsoft.com/', 'http://www.microsoft.com/'); + //testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, 'a.js', 'file:///C:/Alex/src/path/to/a.js'); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, '/a.js', 'file:///a.js'); + + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', 'file:///C:/Alex/src/', 'javascript:void;', null); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', 'file:///C:/Alex/src/', ' \tjavascript:alert(7);', null); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', 'file:///C:/Alex/src/', ' #relative', null); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', null, 'file:///C:\\Alex\\src\\path\\to\\file.txt', 'file:///C:\\Alex\\src\\path\\to\\file.txt'); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', 'file:///C:/Alex/src/', 'http://www.microsoft.com/', 'http://www.microsoft.com/'); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', 'file:///C:/Alex/src/', 'https://www.microsoft.com/', 'https://www.microsoft.com/'); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', 'file:///C:/Alex/src/', 'https://www.microsoft.com/?q=1#h', 'https://www.microsoft.com/?q=1#h'); + testLinkCreation('file:///C:/Alex/src/path/to/file.txt', 'file:///C:/Alex/src/', ' //www.microsoft.com/', 'http://www.microsoft.com/'); + //testLinkCreation('file:///C:/Alex/src/path/to/file.txt', 'file:///C:/Alex/src/', 'a.js', 'file:///C:/Alex/src/path/to/a.js'); + //testLinkCreation('file:///C:/Alex/src/path/to/file.txt', 'file:///C:/Alex/src/', '/a.js', 'file:///C:/Alex/src/a.js'); + + testLinkCreation('https://www.test.com/path/to/file.txt', null, 'file:///C:\\Alex\\src\\path\\to\\file.txt', 'file:///C:\\Alex\\src\\path\\to\\file.txt'); + testLinkCreation('https://www.test.com/path/to/file.txt', null, '//www.microsoft.com/', 'https://www.microsoft.com/'); + testLinkCreation('https://www.test.com/path/to/file.txt', 'https://www.test.com', '//www.microsoft.com/', 'https://www.microsoft.com/'); + + // invalid uris don't throw + testLinkCreation('https://www.test.com/path/to/file.txt', 'https://www.test.com', '%', 'https://www.test.com/path/to/%25'); + + // Bug #18314: Ctrl + Click does not open existing file if folder's name starts with 'c' character + // testLinkCreation('file:///c:/Alex/working_dir/18314-link-detection/test.html', 'file:///c:/Alex/working_dir/18314-link-detection/', '/class/class.js', 'file:///c:/Alex/working_dir/18314-link-detection/class/class.js'); + }); +}); \ No newline at end of file diff --git a/extensions/html/server/src/service/utils/paths.ts b/extensions/html/server/src/service/utils/paths.ts new file mode 100644 index 00000000000..bc1247dcae9 --- /dev/null +++ b/extensions/html/server/src/service/utils/paths.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +export const enum CharCode { + Slash = 47, + Backslash = 92 +} + +/** + * @returns the directory name of a path. + */ +export function dirname(path: string): string { + var idx = ~path.lastIndexOf('/') || ~path.lastIndexOf('\\'); + if (idx === 0) { + return '.'; + } else if (~idx === 0) { + return path[0]; + } else { + return path.substring(0, ~idx); + } +} + +/** + * @returns the base name of a path. + */ +export function basename(path: string): string { + var idx = ~path.lastIndexOf('/') || ~path.lastIndexOf('\\'); + if (idx === 0) { + return path; + } else if (~idx === path.length - 1) { + return basename(path.substring(0, path.length - 1)); + } else { + return path.substr(~idx + 1); + } +} + +/** + * @returns {{.far}} from boo.far or the empty string. + */ +export function extname(path: string): string { + path = basename(path); + var idx = ~path.lastIndexOf('.'); + return idx ? path.substring(~idx) : ''; +} + +export const join: (...parts: string[]) => string = function () { + // Not using a function with var-args because of how TS compiles + // them to JS - it would result in 2*n runtime cost instead + // of 1*n, where n is parts.length. + + let value = ''; + for (let i = 0; i < arguments.length; i++) { + let part = arguments[i]; + if (i > 0) { + // add the separater between two parts unless + // there already is one + let last = value.charCodeAt(value.length - 1); + if (last !== CharCode.Slash && last !== CharCode.Backslash) { + let next = part.charCodeAt(0); + if (next !== CharCode.Slash && next !== CharCode.Backslash) { + + value += '/'; + } + } + } + value += part; + } + + return value; +}; + + + diff --git a/extensions/html/server/src/service/utils/strings.ts b/extensions/html/server/src/service/utils/strings.ts index a7e9036213f..9fdf7d9320c 100644 --- a/extensions/html/server/src/service/utils/strings.ts +++ b/extensions/html/server/src/service/utils/strings.ts @@ -32,6 +32,19 @@ export function endsWith(haystack: string, needle: string): boolean { } } -export function convertSimple2RegExpPattern(pattern: string): string { - return pattern.replace(/[\-\\\{\}\+\?\|\^\$\.\,\[\]\(\)\#\s]/g, '\\$&').replace(/[\*]/g, '.*'); -} +/** + * @returns the length of the common prefix of the two strings. + */ +export function commonPrefixLength(a: string, b: string): number { + + let i: number, + len = Math.min(a.length, b.length); + + for (i = 0; i < len; i++) { + if (a.charCodeAt(i) !== b.charCodeAt(i)) { + return i; + } + } + + return len; +} \ No newline at end of file