diff --git a/src/vs/base/common/resources.ts b/src/vs/base/common/resources.ts index 152ddf6912d5d93998af7bca9d10cf736a38b1c8..a243b433d60907d9cc41112f6fee42e42fd6cafe 100644 --- a/src/vs/base/common/resources.ts +++ b/src/vs/base/common/resources.ts @@ -13,6 +13,84 @@ import { CharCode } from 'vs/base/common/charCode'; import { ParsedExpression, IExpression, parse } from 'vs/base/common/glob'; import { TernarySearchTree } from 'vs/base/common/map'; +//#region IExtUri + +export interface IExtUri { + + /** + * Compares two uris. + * + * @param uri1 Uri + * @param uri2 Uri + * @param ignoreFragment Ignore the fragment (defaults to `false`) + */ + compare(uri1: URI, uri2: URI, ignoreFragment?: boolean): number; + + /** + * Tests whether two uris are equal + * + * @param uri1 Uri + * @param uri2 Uri + * @param ignoreFragment Ignore the fragment (defaults to `false`) + */ + isEqual(uri1: URI, uri2: URI, ignoreFragment?: boolean): boolean; + + /** + * Creates a key from a resource URI to be used to resource comparison and for resource maps. + * @see ResourceMap + * @param uri Uri + * @param ignoreFragment Ignore the fragment (defaults to `false`) + */ + getComparisonKey(uri: URI, ignoreFragment?: boolean): string; +} + +export class ExtUri implements IExtUri { + + constructor(private _ignorePathCasing: (uri: URI) => boolean) { } + + compare(uri1: URI, uri2: URI, ignoreFragment: boolean = false): number { + // scheme + let ret = strCompare(uri1.scheme, uri2.scheme); + if (ret === 0) { + // authority + ret = compareIgnoreCase(uri1.authority, uri2.authority); + if (ret === 0) { + // path + ret = this._ignorePathCasing(uri1) ? compareIgnoreCase(uri1.path, uri2.path) : strCompare(uri1.path, uri2.path); + // query + if (ret === 0) { + ret = strCompare(uri1.query, uri2.query); + // fragment + if (ret === 0 && !ignoreFragment) { + ret = strCompare(uri1.fragment, uri2.fragment); + } + } + } + } + return ret; + } + + getComparisonKey(uri: URI, ignoreFragment: boolean = false): string { + return getComparisonKey(uri, this._ignorePathCasing(uri), ignoreFragment); + } + + isEqual(uri1: URI, uri2: URI, ignoreFragment: boolean = false): boolean { + return isEqual(uri1, uri2, this._ignorePathCasing(uri1), ignoreFragment); + } +} + +/** + * Unbiased utility that takes uris "as they are". This means it can be interchanged with + * uri#toString() usages. The following is true + * ``` + * assertEqual(aUri.toString() === bUri.toString(), exturi.isEqual(aUri, bUri)) + * ``` + */ +export const exturi = new ExtUri(() => false); + + +//#endregion + export function originalFSPath(uri: URI): string { return uriToFsPath(uri, true); } @@ -60,27 +138,6 @@ export function isEqual(first: URI | undefined, second: URI | undefined, ignoreP return (p1 === p2 || ignorePathCasing && equalsIgnoreCase(p1, p2)) && first.query === second.query && (ignoreFragment || first.fragment === second.fragment); } -export function compare(uri1: URI, uri2: URI, ignorePathCasing: boolean = _ignorePathCasingGuess(uri1), ignoreFragment: boolean = false): number { - // scheme - let ret = strCompare(uri1.scheme, uri2.scheme); - if (ret === 0) { - // authority - ret = compareIgnoreCase(uri1.authority, uri2.authority); - if (ret === 0) { - // path - ret = ignorePathCasing ? compareIgnoreCase(uri1.path, uri2.path) : strCompare(uri1.path, uri2.path); - // query - if (ret === 0) { - ret = strCompare(uri1.query, uri2.query); - // fragment - if (ret === 0 && !ignoreFragment) { - ret = strCompare(uri1.fragment, uri2.fragment); - } - } - } - } - return ret; -} /** * Tests whether a `candidate` URI is a parent or equal of a given `base` URI. diff --git a/src/vs/base/test/common/resources.test.ts b/src/vs/base/test/common/resources.test.ts index d02a6cafcea25eafbd4ed2518610466274bdd081..eff29b06bcaf540508c4fb75185313b8bcce6967 100644 --- a/src/vs/base/test/common/resources.test.ts +++ b/src/vs/base/test/common/resources.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { dirname, basename, distinctParents, joinPath, isEqual, isEqualOrParent, normalizePath, isAbsolutePath, relativePath, removeTrailingPathSeparator, hasTrailingPathSeparator, resolvePath, addTrailingPathSeparator, getComparisonKey, compare } from 'vs/base/common/resources'; +import { dirname, basename, distinctParents, joinPath, isEqual, isEqualOrParent, normalizePath, isAbsolutePath, relativePath, removeTrailingPathSeparator, hasTrailingPathSeparator, resolvePath, addTrailingPathSeparator, getComparisonKey, exturi } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { isWindows } from 'vs/base/common/platform'; import { toSlashes } from 'vs/base/common/extpath'; @@ -348,8 +348,8 @@ suite('Resources', () => { function assertIsEqual(u1: URI, u2: URI, ignoreCase: boolean | undefined, expected: boolean) { assert.equal(isEqual(u1, u2, ignoreCase), expected, `${u1.toString()}${expected ? '===' : '!=='}${u2.toString()}`); - assert.equal(compare(u1, u2, ignoreCase) === 0, expected); if (!ignoreCase) { + assert.equal(exturi.compare(u1, u2) === 0, expected); assert.equal(u1.toString() === u2.toString(), expected); } assert.equal(getComparisonKey(u1, ignoreCase) === getComparisonKey(u2, ignoreCase), expected, `comparison keys ${u1.toString()}, ${u2.toString()}`); diff --git a/src/vs/editor/contrib/gotoSymbol/referencesModel.ts b/src/vs/editor/contrib/gotoSymbol/referencesModel.ts index c325946c380d81aa47265c6c30ebdfd508ba914f..d79ffa61c0c81b24c7a0a12932bb1702fb7d2c9e 100644 --- a/src/vs/editor/contrib/gotoSymbol/referencesModel.ts +++ b/src/vs/editor/contrib/gotoSymbol/referencesModel.ts @@ -5,7 +5,7 @@ import { localize } from 'vs/nls'; import { Event, Emitter } from 'vs/base/common/event'; -import { basename, compare, isEqual } from 'vs/base/common/resources'; +import { basename, exturi } from 'vs/base/common/resources'; import { IDisposable, dispose, IReference, DisposableStore } from 'vs/base/common/lifecycle'; import * as strings from 'vs/base/common/strings'; import { URI } from 'vs/base/common/uri'; @@ -151,7 +151,7 @@ export class ReferencesModel implements IDisposable { let current: FileReferences | undefined; for (let link of links) { - if (!current || !isEqual(current.uri, link.uri, false, true)) { + if (!current || !exturi.isEqual(current.uri, link.uri, true)) { // new group current = new FileReferences(this, link.uri); this.groups.push(current); @@ -281,6 +281,6 @@ export class ReferencesModel implements IDisposable { } private static _compareReferences(a: Location, b: Location): number { - return compare(a.uri, b.uri, false, false) || Range.compareRangesUsingStarts(a.range, b.range); + return exturi.compare(a.uri, b.uri) || Range.compareRangesUsingStarts(a.range, b.range); } } diff --git a/src/vs/workbench/contrib/markers/browser/markersModel.ts b/src/vs/workbench/contrib/markers/browser/markersModel.ts index 68b646d9c161199bf9ab039e22d44554913676ca..ce39a3954164b34815699928e59c3cd31f1de746 100644 --- a/src/vs/workbench/contrib/markers/browser/markersModel.ts +++ b/src/vs/workbench/contrib/markers/browser/markersModel.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { basename, compare, getComparisonKey } from 'vs/base/common/resources'; +import { basename, exturi } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { Range, IRange } from 'vs/editor/common/core/range'; import { IMarker, MarkerSeverity, IRelatedInformation, IMarkerData } from 'vs/platform/markers/common/markers'; @@ -15,7 +15,7 @@ import { withUndefinedAsNull } from 'vs/base/common/types'; export function compareMarkersByUri(a: IMarker, b: IMarker) { - return compare(a.resource, b.resource, false, false); + return exturi.compare(a.resource, b.resource); } function compareResourceMarkers(a: ResourceMarkers, b: ResourceMarkers): number { @@ -148,14 +148,14 @@ export class MarkersModel { } getResourceMarkers(resource: URI): ResourceMarkers | null { - return withUndefinedAsNull(this.resourcesByUri.get(getComparisonKey(resource, false, true))); + return withUndefinedAsNull(this.resourcesByUri.get(exturi.getComparisonKey(resource, true))); } setResourceMarkers(resourcesMarkers: [URI, IMarker[]][]): void { const change: MarkerChangesEvent = { added: new Set(), removed: new Set(), updated: new Set() }; for (const [resource, rawMarkers] of resourcesMarkers) { - const key = getComparisonKey(resource, false, true); + const key = exturi.getComparisonKey(resource, true); let resourceMarkers = this.resourcesByUri.get(key); if (isNonEmptyArray(rawMarkers)) { diff --git a/src/vs/workbench/services/uriIdentity/common/uriIdentity.ts b/src/vs/workbench/services/uriIdentity/common/uriIdentity.ts new file mode 100644 index 0000000000000000000000000000000000000000..15a2147dd3e79dd846a90f823a71e38e1e7f866c --- /dev/null +++ b/src/vs/workbench/services/uriIdentity/common/uriIdentity.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from 'vs/base/common/uri'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IExtUri } from 'vs/base/common/resources'; + +export interface IUriIdentity { + readonly pathHierarchical: boolean; + readonly ignorePathCasing: boolean; +} + +export const IUriIdentityService = createDecorator('IUriIdentityService'); + +export interface IUriIdentityService { + + _serviceBrand: undefined; + + /** + * Uri extensions that are aware of casing. + */ + readonly extUri: IExtUri; + + /** + * Returns a canonical uri for the given resource. Different uris can point to the same + * resource. That's because of casing or missing normalization, e.g the following uris + * are different but refer to the same document (because windows paths are not case-sensitive) + * + * ```txt + * file:///c:/foo/bar.txt + * file:///c:/FOO/BAR.txt + * ``` + * + * This function should be invoked when feeding uris into the system that represent the truth, + * e.g document uris or marker-to-document associations etc. This function should NOT be called + * to pretty print a label nor to sanitize a uri. + * + * Samples: + * + * | in | out | | + * |---|---|---| + * | `file:///foo/bar/../bar` | `file:///foo/bar` | n/a | + * | `file:///foo/bar/../bar#frag` | `file:///foo/bar#frag` | keep fragment | + * | `file:///foo/BAR` | `file:///foo/bar` | assume ignore case | + * | `file:///foo/bar/../BAR?q=2` | `file:///foo/BAR?q=2` | query makes it a different document | + */ + asCanonicalUri(uri: URI): URI; +} diff --git a/src/vs/workbench/services/uriIdentity/common/uriIdentityService.ts b/src/vs/workbench/services/uriIdentity/common/uriIdentityService.ts new file mode 100644 index 0000000000000000000000000000000000000000..31c12ad43b342e80e2b1ccca256656b9c30abad9 --- /dev/null +++ b/src/vs/workbench/services/uriIdentity/common/uriIdentityService.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; +import { URI } from 'vs/base/common/uri'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { IFileService, FileSystemProviderCapabilities } from 'vs/platform/files/common/files'; +import { binarySearch } from 'vs/base/common/arrays'; +import { ExtUri, IExtUri, normalizePath } from 'vs/base/common/resources'; + +export class UriIdentityService implements IUriIdentityService { + + _serviceBrand: undefined; + + readonly extUri: IExtUri; + private _canonicalUris: URI[] = []; // use SkipList or BinaryTree instead of array... + + constructor(@IFileService private readonly _fileService: IFileService) { + + // assume path casing matters unless the file system provider spec'ed the opposite + const ignorePathCasing = (uri: URI): boolean => { + + // perf@jrieken cache this information + if (this._fileService.canHandleResource(uri)) { + return !this._fileService.hasCapability(uri, FileSystemProviderCapabilities.PathCaseSensitive); + } + + // this defaults to false which is a good default for + // * virtual documents + // * in-memory uris + // * all kind of "private" schemes + return false; + }; + this.extUri = new ExtUri(ignorePathCasing); + } + + asCanonicalUri(uri: URI): URI { + + // todo@jrieken there is more to it than just comparing + // * ASYNC!? + // * windows 8.3-filenames + // * substr-drives... + // * sym links? + // * fetch real casing? + + // (1) normalize URI + if (this._fileService.canHandleResource(uri)) { + uri = normalizePath(uri); + } + + // (2) find the uri in its canonical form or use this uri to define it + // perf@jrieken + // * using a SkipList or BinaryTree for faster insertion + const idx = binarySearch(this._canonicalUris, uri, (a, b) => this.extUri.compare(a, b, true)); + if (idx >= 0) { + return this._canonicalUris[idx].with({ fragment: uri.fragment }); + } + + // using slice/concat is faster than splice + const before = this._canonicalUris.slice(0, ~idx); + const after = this._canonicalUris.slice(~idx); + this._canonicalUris = before.concat(uri.with({ fragment: null }), after); + return uri; + } +} + +registerSingleton(IUriIdentityService, UriIdentityService, true); diff --git a/src/vs/workbench/services/uriIdentity/test/common/uriIdentityService.test.ts b/src/vs/workbench/services/uriIdentity/test/common/uriIdentityService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea422ca1a1456d57d520857c4a0428abfcb3a8c7 --- /dev/null +++ b/src/vs/workbench/services/uriIdentity/test/common/uriIdentityService.test.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { UriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentityService'; +import { mock } from 'vs/workbench/test/common/workbenchTestServices'; +import { IFileService, FileSystemProviderCapabilities } from 'vs/platform/files/common/files'; +import { URI } from 'vs/base/common/uri'; + +suite('URI Identity', function () { + + class FakeFileService extends mock() { + + constructor(readonly data: Map) { + super(); + } + canHandleResource(uri: URI) { + return this.data.has(uri.scheme); + } + hasCapability(uri: URI, flag: FileSystemProviderCapabilities): boolean { + const mask = this.data.get(uri.scheme) ?? 0; + return Boolean(mask & flag); + } + } + + let _service: UriIdentityService; + + setup(function () { + _service = new UriIdentityService(new FakeFileService(new Map([ + ['bar', FileSystemProviderCapabilities.PathCaseSensitive], + ['foo', 0] + ]))); + }); + + function assertCanonical(input: URI, expected: URI, service: UriIdentityService = _service) { + const actual = service.asCanonicalUri(input); + assert.equal(actual.toString(), expected.toString()); + assert.ok(service.extUri.isEqual(actual, expected)); + } + + test('extUri (isEqual)', function () { + let a = URI.parse('foo://bar/bang'); + let a1 = URI.parse('foo://bar/BANG'); + let b = URI.parse('bar://bar/bang'); + let b1 = URI.parse('bar://bar/BANG'); + + assert.equal(_service.extUri.isEqual(a, a1), true); + assert.equal(_service.extUri.isEqual(a1, a), true); + + assert.equal(_service.extUri.isEqual(b, b1), false); + assert.equal(_service.extUri.isEqual(b1, b), false); + }); + + test('asCanonicalUri (casing)', function () { + + let a = URI.parse('foo://bar/bang'); + let a1 = URI.parse('foo://bar/BANG'); + let b = URI.parse('bar://bar/bang'); + let b1 = URI.parse('bar://bar/BANG'); + + assertCanonical(a, a); + assertCanonical(a1, a); + + assertCanonical(b, b); + assertCanonical(b1, b1); // case sensitive + }); + + test('asCanonicalUri (normalization)', function () { + let a = URI.parse('foo://bar/bang'); + assertCanonical(a, a); + assertCanonical(URI.parse('foo://bar/./bang'), a); + assertCanonical(URI.parse('foo://bar/./bang'), a); + assertCanonical(URI.parse('foo://bar/./foo/../bang'), a); + }); + + test('asCanonicalUri (keep fragement)', function () { + + let a = URI.parse('foo://bar/bang'); + + assertCanonical(a, a); + assertCanonical(URI.parse('foo://bar/./bang#frag'), a.with({ fragment: 'frag' })); + assertCanonical(URI.parse('foo://bar/./bang#frag'), a.with({ fragment: 'frag' })); + assertCanonical(URI.parse('foo://bar/./bang#frag'), a.with({ fragment: 'frag' })); + assertCanonical(URI.parse('foo://bar/./foo/../bang#frag'), a.with({ fragment: 'frag' })); + + let b = URI.parse('foo://bar/bazz#frag'); + assertCanonical(b, b); + assertCanonical(URI.parse('foo://bar/bazz'), b.with({ fragment: '' })); + assertCanonical(URI.parse('foo://bar/BAZZ#DDD'), b.with({ fragment: 'DDD' })); // lower-case path, but fragment is kept + }); + +}); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 13ee74d4e96353a17a0ad0884c5ea40adff07de5..6692f14a7ba6e076c872b417e549046d20c1ceb1 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -53,6 +53,7 @@ import 'vs/workbench/browser/parts/views/viewsService'; //#region --- workbench services import 'vs/platform/undoRedo/common/undoRedoService'; +import 'vs/workbench/services/uriIdentity/common/uriIdentityService'; import 'vs/workbench/services/extensions/browser/extensionUrlHandler'; import 'vs/workbench/services/bulkEdit/browser/bulkEditService'; import 'vs/workbench/services/keybinding/common/keybindingEditing';