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