From a4ce09d30938e3cb58179b6ada08251e427bb8ed Mon Sep 17 00:00:00 2001 From: Dave Holoway Date: Fri, 24 Apr 2020 14:37:51 +0100 Subject: [PATCH] Expression format specifier support (#87) * initial support for format specifier * add readme notes for format specifiers * add support for showing arrays and objects with format specifiers * create unique object variable references for different display formats * add notes on applying formatting to objects and arrays --- README.md | 33 +++++++ src/debugMain.js | 48 +++++----- src/expression/evaluate.js | 46 ++++++---- src/stack-frame.js | 4 +- src/variable-manager.js | 174 ++++++++++++++++++++++++++----------- 5 files changed, 219 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 6be4f1d..ed75dd9 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,39 @@ Add a new task to run the build command: } ``` +## Expression evaluation + +Format specifiers can be appended to watch and repl expressions to change how the evaluated result is displayed. +The specifiers work with the same syntax used in Visual Studio. +See https://docs.microsoft.com/en-us/visualstudio/debugger/format-specifiers-in-cpp for examples. + +``` +123 123 +123,x 0x0000007b +123,xb 0000007b +123,X 0x0000007B +123,o 000000000173 +123,b 0b00000000000000000000000001111011 +123,bb 00000000000000000000000001111011 +123,c '{' +"one\ntwo" "one\ntwo" +"one\ntwo",sb one\ntwo +"one\ntwo",! one + two +``` + +You can also apply the specifiers to object and array instances to format fields and elements: +``` +arr,x int[3] + [0] 0x00000001 + [1] 0x00000002 + [1] 0x00000003 +``` + + +Note: Format specifiers for floating point values (`e`/`g`) and string encoding conversions (`s8`/`su`/`s32`) are not supported. + + ## Powered by coffee The Android Developer Extension is a completely free, fully open-source project. If you've found the extension useful, you diff --git a/src/debugMain.js b/src/debugMain.js index 3688c61..1cd391d 100644 --- a/src/debugMain.js +++ b/src/debugMain.js @@ -79,7 +79,10 @@ class AndroidDebugSession extends DebugSession { // number of call stack entries to display above the project source this.callStackDisplaySize = 1; - // the fifo queue of evaluations (watches, hover, etc) + /** + * the fifo queue of evaluations (watches, hover, etc) + * @type {EvalQueueEntry[]} + */ this._evals_queue = []; // since we want to send breakpoint events, we will assign an id to every event @@ -1269,7 +1272,7 @@ class AndroidDebugSession extends DebugSession { const stack_frame = thread.findStackFrame(args.variablesReference); // evaluate the expression const locals = await stack_frame.getLocals(); - const value = await evaluate(args.value, thread, locals, this.dbgr); + const { value } = await evaluate(args.value, thread, locals, this.dbgr); // update the variable const vsvar = await stack_frame.setVariableValue(args.variablesReference, args.name, value); response.body = { @@ -1307,25 +1310,15 @@ class AndroidDebugSession extends DebugSession { this.sendResponse(prev.response); } - const eval_info = { - expression: args.expression, - response, - /** @type {DebuggerValue[]} */ - locals: null, - /** @type {VariableManager} */ - var_manager: null, - /** @type {AndroidThread} */ - thread: null, - } + let eval_info; if (args.frameId) { const threadId = AndroidThread.variableRefToThreadId(args.frameId); const thread = this.getThread(threadId); if (!thread) return this.failRequestNoThread('Evaluate',threadId, response); if (!thread.paused) return this.failRequestThreadNotSuspended('Evaluate',threadId, response); - eval_info.thread = thread; const stack_frame = thread.findStackFrame(args.frameId); - eval_info.var_manager = stack_frame; - eval_info.locals = await stack_frame.getLocals(); + const locals = await stack_frame.getLocals(); + eval_info = new EvalQueueEntry(args.expression, response, locals, stack_frame, thread); } else { // if there's no frameId, we are being asked to evaluate the value in the 'global' context. // This is a problem because there's no associated stack frame, so we include any locals in the evaluation. @@ -1334,9 +1327,7 @@ class AndroidDebugSession extends DebugSession { // would require primitive literals) const thread = this._threads.find(t => t && t.paused); if (!thread) return this.failRequest(`No threads are paused`, response); - eval_info.thread = thread; - eval_info.var_manager = thread.getGlobalVariableManager(); - eval_info.locals = []; + eval_info = new EvalQueueEntry(args.expression, response, [], thread.getGlobalVariableManager(), thread); } const queue_len = this._evals_queue.push(eval_info); @@ -1347,8 +1338,8 @@ class AndroidDebugSession extends DebugSession { while (this._evals_queue.length > 0) { const { expression, response, locals, var_manager, thread } = this._evals_queue[0]; try { - const value = await evaluate(expression, thread, locals, this.dbgr); - const v = var_manager.makeVariableValue(value); + const { value, display_format } = await evaluate(expression, thread, locals, this.dbgr, { allowFormatSpecifier:true }); + const v = var_manager.makeVariableValue(value, display_format); response.body = { result: v.value, variablesReference: v.variablesReference|0 @@ -1363,6 +1354,23 @@ class AndroidDebugSession extends DebugSession { } } +class EvalQueueEntry { + /** + * @param {string} expression + * @param {import('vscode-debugprotocol').DebugProtocol.EvaluateResponse} response + * @param {DebuggerValue[]} locals + * @param {VariableManager} var_manager + * @param {AndroidThread} thread + */ + constructor(expression, response, locals, var_manager, thread) { + this.expression = expression; + this.response = response; + this.locals = locals; + this.var_manager = var_manager; + this.thread = thread; + } +} + /** * @param {string} p */ diff --git a/src/expression/evaluate.js b/src/expression/evaluate.js index f47d52f..9002552 100644 --- a/src/expression/evaluate.js +++ b/src/expression/evaluate.js @@ -471,7 +471,7 @@ async function evaluate_binary_expression(dbgr, locals, thread, lhs, rhs, operat * @param {DebuggerValue[]} locals * @param {AndroidThread} thread * @param {string} operator - * @param {*} expr + * @param {ParsedExpression} expr */ async function evaluate_unary_expression(dbgr, locals, thread, operator, expr) { /** @type {DebuggerValue} */ @@ -684,7 +684,7 @@ function evaluate_expression(dbgr, locals, thread, expr) { * @param {Debugger} dbgr * @param {DebuggerValue[]} locals * @param {AndroidThread} thread - * @param {string} index_expr + * @param {ParsedExpression} index_expr * @param {DebuggerValue} arr_local */ async function evaluate_array_element(dbgr, locals, thread, index_expr, arr_local) { @@ -712,36 +712,36 @@ async function evaluate_array_element(dbgr, locals, thread, index_expr, arr_loca /** * Build a regular expression which matches the possible parameter types for a value * @param {Debugger} dbgr - * @param {DebuggerValue} v + * @param {DebuggerValue} argument */ -async function getParameterSignatureRegex(dbgr, v) { - if (v.type.signature == 'Lnull;') { +async function getParameterSignatureRegex(dbgr, argument) { + if (argument.type.signature == 'Lnull;') { return /^[LT[]/; // null matches any reference type } - if (/^L/.test(v.type.signature)) { + if (/^L/.test(argument.type.signature)) { // for class reference types, retrieve a list of inherited classes // since subclass instances can be passed as arguments - const sigs = await dbgr.getClassInheritanceList(v.type.signature); + const sigs = await dbgr.getClassInheritanceList(argument.type.signature); const re_sigs = sigs.map(signature => signature.replace(/[$]/g, '\\$')); return new RegExp(`(^${re_sigs.join('$)|(^')}$)`); } - if (/^\[/.test(v.type.signature)) { + if (/^\[/.test(argument.type.signature)) { // for array types, only an exact array match or Object is allowed - return new RegExp(`^(${v.type.signature})|(${JavaType.Object.signature})$`); + return new RegExp(`^(${argument.type.signature})|(${JavaType.Object.signature})$`); } - switch(v.type.signature) { + switch(argument.type.signature) { case 'I': // match bytes/shorts/ints/longs/floats/doubles literals within range - if (v.value >= -128 && v.value <= 127) + if (argument.value >= -128 && argument.value <= 127) return /^[BSIJFD]$/ - if (v.value >= -32768 && v.value <= 32767) + if (argument.value >= -32768 && argument.value <= 32767) return /^[SIJFD]$/ return /^[IJFD]$/; case 'F': return /^[FD]$/; // floats can be assigned to floats or doubles default: // anything else must be an exact match (no implicit cast is valid) - return new RegExp(`^${v.type.signature}$`); + return new RegExp(`^${argument.type.signature}$`); } } @@ -950,8 +950,9 @@ async function evaluate_cast(dbgr, locals, thread, cast_type, rhs) { * @param {AndroidThread} thread * @param {DebuggerValue[]} locals * @param {Debugger} dbgr + * @param {{allowFormatSpecifier:boolean}} [options] */ -async function evaluate(expression, thread, locals, dbgr) { +async function evaluate(expression, thread, locals, dbgr, options) { D('evaluate: ' + expression); await dbgr.ensureConnected(); @@ -967,6 +968,17 @@ async function evaluate(expression, thread, locals, dbgr) { } const parsed_expression = parse_expression(e); + let display_format = null; + if (options && options.allowFormatSpecifier) { + // look for formatting specifiers in the form of ',' + // ref: https://docs.microsoft.com/en-us/visualstudio/debugger/format-specifiers-in-cpp + const df_match = e.expr.match(/^,([doc!]|[xX]b?|bb?|sb?)/); + if (df_match) { + display_format = df_match[1]; + e.expr = e.expr.slice(df_match[0].length) + } + } + // if there's anything left, it's an error if (!parsed_expression || e.expr) { // the expression is not well-formed @@ -975,7 +987,11 @@ async function evaluate(expression, thread, locals, dbgr) { // the expression is well-formed - start the (asynchronous) evaluation const value = await evaluate_expression(dbgr, locals, thread, parsed_expression); - return value; + + return { + value, + display_format, + } } module.exports = { diff --git a/src/stack-frame.js b/src/stack-frame.js index f5294e3..aaac662 100644 --- a/src/stack-frame.js +++ b/src/stack-frame.js @@ -53,7 +53,7 @@ class DebuggerStackFrame extends VariableManager { } const fetch_locals = async () => { const values = await this.dbgr.getLocals(this.frame); - // display the variables in (case-insensitive) alphabetical order, with 'this' first and all-caps last + // display the variables in (case-insensitive) alphabetical order, with 'this' first return this.locals = sortVariables(values, true, false); } // @ts-ignore @@ -133,7 +133,7 @@ class DebuggerStackFrame extends VariableManager { values = [await this.getBigString(varinfo)]; } - return (varinfo.cached = values).map(v => this.makeVariableValue(v)); + return (varinfo.cached = values).map(v => this.makeVariableValue(v, varinfo.display_format)); } async getObjectFields(varinfo) { diff --git a/src/variable-manager.js b/src/variable-manager.js index eef2386..872c9a1 100644 --- a/src/variable-manager.js +++ b/src/variable-manager.js @@ -12,8 +12,6 @@ class VariableManager { * @param {VSCVariableReference} base_variable_reference The reference value for values stored by this manager */ constructor(base_variable_reference) { - // expandable variables get allocated new variable references. - this._expandable_prims = false; /** @type {VSCVariableReference} */ this.nextVariableRef = base_variable_reference + 10; @@ -40,10 +38,20 @@ class VariableManager { this.variableValues.set(variablesReference, value); } - _getObjectIdReference(type, objvalue) { + /** + * Retrieve or create a variable reference for a given object instance + * @param {JavaType} type + * @param {JavaObjectID} instance_id + * @param {string} display_format + */ + _getObjectIdReference(type, instance_id, display_format) { // we need the type signature because we must have different id's for - // an instance and it's supertype instance (which obviously have the same objvalue) - const key = type.signature + objvalue; + // an instance and it's supertype instance (which obviously have the same instance_id) + // + // display_format is also included to give unique variable references for each display type. + // This is because VSCode caches expanded values, so once evaluated in one format, they can + // never be changed. + const key = `${type.signature}:${instance_id}:${display_format || ''}`; let value = this.objIdCache.get(key); if (!value) { this.objIdCache.set(key, value = this.nextVariableRef += 1); @@ -54,12 +62,12 @@ class VariableManager { /** * Convert to a VariableValue object used by VSCode * @param {DebuggerValue} v + * @param {string} [display_format] */ - makeVariableValue(v) { + makeVariableValue(v, display_format) { let varref = 0; let value = ''; const evaluateName = v.fqname || v.name; - const formats = {}; const full_typename = v.type.fullyQualifiedName(); switch(true) { case v.hasnullvalue && JavaType.isReference(v.type): @@ -74,93 +82,161 @@ class VariableManager { value = v.type.typename; break; case v.type.signature === JavaType.String.signature: - value = JSON.stringify(v.string); if (v.biglen) { // since this is a big string - make it viewable on expand varref = this._addVariable({ bigstring: v, }); value = `String (length:${v.biglen})`; - } - else if (this._expandable_prims) { - // as a courtesy, allow strings to be expanded to see their length - varref = this._addVariable({ - signature: v.type.signature, - primitive: true, - value: v.string.length - }); + } else { + value = formatString(v.string, display_format); } break; case JavaType.isArray(v.type): // non-null array type - if it's not zero-length add another variable reference so the user can expand if (v.arraylen) { - varref = this._getObjectIdReference(v.type, v.value); + varref = this._getObjectIdReference(v.type, v.value, display_format); this._setVariable(varref, { varref, arrvar: v, range:[0, v.arraylen], + display_format, }); } value = v.type.typename.replace(/]/, v.arraylen+']'); // insert len as the first array bound break; case JavaType.isClass(v.type): // non-null object instance - add another variable reference so the user can expand - varref = this._getObjectIdReference(v.type, v.value); + varref = this._getObjectIdReference(v.type, v.value, display_format); this._setVariable(varref, { varref, objvar: v, + display_format, }); value = v.type.typename; break; case v.type.signature === JavaType.char.signature: // character types have a integer value - const char = String.fromCodePoint(v.value); - const cmap = {'\b':'b','\f':'f','\r':'r','\n':'n','\t':'t','\v':'v','\'':'\'','\\':'\\','\0':'0'}; - if (cmap[char]) { - value = `'\\${cmap[char]}'`; - } else if (v.value < 32) { - value = `'\\u${v.value.toString(16).padStart(4,'0')}'`; - } else value = `'${char}'`; + value = formatChar(v.value, display_format); break; case v.type.signature === JavaType.long.signature: // because JS cannot handle 64bit ints, we need a bit of extra work const v64hex = v.value.replace(/[^0-9a-fA-F]/g,''); - value = formats.dec = NumberBaseConverter.hexToDec(v64hex, true); - formats.hex = '0x' + v64hex.replace(/^0+/, '0'); - formats.oct = formats.bin = ''; - // 24 bit chunks... - for (let s = v64hex; s; s = s.slice(0,-6)) { - const uint = parseInt(s.slice(-6), 16) >>> 0; // 6*4 = 24 bits - formats.oct = uint.toString(8) + formats.oct; - formats.bin = uint.toString(2) + formats.bin; - } - formats.oct = '0c' + formats.oct.replace(/^0+/, '0'); - formats.bin = '0b' + formats.bin.replace(/^0+/, '0'); + value = formatLong(v64hex, display_format); break; case JavaType.isInteger(v.type): - value = formats.dec = v.value.toString(); - const uint = (v.value >>> 0); - formats.hex = '0x' + uint.toString(16); - formats.oct = '0c' + uint.toString(8); - formats.bin = '0b' + uint.toString(2); + value = formatInteger(v.value, v.type.signature, display_format); break; default: // other primitives: boolean, etc value = v.value.toString(); break; } - // as a courtesy, allow integer and character values to be expanded to show the value in alternate bases - if (this._expandable_prims && /^[IJBSC]$/.test(v.type.signature)) { - varref = this._addVariable({ - signature: v.type.signature, - primitive: true, - value: v.value, - }); - } + return new VariableValue(v.name, value, full_typename, varref, evaluateName); } } +const cmap = { + '\b':'b','\f':'f','\r':'r','\n':'n','\t':'t', + '\v':'v','\'':'\'','\\':'\\','\0':'0' +}; + +function makeJavaChar(i) { + let value; + const char = String.fromCodePoint(i); + if (cmap[char]) { + value = `'\\${cmap[char]}'`; + } else if (i < 32) { + value = `'\\u${i.toString(16).padStart(4,'0')}'`; + } else value = `'${char}'`; + return value; +} + +/** + * @param {number} c + * @param {string} df + */ +function formatChar(c, df) { + if (/[xX]b|o|bb|d/.test(df)) { + return formatInteger(c, 'C', df); + } + return makeJavaChar(c); + +} + +/** + * + * @param {string} s + * @param {string} display_format + */ +function formatString(s, display_format) { + if (display_format === '!') { + return s; + } + let value = JSON.stringify(s); + if (display_format === 'sb') { + // remove quotes + value = value.slice(1,-1); + } + return value; +} + +/** + * @param {hex64} hex64 + * @param {string} df + */ +function formatLong(hex64, df) { + let minlength; + if (/[xX]b?/.test(df)) { + minlength = Math.ceil(64 / 4); + let s = `${df[1]?'':'0x'}${hex64.padStart(minlength,'0')}`; + return df[0] === 'x' ? s.toLowerCase() : s.toUpperCase(); + } + if (/o/.test(df)) { + minlength = Math.ceil(64 / 3); + return `${df[1]?'':'0'}${NumberBaseConverter.convertBase(hex64,16,8).padStart(minlength, '0')}`; + } + if (/bb?/.test(df)) { + minlength = 64; + return `${df[1]?'':'0b'}${NumberBaseConverter.convertBase(hex64,16,2).padStart(minlength, '0')}`; + } + if (/c/.test(df)) { + return makeJavaChar(parseInt(hex64.slice(-4), 16)); + } + return NumberBaseConverter.convertBase(hex64, 16, 10); +} + +/** + * @param {number} i + * @param {string} signature + * @param {string} df + */ +function formatInteger(i, signature, df) { + const bits = { B:8,S:16,I:32,C:16 }[signature]; + let u = (i & (-1 >>> (32 - bits))) >>> 0; + let minlength; + if (/[xX]b?/.test(df)) { + minlength = Math.ceil(bits / 4); + let s = u.toString(16).padStart(minlength,'0'); + s = df[0] === 'x' ? s.toLowerCase() : s.toUpperCase(); + return `${df[1]?'':'0x'}${s}`; + } + if (/o/.test(df)) { + minlength = Math.ceil(bits / 3); + return `${df[1]?'':'0'}${u.toString(8).padStart(minlength, '0')}`; + } + if (/bb?/.test(df)) { + minlength = bits; + return `${df[1]?'':'0b'}${u.toString(2).padStart(minlength, '0')}`; + } + if (/c/.test(df)) { + minlength = bits; + return makeJavaChar(u & 0xffff); + } + return i.toString(); +} + module.exports = { VariableManager, }