diff --git a/.vscode/launch.json b/.vscode/launch.json index dca4a80b..d9356757 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "NODE_ENV": "development" }, "sourceMaps": true, - "outDir": "${workspaceRoot}/out" + "outFiles": ["${workspaceRoot}/out/**/*.js"] }, { "name": "Launch Extension", @@ -21,7 +21,7 @@ "runtimeExecutable": "${execPath}", "args": ["--extensionDevelopmentPath=${workspaceRoot}"], "sourceMaps": true, - "outDir": "${workspaceRoot}/out" + "outFiles": ["${workspaceRoot}/out/**/*.js"] }, { "name": "Mocha", @@ -31,7 +31,7 @@ "args": ["out/test", "--no-timeouts", "--colors"], "cwd": "${workspaceRoot}", "sourceMaps": true, - "outDir": "${workspaceRoot}/out" + "outFiles": ["${workspaceRoot}/out/**/*.js"] } ] } diff --git a/package-lock.json b/package-lock.json index 2f59a6ee..75fcc015 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1195,6 +1195,12 @@ "integrity": "sha512-4Gqya3/CmZ280CA6DvBnHmnq/vfgIqXJvzUC2S7a8kxmv/gbmt0uhJbx6m7ypWJvNn9n8DKWj5LD8wFpSxeQuA==", "dev": true }, + "@types/vscode": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.53.0.tgz", + "integrity": "sha512-XjFWbSPOM0EKIT2XhhYm3D3cx3nn3lshMUcWNy1eqefk+oqRuBq8unVb6BYIZqXy9lQZyeUl7eaBCOZWv+LcXQ==", + "dev": true + }, "@types/xmldom": { "version": "0.1.30", "resolved": "https://registry.npmjs.org/@types/xmldom/-/xmldom-0.1.30.tgz", @@ -2438,7 +2444,7 @@ "readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "integrity": "sha1-Hsoc9xGu+BTAT2IlKjamL2yyO1c=", "dev": true, "requires": { "core-util-is": "~1.0.0", @@ -2462,7 +2468,7 @@ "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "integrity": "sha1-nPFhG6YmhdcDCunkujQUnDrwP8g=", "dev": true, "requires": { "safe-buffer": "~5.1.0" @@ -2679,7 +2685,7 @@ "human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha1-3JH8ukLk0G5Kuu0zs+ejwC9RTqA=", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true }, "husky": { @@ -3757,7 +3763,7 @@ "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha1-UoI2KaFN0AyXcPtq1H3GMQ8sH2A=", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, "merge2": { @@ -8943,7 +8949,7 @@ "strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha1-ibhS+y/L6Tb29LMYevsKEsGrWK0=", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true }, "strip-json-comments": { diff --git a/package.json b/package.json index 8c153f16..dbc52ef3 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/semver": "^7.3.4", "@types/urlencode": "^1.1.2", "@types/xmldom": "^0.1.30", + "@types/vscode": "^1.53.0", "chai": "^4.3.0", "chai-as-promised": "^7.1.1", "husky": "^4.3.8", @@ -130,6 +131,10 @@ "out/test/**/*.*" ] }, + "main": "./out/extension.js", + "activationEvents": [ + "onDebug" + ], "contributes": { "breakpoints": [ { @@ -202,7 +207,7 @@ "port": { "type": "number", "description": "Port on which to listen for Xdebug", - "default": 9000 + "default": 9003 }, "serverSourceRoot": { "type": "string", @@ -261,7 +266,7 @@ "name": "Listen for Xdebug", "type": "php", "request": "launch", - "port": 9000 + "port": 9003 }, { "name": "Launch currently open script", @@ -269,7 +274,62 @@ "request": "launch", "program": "${file}", "cwd": "${fileDirname}", - "port": 9000 + "port": 0, + "runtimeArgs": [ + "-dxdebug.start_with_request=yes" + ], + "env": { + "XDEBUG_MODE": "debug,develop", + "XDEBUG_CONFIG": "client_port=${port}" + } + } + ], + "configurationSnippets": [ + { + "label": "PHP: Listen for Xdebug", + "description": "Listening for Xdebug", + "body": { + "name": "Listen for Xdebug", + "type": "php", + "request": "launch", + "port": 9003 + } + }, + { + "label": "PHP: Launch currently open script", + "description": "Launch currently open script", + "body": { + "name": "Launch currently open script", + "type": "php", + "request": "launch", + "program": "^\"${1:\\${file\\}}\"", + "cwd": "^\"${2:\\${fileDirname\\}}\"", + "port": 0, + "runtimeArgs": [ + "-dxdebug.start_with_request=yes" + ], + "env": { + "XDEBUG_MODE": "debug,develop", + "XDEBUG_CONFIG": "^\"client_port=\\${port\\}\"" + } + } + }, + { + "label": "PHP: Launch currently open script with Xdebug 2", + "description": "Launch currently open script", + "body": { + "name": "Launch currently open script", + "type": "php", + "request": "launch", + "program": "^\"${1:\\${file\\}}\"", + "cwd": "^\"${2:\\${fileDirname\\}}\"", + "port": 0, + "runtimeArgs": [ + "-dxdebug.remote_enable=1", + "-dxdebug.remote_autostart=1", + "^\"-dxdebug.remote_port=\\${port\\}\"" + ] + } } ] } diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 00000000..579a7e99 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,33 @@ +import * as vscode from 'vscode' +import { PhpDebugSession, StartRequestArguments } from './phpDebug' + +export function activate(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.debug.onDidReceiveDebugSessionCustomEvent(event => { + if (event.event === 'newDbgpConnection') { + const config: vscode.DebugConfiguration & StartRequestArguments = { + ...event.session.configuration, + } + config.request = 'launch' + config.name = 'DBGp connection ' + event.body.connId + config.connId = event.body.connId + vscode.debug.startDebugging(undefined, config) + } + }) + ) + + const factory = new InlineDebugAdapterFactory() + context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory('php', factory)) + if ('dispose' in factory) { + context.subscriptions.push(factory) + } +} + +class InlineDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory { + createDebugAdapterDescriptor(_session: vscode.DebugSession): vscode.ProviderResult { + // since DebugAdapterInlineImplementation is proposed API, a cast to is required for now + const dap = new PhpDebugSession() + dap.setFromExtension(true) + return new vscode.DebugAdapterInlineImplementation(dap) + } +} diff --git a/src/phpDebug.ts b/src/phpDebug.ts index 082ca6aa..362e1c3f 100644 --- a/src/phpDebug.ts +++ b/src/phpDebug.ts @@ -46,10 +46,22 @@ function formatPropertyValue(property: xdebug.BaseProperty): string { return displayValue } +class NewDbgpConnectionEvent extends vscode.Event { + body: { + connId: number + } + constructor(connId: number) { + super('newDbgpConnection') + this.body = { + connId: connId, + } + } +} + /** * This interface should always match the schema found in the mock-debug extension manifest. */ -interface LaunchRequestArguments extends VSCodeDebugProtocol.LaunchRequestArguments { +export interface StartRequestArguments { /** The address to bind to for listening for Xdebug connections (default: all IPv6 connections if available, else all IPv4 connections) */ hostname?: string /** The port where the adapter should listen for Xdebug connections (default: 9000) */ @@ -85,11 +97,13 @@ interface LaunchRequestArguments extends VSCodeDebugProtocol.LaunchRequestArgume env?: { [key: string]: string } /** If true launch the target in an external console. */ externalConsole?: boolean + /** Internal variable for passing connections between adapter instances */ + connId?: number } -class PhpDebugSession extends vscode.DebugSession { +export class PhpDebugSession extends vscode.DebugSession { /** The arguments that were given to launchRequest */ - private _args: LaunchRequestArguments + private _args: StartRequestArguments /** The TCP server that listens for Xdebug connections */ private _server: net.Server @@ -143,6 +157,12 @@ class PhpDebugSession extends vscode.DebugSession { /** A flag to indicate that the adapter has already processed the stopOnEntry step request */ private _hasStoppedOnEntry = false + /** Static store of all active connections used to pass them betweeen adapter instances */ + private static _allConnections = new Map() + + /** A flag indicating the adapter was initiatefd from vscode extension - controls how sessions are handeled */ + private _fromExtension = false + public constructor() { super() this.setDebuggerColumnsStartAt1(true) @@ -150,6 +170,10 @@ class PhpDebugSession extends vscode.DebugSession { this.setDebuggerPathFormat('uri') } + public setFromExtension(fromExtension = false): void { + this._fromExtension = fromExtension + } + protected initializeRequest( response: VSCodeDebugProtocol.InitializeResponse, args: VSCodeDebugProtocol.InitializeRequestArguments @@ -186,15 +210,27 @@ class PhpDebugSession extends vscode.DebugSession { this.sendResponse(response) } - protected attachRequest( + protected async attachRequest( response: VSCodeDebugProtocol.AttachResponse, - args: VSCodeDebugProtocol.AttachRequestArguments + args: StartRequestArguments & VSCodeDebugProtocol.AttachRequestArguments ) { - this.sendErrorResponse(response, new Error('Attach requests are not supported')) - this.shutdown() + if (!args.connId || !PhpDebugSession._allConnections.has(args.connId)) { + this.sendErrorResponse(response, new Error("Can't find connection")) + this.shutdown() + return + } + this.sendResponse(response) + + this._args = args + const connection = PhpDebugSession._allConnections.get(args.connId!)! + + await this.processConnection(connection, args) } - protected async launchRequest(response: VSCodeDebugProtocol.LaunchResponse, args: LaunchRequestArguments) { + protected async launchRequest( + response: VSCodeDebugProtocol.LaunchResponse, + args: StartRequestArguments & VSCodeDebugProtocol.LaunchRequestArguments + ) { if (args.localSourceRoot && args.serverSourceRoot) { let pathMappings: { [index: string]: string } = {} if (args.pathMappings) { @@ -204,134 +240,165 @@ class PhpDebugSession extends vscode.DebugSession { args.pathMappings = pathMappings } this._args = args - /** launches the script as CLI */ - const launchScript = async () => { - // check if program exists - await new Promise((resolve, reject) => - fs.access(args.program!, fs.constants.F_OK, err => (err ? reject(err) : resolve())) - ) - const runtimeArgs = args.runtimeArgs || [] - const runtimeExecutable = args.runtimeExecutable || 'php' - const programArgs = args.args || [] - const cwd = args.cwd || process.cwd() - const env = args.env || process.env - // launch in CLI mode - if (args.externalConsole) { - const script = await Terminal.launchInTerminal( - cwd, - [runtimeExecutable, ...runtimeArgs, args.program!, ...programArgs], - env - ) - if (script) { - // we only do this for CLI mode. In normal listen mode, only a thread exited event is send. - script.on('exit', () => { - this.sendEvent(new vscode.TerminatedEvent()) - }) + try { + if (args.connId) { + if (!PhpDebugSession._allConnections.has(args.connId)) { + this.sendErrorResponse(response, new Error("Can't find connection")) + this.shutdown() + return } + const connection = PhpDebugSession._allConnections.get(args.connId!)! + await this.processConnection(connection, args) } else { - const script = childProcess.spawn(runtimeExecutable, [...runtimeArgs, args.program!, ...programArgs], { - cwd, - env, - }) - // redirect output to debug console - script.stdout.on('data', (data: Buffer) => { - this.sendEvent(new vscode.OutputEvent(data + '', 'stdout')) - }) - script.stderr.on('data', (data: Buffer) => { - this.sendEvent(new vscode.OutputEvent(data + '', 'stderr')) - }) + let port = 0 + if (!args.noDebug) { + port = await this.createServer(args) + } + if (args.program) { + await this.launchScript(port, args) + } + } + } catch (error) { + this.sendErrorResponse(response, error) + return + } + this.sendResponse(response) + } + + /** launches the script as CLI */ + private async launchScript(port: number, args: StartRequestArguments): Promise { + // check if program exists + await new Promise((resolve, reject) => + fs.access(args.program!, fs.constants.F_OK, err => (err ? reject(err) : resolve())) + ) + const runtimeArgs = (args.runtimeArgs || []).map(v => v.replace('${port}', port.toString())) + const runtimeExecutable = args.runtimeExecutable || 'php' + const programArgs = args.args || [] + const cwd = args.cwd || process.cwd() + const env = Object.fromEntries( + Object.entries(args.env || process.env).map(v => [v[0], v[1]?.replace('${port}', port.toString())]) + ) + // launch in CLI mode + if (args.externalConsole) { + const script = await Terminal.launchInTerminal( + cwd, + [runtimeExecutable, ...runtimeArgs, args.program!, ...programArgs], + env + ) + if (script) { // we only do this for CLI mode. In normal listen mode, only a thread exited event is send. script.on('exit', () => { this.sendEvent(new vscode.TerminatedEvent()) }) - script.on('error', (error: Error) => { - this.sendEvent(new vscode.OutputEvent(util.inspect(error) + '\n')) - }) - this._phpProcess = script } + } else { + const script = childProcess.spawn(runtimeExecutable, [...runtimeArgs, args.program!, ...programArgs], { + cwd, + env, + }) + // redirect output to debug console + script.stdout.on('data', (data: Buffer) => { + this.sendEvent(new vscode.OutputEvent(data + '', 'stdout')) + }) + script.stderr.on('data', (data: Buffer) => { + this.sendEvent(new vscode.OutputEvent(data + '', 'stderr')) + }) + // we only do this for CLI mode. In normal listen mode, only a thread exited event is send. + script.on('exit', () => { + this.sendEvent(new vscode.TerminatedEvent()) + }) + script.on('error', (error: Error) => { + this.sendEvent(new vscode.OutputEvent(util.inspect(error) + '\n')) + }) + this._phpProcess = script } - /** sets up a TCP server to listen for Xdebug connections */ - const createServer = () => - new Promise((resolve, reject) => { - const server = (this._server = net.createServer()) - server.on('connection', async (socket: net.Socket) => { - try { - // new Xdebug connection - const connection = new xdebug.Connection(socket) - if (args.log) { - this.sendEvent(new vscode.OutputEvent('new connection ' + connection.id + '\n'), true) - } - this._connections.set(connection.id, connection) - this._waitingConnections.add(connection) - const disposeConnection = (error?: Error) => { - if (this._connections.has(connection.id)) { - if (args.log) { - this.sendEvent(new vscode.OutputEvent('connection ' + connection.id + ' closed\n')) - } - if (error) { - this.sendEvent( - new vscode.OutputEvent( - 'connection ' + connection.id + ': ' + error.message + '\n' - ) - ) - } - this.sendEvent(new vscode.ContinuedEvent(connection.id, false)) - this.sendEvent(new vscode.ThreadEvent('exited', connection.id)) - connection.close() - this._connections.delete(connection.id) - this._waitingConnections.delete(connection) - } - } - connection.on('warning', (warning: string) => { - this.sendEvent(new vscode.OutputEvent(warning + '\n')) - }) - connection.on('error', disposeConnection) - connection.on('close', disposeConnection) - await connection.waitForInitPacket() - - // override features from launch.json - try { - const xdebugSettings = args.xdebugSettings || {} - await Promise.all( - Object.keys(xdebugSettings).map(setting => - connection.sendFeatureSetCommand(setting, xdebugSettings[setting]) - ) - ) - } catch (error) { - throw new Error( - 'Error applying xdebugSettings: ' + (error instanceof Error ? error.message : error) - ) - } + } - this.sendEvent(new vscode.ThreadEvent('started', connection.id)) + /** sets up a TCP server to listen for Xdebug connections */ + private createServer(args: StartRequestArguments): Promise { + return new Promise((resolve, reject) => { + const server = (this._server = net.createServer()) + server.on('connection', async (socket: net.Socket) => { + try { + // new Xdebug connection + const connection = new xdebug.Connection(socket) + if (args.log) { + this.sendEvent(new vscode.OutputEvent('new connection ' + connection.id + '\n'), true) + } - // request breakpoints from VS Code - await this.sendEvent(new vscode.InitializedEvent()) - } catch (error) { - this.sendEvent( - new vscode.OutputEvent((error instanceof Error ? error.message : error) + '\n', 'stderr') - ) - this.shutdown() + // HANDLE NEW SESSION + if (this._fromExtension) { + PhpDebugSession._allConnections.set(connection.id, connection) + this.sendEvent(new NewDbgpConnectionEvent(connection.id)) + } else { + // handle as thread + this.processConnection(connection, args) } - }) - server.on('error', (error: Error) => { - this.sendEvent(new vscode.OutputEvent(util.inspect(error) + '\n')) - this.sendErrorResponse(response, error) - }) - server.listen(args.port || 9000, args.hostname, () => resolve()) + } catch (error) { + this.sendEvent( + new vscode.OutputEvent((error instanceof Error ? error.message : error) + '\n', 'stderr') + ) + this.shutdown() + } }) - try { - if (!args.noDebug) { - await createServer() + server.on('error', (error: Error) => { + this.sendEvent(new vscode.OutputEvent(util.inspect(error) + '\n')) + reject(error) + }) + server.on('listening', () => { + const port = (server.address() as net.AddressInfo).port + resolve(port) + }) + const listenPort = args.port === undefined ? 9000 : args.port + server.listen(listenPort, args.hostname) + }) + } + + private async processConnection(connection: xdebug.Connection, args: StartRequestArguments): Promise { + this._connections.set(connection.id, connection) + this._waitingConnections.add(connection) + const disposeConnection = (error?: Error) => { + if (this._connections.has(connection.id)) { + if (args.log) { + this.sendEvent(new vscode.OutputEvent('connection ' + connection.id + ' closed\n')) + } + if (error) { + this.sendEvent(new vscode.OutputEvent('connection ' + connection.id + ': ' + error.message + '\n')) + } + this.sendEvent(new vscode.ContinuedEvent(connection.id, false)) + this.sendEvent(new vscode.ThreadEvent('exited', connection.id)) + connection.close() + this._connections.delete(connection.id) + this._waitingConnections.delete(connection) } - if (args.program) { - await launchScript() + PhpDebugSession._allConnections.delete(connection.id) + if (this._fromExtension) { + this.sendEvent(new vscode.TerminatedEvent()) } + } + connection.on('warning', (warning: string) => { + this.sendEvent(new vscode.OutputEvent(warning + '\n')) + }) + connection.on('error', disposeConnection) + connection.on('close', disposeConnection) + await connection.waitForInitPacket() + + // override features from launch.json + try { + const xdebugSettings = args.xdebugSettings || {} + await Promise.all( + Object.keys(xdebugSettings).map(setting => + connection.sendFeatureSetCommand(setting, xdebugSettings[setting]) + ) + ) } catch (error) { - this.sendErrorResponse(response, error) - return + throw new Error('Error applying xdebugSettings: ' + (error instanceof Error ? error.message : error)) } - this.sendResponse(response) + + this.sendEvent(new vscode.ThreadEvent('started', connection.id)) + + // request breakpoints from VS Code + this.sendEvent(new vscode.InitializedEvent()) } /** @@ -1022,7 +1089,7 @@ class PhpDebugSession extends vscode.DebugSession { ) // If listening for connections, close server if (this._server) { - await new Promise(resolve => this._server.close(resolve)) + this._server.close() } // If launched as CLI, kill process if (this._phpProcess) { @@ -1068,6 +1135,10 @@ class PhpDebugSession extends vscode.DebugSession { this.sendErrorResponse(response, error) } } + + protected customRequest(command: string, response: VSCodeDebugProtocol.Response, args: any): void { + console.log('test', args) + } } vscode.DebugSession.run(PhpDebugSession) diff --git a/testproject/.vscode/launch.json b/testproject/.vscode/launch.json index 993d88da..6b31e12a 100644 --- a/testproject/.vscode/launch.json +++ b/testproject/.vscode/launch.json @@ -1,4 +1,7 @@ { + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { @@ -6,17 +9,22 @@ "name": "Listen for Xdebug", "type": "php", "request": "launch", - "port": 9000, + "port": 9003, "log": true }, { //"debugServer": 4711, // Uncomment for debugging the adapter - "name": "Launch", - "request": "launch", + "name": "Launch currently open script", "type": "php", + "request": "launch", "program": "${file}", - "cwd": "${workspaceRoot}", - "externalConsole": false + "cwd": "${fileDirname}", + "port": 0, + "runtimeArgs": ["-dxdebug.start_with_request=yes"], + "env": { + "XDEBUG_MODE": "debug,develop", + "XDEBUG_CONFIG": "client_port=${port}" + } } ] } diff --git a/tsconfig.json b/tsconfig.json index 54b90696..c65d129e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "include": ["src/**/*"], "compilerOptions": { - "target": "es6", + "target": "es2019", "module": "commonjs", "rootDir": "src", "outDir": "out",