diff --git a/package-lock.json b/package-lock.json index fe91b753..ad88af7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -331,6 +331,7 @@ "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", "dev": true, + "optional": true, "requires": { "kind-of": "^3.0.2", "longest": "^1.0.1", @@ -342,6 +343,7 @@ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", "dev": true, + "optional": true, "requires": { "is-buffer": "^1.1.5" } @@ -1447,8 +1449,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "eslint": { "version": "4.19.1", @@ -2955,7 +2956,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "dev": true + "dev": true, + "optional": true }, "loud-rejection": { "version": "1.6.0", @@ -6525,6 +6527,7 @@ "version": "0.1.4", "bundled": true, "dev": true, + "optional": true, "requires": { "kind-of": "^3.0.2", "longest": "^1.0.1", @@ -7335,7 +7338,8 @@ "longest": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "loose-envify": { "version": "1.3.1", @@ -8083,8 +8087,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-copy": { "version": "0.1.0", @@ -9294,6 +9297,15 @@ "readable-stream": "^2.0.2" } }, + "string-replace-async": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/string-replace-async/-/string-replace-async-1.2.1.tgz", + "integrity": "sha1-1SzcfjOBQbvq6jRx3jEhUCjJo6o=", + "requires": { + "escape-string-regexp": "^1.0.4", + "object-assign": "^4.0.1" + } + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", diff --git a/package.json b/package.json index 7d622b6c..d2b96063 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "iconv-lite": "^0.4.15", "minimatch": "^3.0.3", "moment": "^2.17.1", + "string-replace-async": "^1.2.1", "url-relative": "^1.0.0", "urlencode": "^1.1.0", "vscode-debugadapter": "^1.11.0", diff --git a/src/logpoint.ts b/src/logpoint.ts new file mode 100644 index 00000000..b1f592e5 --- /dev/null +++ b/src/logpoint.ts @@ -0,0 +1,39 @@ +import stringReplaceAsync = require('string-replace-async') + +export class LogPointManager { + private _logpoints = new Map>() + + public addLogPoint(fileUri: string, lineNumber: number, logMessage: string) { + if (!this._logpoints.has(fileUri)) { + this._logpoints.set(fileUri, new Map()) + } + this._logpoints.get(fileUri)!.set(lineNumber, logMessage) + } + + public clearFromFile(fileUri: string) { + if (this._logpoints.has(fileUri)) { + this._logpoints.get(fileUri)!.clear() + } + } + + public hasLogPoint(fileUri: string, lineNumber: number): boolean { + return this._logpoints.has(fileUri) && this._logpoints.get(fileUri)!.has(lineNumber) + } + + public async resolveExpressions( + fileUri: string, + lineNumber: number, + callback: (expr: string) => Promise + ): Promise { + if (!this.hasLogPoint(fileUri, lineNumber)) { + return Promise.reject('Logpoint not found') + } + const expressionRegex = /\{(.*?)\}/gm + return await stringReplaceAsync(this._logpoints.get(fileUri)!.get(lineNumber)!, expressionRegex, function( + _: string, + group: string + ) { + return group.length === 0 ? Promise.resolve('') : callback(group) + }) + } +} diff --git a/src/phpDebug.ts b/src/phpDebug.ts index 76e84692..9d862073 100644 --- a/src/phpDebug.ts +++ b/src/phpDebug.ts @@ -10,6 +10,7 @@ import * as util from 'util' import * as fs from 'fs' import { Terminal } from './terminal' import { isSameUri, convertClientPathToDebugger, convertDebuggerPathToClient } from './paths' +import { LogPointManager } from './logpoint' import minimatch = require('minimatch') if (process.env['VSCODE_NLS_CONFIG']) { @@ -104,6 +105,13 @@ class PhpDebugSession extends vscode.DebugSession { */ private _connections = new Map() + /** + * The manager for logpoints. Since xdebug does not support anything like logpoints, + * it has to be managed by the extension/debug server. It does that by a Map referencing + * the log messages per file. XDebug sees it as a regular breakpoint. + */ + private _logPointManager = new LogPointManager() + /** A set of connections which are not yet running and are waiting for configurationDoneRequest */ private _waitingConnections = new Set() @@ -156,6 +164,7 @@ class PhpDebugSession extends vscode.DebugSession { supportsEvaluateForHovers: false, supportsConditionalBreakpoints: true, supportsFunctionBreakpoints: true, + supportsLogPoints: true, exceptionBreakpointFilters: [ { filter: 'Notice', @@ -366,6 +375,21 @@ class PhpDebugSession extends vscode.DebugSession { exceptionText = response.exception.name + ': ' + response.exception.message // this seems to be ignored currently by VS Code } else if (this._args.stopOnEntry) { stoppedEventReason = 'entry' + } else if (this._logPointManager.hasLogPoint(response.fileUri, response.line)) { + const logMessage = await this._logPointManager.resolveExpressions( + response.fileUri, + response.line, + async (expr: string): Promise => { + const evaluated = await connection.sendEvalCommand(expr) + return formatPropertyValue(evaluated.result) + } + ) + + this.sendEvent(new vscode.OutputEvent(logMessage + '\n', 'console')) + + const responseCommand = await connection.sendRunCommand() + await this._checkStatus(responseCommand) + return } else if (response.command.indexOf('step') === 0) { stoppedEventReason = 'step' } else { @@ -448,6 +472,7 @@ class PhpDebugSession extends vscode.DebugSession { response.body = { breakpoints: [] } // this is returned to VS Code let vscodeBreakpoints: VSCodeDebugProtocol.Breakpoint[] + this._logPointManager.clearFromFile(fileUri) if (connections.length === 0) { // if there are no connections yet, we cannot verify any breakpoint vscodeBreakpoints = args.breakpoints!.map(breakpoint => ({ verified: false, line: breakpoint.line })) @@ -458,6 +483,9 @@ class PhpDebugSession extends vscode.DebugSession { if (breakpoint.condition) { return new xdebug.ConditionalBreakpoint(breakpoint.condition, fileUri, breakpoint.line) } else { + if (breakpoint.logMessage !== undefined) { + this._logPointManager.addLogPoint(fileUri, breakpoint.line, breakpoint.logMessage) + } return new xdebug.LineBreakpoint(fileUri, breakpoint.line) } }) diff --git a/src/test/logpoint.ts b/src/test/logpoint.ts new file mode 100644 index 00000000..7be746b4 --- /dev/null +++ b/src/test/logpoint.ts @@ -0,0 +1,98 @@ +import { LogPointManager } from '../logpoint' +import * as assert from 'assert' + +describe('logpoint', () => { + const FILE_URI1 = 'file://my/file1' + const FILE_URI2 = 'file://my/file2' + const FILE_URI3 = 'file://my/file3' + + const LOG_MESSAGE_VAR = '{$variable1}' + const LOG_MESSAGE_MULTIPLE = '{$variable1} {$variable3} {$variable2}' + const LOG_MESSAGE_TEXT_AND_VAR = 'This is my {$variable1}' + const LOG_MESSAGE_TEXT_AND_MULTIVAR = 'Those variables: {$variable1} ${$variable2} should be replaced' + const LOG_MESSAGE_REPEATED_VAR = 'This {$variable1} and {$variable1} should be equal' + const LOG_MESSAGE_BADLY_FORMATED_VAR = 'Only {$variable1} should be resolved and not }$variable1 and $variable1{}' + + const REPLACE_FUNCTION = (str: string): Promise => { + return Promise.resolve(`${str}_value`) + } + + let logPointManager: LogPointManager + + beforeEach('create new instance', () => (logPointManager = new LogPointManager())) + + describe('basic map management', () => { + it('should contain added logpoints', () => { + logPointManager.addLogPoint(FILE_URI1, 10, LOG_MESSAGE_VAR) + logPointManager.addLogPoint(FILE_URI1, 11, LOG_MESSAGE_VAR) + logPointManager.addLogPoint(FILE_URI2, 12, LOG_MESSAGE_VAR) + logPointManager.addLogPoint(FILE_URI3, 13, LOG_MESSAGE_VAR) + + assert.equal(logPointManager.hasLogPoint(FILE_URI1, 10), true) + assert.equal(logPointManager.hasLogPoint(FILE_URI1, 11), true) + assert.equal(logPointManager.hasLogPoint(FILE_URI2, 12), true) + assert.equal(logPointManager.hasLogPoint(FILE_URI3, 13), true) + + assert.equal(logPointManager.hasLogPoint(FILE_URI1, 12), false) + assert.equal(logPointManager.hasLogPoint(FILE_URI2, 13), false) + assert.equal(logPointManager.hasLogPoint(FILE_URI3, 10), false) + }) + + it('should add and clear entries', () => { + logPointManager.addLogPoint(FILE_URI1, 10, LOG_MESSAGE_VAR) + logPointManager.addLogPoint(FILE_URI1, 11, LOG_MESSAGE_VAR) + logPointManager.addLogPoint(FILE_URI2, 12, LOG_MESSAGE_VAR) + logPointManager.addLogPoint(FILE_URI3, 13, LOG_MESSAGE_VAR) + + assert.equal(logPointManager.hasLogPoint(FILE_URI1, 10), true) + assert.equal(logPointManager.hasLogPoint(FILE_URI1, 11), true) + assert.equal(logPointManager.hasLogPoint(FILE_URI2, 12), true) + assert.equal(logPointManager.hasLogPoint(FILE_URI3, 13), true) + + logPointManager.clearFromFile(FILE_URI1) + + assert.equal(logPointManager.hasLogPoint(FILE_URI1, 10), false) + assert.equal(logPointManager.hasLogPoint(FILE_URI1, 11), false) + assert.equal(logPointManager.hasLogPoint(FILE_URI2, 12), true) + assert.equal(logPointManager.hasLogPoint(FILE_URI3, 13), true) + }) + }) + + describe('variable resolution', () => { + it('should resolve variables', async () => { + logPointManager.addLogPoint(FILE_URI1, 10, LOG_MESSAGE_VAR) + const result = await logPointManager.resolveExpressions(FILE_URI1, 10, REPLACE_FUNCTION) + assert.equal(result, '$variable1_value') + }) + + it('should resolve multiple variables', async () => { + logPointManager.addLogPoint(FILE_URI1, 10, LOG_MESSAGE_MULTIPLE) + const result = await logPointManager.resolveExpressions(FILE_URI1, 10, REPLACE_FUNCTION) + assert.equal(result, '$variable1_value $variable3_value $variable2_value') + }) + + it('should resolve variables with text', async () => { + logPointManager.addLogPoint(FILE_URI1, 10, LOG_MESSAGE_TEXT_AND_VAR) + const result = await logPointManager.resolveExpressions(FILE_URI1, 10, REPLACE_FUNCTION) + assert.equal(result, 'This is my $variable1_value') + }) + + it('should resolve multiple variables with text', async () => { + logPointManager.addLogPoint(FILE_URI1, 10, LOG_MESSAGE_TEXT_AND_MULTIVAR) + const result = await logPointManager.resolveExpressions(FILE_URI1, 10, REPLACE_FUNCTION) + assert.equal(result, 'Those variables: $variable1_value $$variable2_value should be replaced') + }) + + it('should resolve repeated variables', async () => { + logPointManager.addLogPoint(FILE_URI1, 10, LOG_MESSAGE_REPEATED_VAR) + const result = await logPointManager.resolveExpressions(FILE_URI1, 10, REPLACE_FUNCTION) + assert.equal(result, 'This $variable1_value and $variable1_value should be equal') + }) + + it('should resolve repeated bad formated messages correctly', async () => { + logPointManager.addLogPoint(FILE_URI1, 10, LOG_MESSAGE_BADLY_FORMATED_VAR) + const result = await logPointManager.resolveExpressions(FILE_URI1, 10, REPLACE_FUNCTION) + assert.equal(result, 'Only $variable1_value should be resolved and not }$variable1 and $variable1') + }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 5afb649b..ddbef08c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,8 @@ { "exclude": ["node_modules", "out"], "compilerOptions": { + "baseUrl": ".", + "paths": { "*": ["types/*"] }, "target": "es6", "module": "commonjs", "rootDir": "src", diff --git a/types/string-replace-async.d.ts b/types/string-replace-async.d.ts new file mode 100644 index 00000000..9d7cdc99 --- /dev/null +++ b/types/string-replace-async.d.ts @@ -0,0 +1,9 @@ +export = index +declare function index( + str: string, + re: RegExp | string, + replacer: (match: string, ...args: any[]) => Promise +): string +declare namespace index { + function seq(str: string, re: RegExp | string, replacer: (match: string, ...args: any[]) => Promise): string +}