Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consider adding a mechanism akin to .NET's 'DebuggerTypeProxyAttribute' for JS/TS debugging #102181

Closed
rbuckton opened this issue Jul 11, 2020 · 14 comments · Fixed by microsoft/vscode-js-debug#1775
Assignees
Labels
debug Debug viewlet, configurations, breakpoints, adapter issues feature-request Request for new features or functionality
Milestone

Comments

@rbuckton
Copy link
Member

rbuckton commented Jul 11, 2020

When debugging a program written for .NET in Visual Studio, there are certain attributes that can be used to control how the debugger interacts with various objects. The DebuggerTypeProxyAttribute provides a way to inform the debugger how to represent a value in the "Watch" view.

JS does not have a way to define such metadata for its types, and while the Decorators proposal is still under active development it would still likely only serve as half the story.

An alternative solution I am proposing is roughly this:

  1. A developer define a well-known Symbol to add as a key to a class (i.e., const debuggerProxySym = Symbol.for("vscode:debugger-type-proxy").
  2. A developer can add that symbol as a method of the class (i.e., class C { [debuggerProxySym]() { ... } }.
  3. In the launch.config, the well-known symbol identity could be added as a configuration option.
  4. When a value with the configured symbol is shown in the "Watch" or "Locals" window, that method is called and its result is used in place of the original value.

In this way, configuration is flexible and up to the end user and it doesn't depend on proposing new syntax to ECMAScript. In addition, should the Decorators proposal land as a feature in the future this could also leverage decorators (i.e., @DebuggerTypeProxy(() => { ... }) class C {}, where DebuggerTypeProxy merely sets the same kind of symbol, above).

Also, the default configuration could conceivably also include the existing inspect.custom symbol in NodeJS's "util" module.

A similar design could theoretically be employed to allow for behaviors similar to .NET's other DebuggerXAttribute controls, such as:

  • A debugger-display symbol to provide a simplified text view for a value in the "Locals" or "Watch" window, similar to .NET's DebuggerDisplayAttribute.
  • A debugger-visualizer symbol to further control how a value in "Locals" or "Watch" is rendered (though this is obviously more complex).
  • A debugger-hidden symbol to force the debugger to step over code inside of a function/method with this property (but still step into methods invoked by the function), similar to .NET's DebuggerHiddenAttribute.
  • As well as symbols for other, related attributes such as DebuggerNonUserCodeAttribute, DebuggerStepThroughAttribute, etc.

An example of how this would help can be seen in the TypeScript compiler itself. Since TypeScript enums are compiled down to number values, its often complex to correlate a flag value back to the enum value. When debugging, we explicitly add additional properties to internal classes like Node, Symbol, and Type to make our lives easier, but first-class support for this behavior would be much more appealing:

What we do today:
image

What would be nice to have in the future:

// debug.ts
namespace ts.Debug {
  export const debuggerDisplay = Symbol.for("vscode:debugger-display");
  export const debuggerTypeProxy = Symbol.for("vscode:debugger-type-proxy");
  export const debuggerHidden = Symbol.for("vscode:debugger-hidden");
  export const debuggerBrowsable = Symbol.for("vscode:debugger-browsable");
}
// core.ts
export function map<T, U>(array: T[] | undefined, cb: (v: T) => U): U[] | undefined { ... }
// F11 steps over calls to `map`, but still steps into `cb`...
map[Debug.debuggerHidden] = true;

// utilities.ts
function Symbol() { ... }
Symbol.prototype[Debug.debuggerDisplay] = "{escapedName}";

function Type() { ... }
Type.prototype[Debug.debuggerTypeProxy] = function() { 
  return { 
    ...this,
    [Debug.debuggerDisplay]: "{name} {flagsString}",
    [Debug.debuggerBrowsable]: { name: false, flagsString: false },
    name: this.symbol?.escapedName,
    flagsString: Debug.formatTypeFlags(this.flags)
  }
};
// launch.json
{
  // ...
  "configurations": [
    {
      // ...
      "debugSymbols": {
        "display": ["vscode:debugger-display", "nodejs.util.inspect.custom"], // the key for `require("util").inspect.custom` in NodeJS
        "typeProxy": ["vscode:debugger-type-proxy"],
        "hidden": ["vscode:debugger-hidden"],
        "browsable": ["vscode:debugger-browsable"]
      }
    }
  ]
}
@weinand weinand added debug Debug viewlet, configurations, breakpoints, adapter issues feature-request Request for new features or functionality labels Jul 11, 2020
@weinand weinand added this to the On Deck milestone Jul 11, 2020
@weinand
Copy link
Contributor

weinand commented Jul 11, 2020

@rbuckton in simpler terms you are proposing a mechanism for the JavaScript debugger to allow end users to provide custom toString() implementations for some types, correct?

@connor4312 this sounds like a worthwhile feature to have.

@rbuckton
Copy link
Member Author

Something like that, yes. It is more than just toString, however. DebuggerTypeProxy in .NET substitutes the value used in the Watch window with another one that could be used to provide more useful context when debugging. Debugger Hidden and DebuggerStepThrough control debugger stepping behavior like "Step Into" and "Step Over".

@connor4312
Copy link
Member

connor4312 commented Nov 2, 2020

Apologies for the delay in responding here.

I like this idea, and this is similar to some work that @digeff has been doing in js-debug. His work has been more around setting custom descriptors and properties in the launch.json, but this is less ideal for a number of reasons.

I didn't know Symbol.for was available. That makes implementation more streamlined and feels very natural with other modern features like iterators. Could have an optional npm package that exports those symbols, along with TS decorators and designed to avoid producing any code if running a production build with a bundler.

I'm not sure that I would want the symboles to be user-configurable, since the presence of a symbol method on objects entails a contract with js-debug that arbitrary symbols might not uphold. (And if the contract is not being broken, I'm not sure why a user would want a custom symbol).

I'll put this on the November iteration, for which feature development starts on the 16th (slightly delayed due to grooming from last month and vacations)

@justingrant
Copy link
Contributor

I'm part of the team that's working on the polyfill for the soon-to-be-Stage3 JS Temporal proposal and we'd definitely be interested in a a good way to show a text representation of Temporal objects in the VSCode debugger. Temporal objects have no own properties, only property getters on the prototype. The VSCode debug hover experience is awful today. Example:

image

@rbuckton in simpler terms you are proposing a mechanism for the JavaScript debugger to allow end users to provide custom toString() implementations for some types, correct?

Currently, VSCode doesn't seem to be calling toString() in debug hover, at least not for ES6 class types where it shows the name of the constructor function like in the screenshot above. Is this expected behavior or a bug?

FWIW, if all VSCode did was call toString(), that'd be a huge improvement for us-- we wouldn't even need any custom debug display if toString just worked. One quirk in Temporal's implementation is that .valueOf() throws to prevent users from comparisons like dateTimeA < dateTimeB which are hard or impossible to get right with some date/time data. Not sure if this is why toString() isn't working for us.

3. In the launch.config, the well-known symbol identity could be added as a configuration option.

I assume that there'd be a default configuration that OSS libraries could use, so that individual debugger users wouldn't have to opt into getting better debug display? The requirement for debugger users to opt in to get Chrome's object formatters is a pretty big adoption blocker.

@digeff
Copy link

digeff commented Nov 4, 2020

@justingrant I'm not sure if this launch.json parameter has been released yet, if it is:

customDescriptionGenerator:            'function (def) { if (this.toString) return this.toString(); else return def }' 

should call this.toString() to generate the description of objects shown in variables, watch, etc...

@connor4312
Copy link
Member

Thanks for the input, Justin. It sounds like you have a very similar use case that could be served in the same way.

I assume that there'd be a default configuration that OSS libraries could use, so that individual debugger users wouldn't have to opt into getting better debug display?

This is correct

@weinand
Copy link
Contributor

weinand commented Nov 9, 2020

@justingrant you said:

Currently, VSCode doesn't seem to be calling toString() in debug hover, at least not for ES6 class types where it shows the name of the constructor function like in the screenshot above. Is this expected behavior or a bug?

FWIW, if all VSCode did was call toString(), that'd be a huge improvement for us-- we wouldn't even need any custom debug display if toString just worked.

Just to be precise here: VS Code requests UI strings from a debug adapter via the Debug Adapter Protocol (DAP), it does not interact with programs or debuggers directly. So calling "toString()" must be done in the debug adapter. I do not see an opportunity for "generic support" in VS Code.

@connor4312 So this issue is a feature request for js-debug.

@FrozenKiwi
Copy link

FrozenKiwi commented Dec 17, 2020

customDescriptionGenerator: 'function (def) { if (this.toString) return this.toString(); else return def }'

Incredible: this works a treat for me (using decimal)

A slight edit to save peeps a few minutes in tweaking their output: add a test for [object Object] to disable formatting all your custom types away.

"customDescriptionGenerator": "function (def) { if (this.toString) { const _v = this.toString(); if (_v.indexOf(\"[object Object]\") < 0) return _v; } return def; }",

@connor4312 connor4312 modified the milestones: January 2021, Backlog Jan 26, 2021
@icetbr
Copy link

icetbr commented Apr 12, 2021

Can we have this for primitives as well? I want my milliseconds to display as dates, preferably with a custom format.

@hillin
Copy link

hillin commented Jun 6, 2022

customDescriptionGenerator is extremely useful. I wrote a helper function to better handle object properties and array elements display, please help yourself:

The __registerGlobalInspect function
/** Register a global __inspectObject function to inspect arbitrary value. */
export function __registerGlobalInspect() {
  if (window && !(window as any).__inspectObject) {
    const inspectSingleObject = (element: any) => {
      if (element) {
        if (typeof element === 'object') {
          if (element.__inspect && typeof element.__inspect === 'function') {
            return element.__inspect();
          }

          if (Array.isArray(element)) {
            return `[${element.length}]`;
          }

          const ctor = Object.getPrototypeOf(element).constructor;

          if (ctor === Set) {
            return `Set(${element.size})`;
          }

          if (ctor === Map) {
            return `Map(${element.size})`;
          }

          if (ctor === WeakSet) {
            return `WeakSet(${element.size})`;
          }

          if (ctor === WeakMap) {
            return `WeakMap(${element.size})`;
          }

          if (ctor === Object) {
            return '{…}';
          }

          return Object.getPrototypeOf(element).constructor.name;
        }

        if (typeof element === 'function') {
          return `<Function>`;
        }
      }

      return JSON.stringify(element);
    };

    const anyWindow = window as any;
    anyWindow.__inspectObject = function (target: any) {
      if (target.__inspect && typeof target.__inspect === 'function') {
        return target.__inspect();
      }

      const maxPropertyCount = 5;
      const elements = [];
      if (Array.isArray(target)) {
        for (let i = 0; i < Math.min(maxPropertyCount, target.length); ++i) {
          elements.push(inspectSingleObject(target[i]));
        }
        return `(${target.length}) [${elements.join(', ')}${
          target.length > maxPropertyCount ? '…' : ''
        }]`;
      }

      for (let key in target) {
        if (target.hasOwnProperty(key)) {
          if (elements.length >= maxPropertyCount) {
            break;
          }

          elements.push(`${key}: ${inspectSingleObject(target[key])}`);
        }
      }

      return `{${elements.join(', ')}}`;
    };
  }
}

Usage: call __registerGlobalInspect() at the entrance of your script. Implement __inspect(): string in any object that provides custom inspection. Config customDescriptionGenerator as follows:

"customDescriptionGenerator": "function (def) { return this.__inspect ? this.__inspect() : window?.__inspectObject ? window.__inspectObject(this) : def }"

This approach shows custom inspection text if an object has __inspect(): string method, otherwise falls back to a way which mimics vscode's native debug displays; but still use the __inspect() value for objects inside an array or nested in another object.

@Mrjen
Copy link

Mrjen commented Sep 7, 2022

@hillin __registerGlobalInspect write where to use this?

@connor4312
Copy link
Member

Btw, if an object has a custom (non-native) toString() method, that will now be called to create its description.

@hillin
Copy link

hillin commented Sep 8, 2022

@hillin __registerGlobalInspect write where to use this?

The entrance of your script, i.e. the first line of your script. If you are using a JS framework, call it in the initialization or constructor of the root component.

@connor4312
Copy link
Member

connor4312 commented Aug 3, 2023

Both Firefox and Chromium support essentially the same API for object formatters. However, formatting is done by creating a DOM structure, and Chromium notes that there may be a style with "any valid CSS key". From their demo:

var formatter = {
	header: function(x) {
		if (x === custom) {
			return ["span", {"style": "background-color: #fcc"}, "Hello!"];
		} else {
			return null;
		}
	},
	hasBody: function(x) {
		return x === custom;
	},
	body: function(x) {
		var selfRef = ["object", {"object": custom}];
		return ["ol", {}, ["li", {}, "world!"], ["li", {}, selfRef]];
	}
};
window.devtoolsFormatters = [formatter];

While Firefox restricts usable CSS properties, this is still something that is not approachable from the VS Code JavaScript debugger, because:

  • We talk over the generic DAP protocol which does not have advanced formatting and,
  • Even if such formatting were added, it could never encompass the scope of CSS properties, as not all DAP clients are DOM based (or would be eager to embed a browser engine for esoteric formatting cases)
  • We cannot really add a "custom extension" for the JS debugger, since such formatting can significantly change the way data is displayed, and the JS debugger is also used by other clients like neovim in its generic server mode.

And on a personal note, the way they're registered on a window global is kind of awkward 😛

So, I think I will adopt the symbol approach. I do not plan to prefix it with vscode: in hopes that others might adopt it. Node.js already uses a similar propery called nodejs.util.inspect.custom. Instead, let's call it debug.description. So a class that implements it could look like:

class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  [Symbol.for('debug.description')]() {
    return `${this.start} -> ${this.end}`;
  }
}

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
debug Debug viewlet, configurations, breakpoints, adapter issues feature-request Request for new features or functionality
Projects
None yet
9 participants