From 453ebc324581e2dcba828180588c12f06bbc1af3 Mon Sep 17 00:00:00 2001 From: Damjan Cvetko Date: Wed, 1 Feb 2023 13:28:09 +0100 Subject: [PATCH] feat: support uri path mappings (#879) * Re-implement path mapping logic * Fix typos. * Test Phar specific cases * Add exact file mapping match. * Changelog. --- CHANGELOG.md | 4 + src/paths.ts | 212 +++++++++++++++++++++++----------------------- src/phpDebug.ts | 4 +- src/test/paths.ts | 159 +++++++++++++++++++++++++++++----- 4 files changed, 253 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 548a9be8..59b84571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [1.31.0] + +- Allow more flexible path mappings in url format. + ## [1.30.0] - Add skipFiles launch setting to skip over specified file patterns. diff --git a/src/paths.ts b/src/paths.ts index 3c472666..d7ab8f91 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,141 +1,108 @@ import fileUrl from 'file-url' import * as url from 'url' import * as path from 'path' -import { decode } from 'urlencode' -import RelateUrl from 'relateurl' import minimatch from 'minimatch' -/** - * Options to make sure that RelateUrl only outputs relative URLs and performs not other "smart" modifications. - * They would mess up things like prefix checking. - */ -const RELATE_URL_OPTIONS: RelateUrl.Options = { - // Make sure RelateUrl does not prefer root-relative URLs if shorter - output: RelateUrl.PATH_RELATIVE, - // Make sure RelateUrl does not remove trailing slash if present - removeRootTrailingSlash: false, - // Make sure RelateUrl does not remove default ports - defaultPorts: {}, -} - -/** - * Like `path.relative()` but for URLs. - * Inverse of `url.resolve()` or `new URL(relative, base)`. - */ -const relativeUrl = (from: string, to: string): string => RelateUrl.relate(from, to, RELATE_URL_OPTIONS) - /** converts a server-side Xdebug file URI to a local path for VS Code with respect to source root settings */ -export function convertDebuggerPathToClient( - fileUri: string | url.Url, - pathMapping?: { [index: string]: string } -): string { - let localSourceRoot: string | undefined - let serverSourceRoot: string | undefined - if (typeof fileUri === 'string') { - fileUri = url.parse(fileUri) - } - // convert the file URI to a path - let serverPath = decode(fileUri.pathname!) - // strip the trailing slash from Windows paths (indicated by a drive letter with a colon) - const serverIsWindows = /^\/[a-zA-Z]:\//.test(serverPath) - if (serverIsWindows) { - serverPath = serverPath.substr(1) - } +export function convertDebuggerPathToClient(fileUri: string, pathMapping?: { [index: string]: string }): string { + let localSourceRootUrl: string | undefined + let serverSourceRootUrl: string | undefined + if (pathMapping) { for (const mappedServerPath of Object.keys(pathMapping)) { - const mappedLocalSource = pathMapping[mappedServerPath] - // normalize slashes for windows-to-unix - const serverRelative = (serverIsWindows ? path.win32 : path.posix).relative(mappedServerPath, serverPath) - if (!serverRelative.startsWith('..')) { + let mappedServerPathUrl = pathOrUrlToUrl(mappedServerPath) + // try exact match + if (fileUri.length === mappedServerPathUrl.length && isSameUri(fileUri, mappedServerPathUrl)) { + // bail early + serverSourceRootUrl = mappedServerPathUrl + localSourceRootUrl = pathOrUrlToUrl(pathMapping[mappedServerPath]) + break + } + // make sure it ends with a slash + if (!mappedServerPathUrl.endsWith('/')) { + mappedServerPathUrl += '/' + } + if (isSameUri(fileUri.substring(0, mappedServerPathUrl.length), mappedServerPathUrl)) { // If a matching mapping has previously been found, only update // it if the current server path is longer than the previous one // (longest prefix matching) - if (!serverSourceRoot || mappedServerPath.length > serverSourceRoot.length) { - serverSourceRoot = mappedServerPath - localSourceRoot = mappedLocalSource + if (!serverSourceRootUrl || mappedServerPathUrl.length > serverSourceRootUrl.length) { + serverSourceRootUrl = mappedServerPathUrl + localSourceRootUrl = pathOrUrlToUrl(pathMapping[mappedServerPath]) + if (!localSourceRootUrl.endsWith('/')) { + localSourceRootUrl += '/' + } } } } } let localPath: string - if (serverSourceRoot && localSourceRoot) { - const clientIsWindows = - /^[a-zA-Z]:\\/.test(localSourceRoot) || - /^\\\\/.test(localSourceRoot) || - /^[a-zA-Z]:$/.test(localSourceRoot) || - /^[a-zA-Z]:\//.test(localSourceRoot) - // get the part of the path that is relative to the source root - let pathRelativeToSourceRoot = (serverIsWindows ? path.win32 : path.posix).relative( - serverSourceRoot, - serverPath - ) - if (serverIsWindows && !clientIsWindows) { - pathRelativeToSourceRoot = pathRelativeToSourceRoot.replace(/\\/g, path.posix.sep) - } - if (clientIsWindows && /^[a-zA-Z]:$/.test(localSourceRoot)) { - // if local source root mapping is only drive letter, add backslash - localSourceRoot += '\\' + if (serverSourceRootUrl && localSourceRootUrl) { + fileUri = localSourceRootUrl + fileUri.substring(serverSourceRootUrl.length) + } + if (fileUri.startsWith('file://')) { + const u = new URL(fileUri) + let pathname = u.pathname + if (isWindowsUri(fileUri)) { + // From Node.js lib/internal/url.js pathToFileURL + pathname = pathname.replace(/\//g, path.win32.sep) + pathname = decodeURIComponent(pathname) + if (u.hostname !== '') { + localPath = `\\\\${url.domainToUnicode(u.hostname)}${pathname}` + } else { + localPath = pathname.slice(1) + } + } else { + localPath = decodeURIComponent(pathname) } - // resolve from the local source root - localPath = (clientIsWindows ? path.win32 : path.posix).resolve(localSourceRoot, pathRelativeToSourceRoot) } else { - localPath = (serverIsWindows ? path.win32 : path.posix).normalize(serverPath) + // if it's not a file url it could be sshfs or something else + localPath = fileUri } return localPath } /** converts a local path from VS Code to a server-side Xdebug file URI with respect to source root settings */ export function convertClientPathToDebugger(localPath: string, pathMapping?: { [index: string]: string }): string { - let localSourceRoot: string | undefined - let serverSourceRoot: string | undefined - // Xdebug always lowercases Windows drive letters in file URIs - const localFileUri = fileUrl( - localPath.replace(/^[A-Z]:\\/, match => match.toLowerCase()), - { resolve: false } - ) + let localSourceRootUrl: string | undefined + let serverSourceRootUrl: string | undefined + + // Parse or convert local path to URL + const localFileUri = pathOrUrlToUrl(localPath) + let serverFileUri: string if (pathMapping) { for (const mappedServerPath of Object.keys(pathMapping)) { - let mappedLocalSource = pathMapping[mappedServerPath] - if (/^[a-zA-Z]:$/.test(mappedLocalSource)) { - // if local source root mapping is only drive letter, add backslash - mappedLocalSource += '\\' + //let mappedLocalSource = pathMapping[mappedServerPath] + let mappedLocalSourceUrl = pathOrUrlToUrl(pathMapping[mappedServerPath]) + // try exact match + if (localFileUri.length === mappedLocalSourceUrl.length && isSameUri(localFileUri, mappedLocalSourceUrl)) { + // bail early + localSourceRootUrl = mappedLocalSourceUrl + serverSourceRootUrl = pathOrUrlToUrl(mappedServerPath) + break + } + // make sure it ends with a slash + if (!mappedLocalSourceUrl.endsWith('/')) { + mappedLocalSourceUrl += '/' } - const localRelative = path.relative(mappedLocalSource, localPath) - if (!localRelative.startsWith('..')) { + + if (isSameUri(localFileUri.substring(0, mappedLocalSourceUrl.length), mappedLocalSourceUrl)) { // If a matching mapping has previously been found, only update // it if the current local path is longer than the previous one // (longest prefix matching) - if (!localSourceRoot || mappedLocalSource.length > localSourceRoot.length) { - serverSourceRoot = mappedServerPath - localSourceRoot = mappedLocalSource + if (!localSourceRootUrl || mappedLocalSourceUrl.length > localSourceRootUrl.length) { + localSourceRootUrl = mappedLocalSourceUrl + serverSourceRootUrl = pathOrUrlToUrl(mappedServerPath) + if (!serverSourceRootUrl.endsWith('/')) { + serverSourceRootUrl += '/' + } } } } } - if (localSourceRoot) { - localSourceRoot = localSourceRoot.replace(/^[A-Z]:$/, match => match.toLowerCase()) - localSourceRoot = localSourceRoot.replace(/^[A-Z]:\\/, match => match.toLowerCase()) - localSourceRoot = localSourceRoot.replace(/^[A-Z]:\//, match => match.toLowerCase()) - } - if (serverSourceRoot) { - serverSourceRoot = serverSourceRoot.replace(/^[A-Z]:$/, match => match.toLowerCase()) - serverSourceRoot = serverSourceRoot.replace(/^[A-Z]:\\/, match => match.toLowerCase()) - serverSourceRoot = serverSourceRoot.replace(/^[A-Z]:\//, match => match.toLowerCase()) - } - if (serverSourceRoot && localSourceRoot) { - let localSourceRootUrl = fileUrl(localSourceRoot, { resolve: false }) - if (!localSourceRootUrl.endsWith('/')) { - localSourceRootUrl += '/' - } - let serverSourceRootUrl = fileUrl(serverSourceRoot, { resolve: false }) - if (!serverSourceRootUrl.endsWith('/')) { - serverSourceRootUrl += '/' - } - // get the part of the path that is relative to the source root - const urlRelativeToSourceRoot = relativeUrl(localSourceRootUrl, localFileUri) - // resolve from the server source root - serverFileUri = url.resolve(serverSourceRootUrl, urlRelativeToSourceRoot) + if (serverSourceRootUrl && localSourceRootUrl) { + serverFileUri = serverSourceRootUrl + localFileUri.substring(localSourceRootUrl.length) } else { serverFileUri = localFileUri } @@ -143,7 +110,44 @@ export function convertClientPathToDebugger(localPath: string, pathMapping?: { [ } export function isWindowsUri(path: string): boolean { - return /^file:\/\/\/[a-zA-Z]:\//.test(path) + return /^file:\/\/\/[a-zA-Z]:\//.test(path) || /^file:\/\/[^/]/.test(path) +} + +function isWindowsPath(path: string): boolean { + return /^[a-zA-Z]:\\/.test(path) || /^\\\\/.test(path) || /^[a-zA-Z]:$/.test(path) || /^[a-zA-Z]:\//.test(path) +} + +function pathOrUrlToUrl(path: string): string { + // Do not try to parse windows drive letter paths + if (!isWindowsPath(path)) { + try { + // try to parse, but do not modify + new URL(path).toString() + return path + } catch (ex) { + // should be a path + } + } + // Not a URL, do some windows path mangling before it is converted to URL + if (path.startsWith('\\\\')) { + // UNC + const hostEndIndex = path.indexOf('\\', 2) + const host = path.substring(2, hostEndIndex) + const outURL = new URL('file://') + outURL.hostname = url.domainToASCII(host) + outURL.pathname = path.substring(hostEndIndex).replace(/\\/g, '/') + return outURL.toString() + } + if (/^[a-zA-Z]:$/.test(path)) { + // if local source root mapping is only drive letter, add backslash + path += '\\' + } + // Do not change drive later to lower case anymore + // if (/^[a-zA-Z]:/.test(path)) { + // // Xdebug always lowercases Windows drive letters in file URIs + // //path = path.replace(/^[A-Z]:/, match => match.toLowerCase()) + // } + return fileUrl(path, { resolve: false }) } export function isSameUri(clientUri: string, debuggerUri: string): boolean { diff --git a/src/phpDebug.ts b/src/phpDebug.ts index 229938e8..3e0b15a0 100644 --- a/src/phpDebug.ts +++ b/src/phpDebug.ts @@ -953,7 +953,7 @@ class PhpDebugSession extends vscode.DebugSession { line++ } else { // Xdebug paths are URIs, VS Code file paths - const filePath = convertDebuggerPathToClient(urlObject, this._args.pathMappings) + const filePath = convertDebuggerPathToClient(status.fileUri, this._args.pathMappings) // "Name" of the source and the actual file path source = { name: path.basename(filePath), path: filePath } } @@ -992,7 +992,7 @@ class PhpDebugSession extends vscode.DebugSession { line++ } else { // Xdebug paths are URIs, VS Code file paths - const filePath = convertDebuggerPathToClient(urlObject, this._args.pathMappings) + const filePath = convertDebuggerPathToClient(stackFrame.fileUri, this._args.pathMappings) // "Name" of the source and the actual file path source = { name: path.basename(filePath), path: filePath } } diff --git a/src/test/paths.ts b/src/test/paths.ts index 4869354e..57233cf3 100644 --- a/src/test/paths.ts +++ b/src/test/paths.ts @@ -30,7 +30,7 @@ describe('paths', () => { it('should convert a windows path to a URI', () => { assert.equal( convertClientPathToDebugger('C:\\Users\\felix\\test.php'), - 'file:///c:/Users/felix/test.php' + 'file:///C:/Users/felix/test.php' ) }) it('should convert a unix path to a URI', () => { @@ -74,7 +74,7 @@ describe('paths', () => { 'C:\\Program Files\\Apache\\2.4\\htdocs': '/home/felix/mysite', 'C:\\Program Files\\MySource': '/home/felix/mysource', }), - 'file:///c:/Program%20Files/Apache/2.4/htdocs/site.php' + 'file:///C:/Program%20Files/Apache/2.4/htdocs/site.php' ) // source assert.equal( @@ -82,11 +82,11 @@ describe('paths', () => { 'C:\\Program Files\\Apache\\2.4\\htdocs': '/home/felix/mysite', 'C:\\Program Files\\MySource': '/home/felix/mysource', }), - 'file:///c:/Program%20Files/MySource/source.php' + 'file:///C:/Program%20Files/MySource/source.php' ) }) // windows to unix - ;(process.platform === 'win32' ? it : it.skip)('should convert a windows path to a unix URI', () => { + it('should convert a windows path to a unix URI', () => { // site assert.equal( convertClientPathToDebugger('C:\\Users\\felix\\mysite\\site.php', { @@ -132,28 +132,25 @@ describe('paths', () => { 'file:///app/source.php' ) }) - ;(process.platform === 'win32' ? it : it.skip)( - 'should convert a windows path with inconsistent casing to a unix URI', - () => { - const localSourceRoot = 'C:\\Users\\felix\\myproject' - const serverSourceRoot = '/var/www' - assert.equal( - convertClientPathToDebugger('c:\\Users\\felix\\myproject\\test.php', { - [serverSourceRoot]: localSourceRoot, - }), - 'file:///var/www/test.php' - ) - } - ) + it('should convert a windows path with inconsistent casing to a unix URI', () => { + const localSourceRoot = 'C:\\Users\\felix\\myproject' + const serverSourceRoot = '/var/www' + assert.equal( + convertClientPathToDebugger('c:\\Users\\felix\\myproject\\test.php', { + [serverSourceRoot]: localSourceRoot, + }), + 'file:///var/www/test.php' + ) + }) // windows to windows - ;(process.platform === 'win32' ? it : it.skip)('should convert a windows path to a windows URI', () => { + it('should convert a windows path to a windows URI', () => { // site assert.equal( convertClientPathToDebugger('C:\\Users\\felix\\mysite\\site.php', { 'C:\\Program Files\\Apache\\2.4\\htdocs': 'C:\\Users\\felix\\mysite', 'C:\\Program Files\\MySource': 'C:\\Users\\felix\\mysource', }), - 'file:///c:/Program%20Files/Apache/2.4/htdocs/site.php' + 'file:///C:/Program%20Files/Apache/2.4/htdocs/site.php' ) // source assert.equal( @@ -161,7 +158,25 @@ describe('paths', () => { 'C:\\Program Files\\Apache\\2.4\\htdocs': 'C:\\Users\\felix\\mysite', 'C:\\Program Files\\MySource': 'C:\\Users\\felix\\mysource', }), - 'file:///c:/Program%20Files/MySource/source.php' + 'file:///C:/Program%20Files/MySource/source.php' + ) + }) + }) + describe('exact file mappings', () => { + it('should map exact unix path', () => { + assert.equal( + convertClientPathToDebugger('/var/path/file.php', { + '/var/path2/file2.php': '/var/path/file.php', + }), + 'file:///var/path2/file2.php' + ) + }) + it('should map exact windows path', () => { + assert.equal( + convertClientPathToDebugger('C:\\var\\path\\file.php', { + 'C:\\var\\path2\\file2.php': 'C:\\var\\path\\file.php', + }), + 'file:///C:/var/path2/file2.php' ) }) }) @@ -262,6 +277,14 @@ describe('paths', () => { }) // windows to unix it('should map windows uris to unix paths', () => { + // dir/site + assert.equal( + convertDebuggerPathToClient('file:///C:/Program%20Files/Apache/2.4/htdocs/dir/site.php', { + 'C:\\Program Files\\Apache\\2.4\\htdocs': '/home/felix/mysite', + 'C:\\Program Files\\MySource': '/home/felix/mysource', + }), + '/home/felix/mysite/dir/site.php' + ) // site assert.equal( convertDebuggerPathToClient('file:///C:/Program%20Files/Apache/2.4/htdocs/site.php', { @@ -307,6 +330,102 @@ describe('paths', () => { ) }) }) + describe('exact file mappings', () => { + it('should map exact unix path', () => { + assert.equal( + convertDebuggerPathToClient('file:///var/path2/file2.php', { + '/var/path2/file2.php': '/var/path/file.php', + }), + '/var/path/file.php' + ) + }) + it('should map exact windows path', () => { + assert.equal( + convertDebuggerPathToClient('file:///C:/var/path2/file2.php', { + 'C:\\var\\path2\\file2.php': 'C:\\var\\path\\file.php', + }), + 'C:\\var\\path\\file.php' + ) + }) + }) + }) + describe('sshfs', () => { + it('should map sshfs to remote unix', () => { + assert.equal( + convertClientPathToDebugger('ssh://host/path/file.php', { + '/root/path': 'ssh://host/path/', + }), + 'file:///root/path/file.php' + ) + }) + it('should map remote unix to sshfs', () => { + assert.equal( + convertDebuggerPathToClient('file:///root/path/file.php', { + '/root/path': 'ssh://host/path/', + }), + 'ssh://host/path/file.php' + ) + }) + }) + describe('UNC', () => { + it('should convert UNC to url', () => { + assert.equal(convertClientPathToDebugger('\\\\DARKPAD\\smb\\test1.php', {}), 'file://darkpad/smb/test1.php') + }) + it('should convert url to UNC', () => { + assert.equal(convertDebuggerPathToClient('file://DARKPAD/SMB/test2.php', {}), '\\\\darkpad\\SMB\\test2.php') + }) + }) + describe('UNC mapping', () => { + it('should convert UNC to mapped url', () => { + assert.equal( + convertClientPathToDebugger('\\\\DARKPAD\\smb\\test1.php', { + '/var/test': '\\\\DARKPAD\\smb', + }), + 'file:///var/test/test1.php' + ) + }) + it('should convert url to mapped UNC', () => { + assert.equal( + convertDebuggerPathToClient('file:///var/test/test2.php', { + '/var/test': '\\\\DARKPAD\\smb', + }), + '\\\\darkpad\\smb\\test2.php' + ) + }) + }) + describe('Phar', () => { + it('should map win32 Phar client to debugger', () => { + assert.equal( + convertClientPathToDebugger('C:\\otherfolder\\internal\\file.php', { + 'phar://C:/folder/file.phar': 'C:\\otherfolder', + }), + 'phar://C:/folder/file.phar/internal/file.php' + ) + }) + it('should map win32 Phar debugger to debugger', () => { + assert.equal( + convertDebuggerPathToClient('phar://C:/folder/file.phar/internal/file.php', { + 'phar://C:/folder/file.phar': 'C:\\otherfolder', + }), + 'C:\\otherfolder\\internal\\file.php' + ) + }) + it('should map posix Phar client to debugger', () => { + assert.equal( + convertClientPathToDebugger('/otherfolder/internal/file.php', { + 'phar:///folder/file.phar': '/otherfolder', + }), + 'phar:///folder/file.phar/internal/file.php' + ) + }) + it('should map posix Phar debugger to debugger', () => { + assert.equal( + convertDebuggerPathToClient('phar:///folder/file.phar/internal/file.php', { + 'phar:///folder/file.phar': '/otherfolder', + }), + '/otherfolder/internal/file.php' + ) + }) }) describe('isPositiveMatchInGlobs', () => { it('should not match empty globs', () => {