Minor refactoring of @hediet/debug-visualizer-data-extraction's API. Improved documentation.

This commit is contained in:
Henning Dieterichs
2020-02-10 16:00:17 +01:00
parent a0597d428b
commit 6614198c2a
19 changed files with 400 additions and 123 deletions

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Henning Dieterichs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

126
data-extraction/README.md Normal file
View File

@@ -0,0 +1,126 @@
# @hediet/debug-visualizer-data-extraction
[![](https://img.shields.io/twitter/follow/hediet_dev.svg?style=social)](https://twitter.com/intent/follow?screen_name=hediet_dev)
A library that helps implementing data extractors for the Debug Visualizer VS Code extension.
# Installation
Use the following command to install the library using yarn:
```
yarn add @hediet/debug-visualizer-data-extraction
```
# Usage
## `createGraph` / `createGraphFromPointers` Helper
```ts
import {
registerDefaultDataExtractors,
createGraphFromPointers,
} from "@hediet/debug-visualizer-data-extraction";
registerDefaultDataExtractors();
setTimeout(() => {
new Main().run();
}, 0);
class Main {
run() {
const list = new DoublyLinkedList("1");
list.setNext(new DoublyLinkedList("2"));
list.next!.setNext(new DoublyLinkedList("3"));
list.next!.next!.setNext(new DoublyLinkedList("4"));
// Watch `visualize()` with the Debug Visualizer Extension for VS Code!
const visualize = () =>
// Returns `CommonDataTypes.Graph` data which can be visualized by
// either the vis.js or the graphviz visualizer.
createGraphFromPointers({ list, last, cur }, i => ({
id: i.id,
label: i.name,
color: finished.has(i) ? "lime" : undefined,
edges: [
{ to: i.next!, label: "next" },
{ to: i.prev!, label: "prev", color: "lightgray" },
].filter(r => !!r.to),
}));
const finished = new Set();
var cur: DoublyLinkedList | undefined = list;
// Reverses `list`. Finished nodes have correct pointers,
// their next node is also finished.
var last: DoublyLinkedList | undefined = undefined;
while (cur) {
cur.prev = cur.next;
cur.next = last;
finished.add(cur);
last = cur;
cur = cur.prev;
}
console.log("finished");
}
}
let id = 0;
class DoublyLinkedList {
public readonly id = (id++).toString();
constructor(public name: string) {}
next: DoublyLinkedList | undefined;
prev: DoublyLinkedList | undefined;
public setNext(val: DoublyLinkedList): void {
val.prev = this;
this.next = val;
}
}
```
![](../docs/doubly-linked-list-reverse-demo.gif)
## Registering Custom Data Extractors
```ts
import {
registerDefaultDataExtractors,
getDataExtractorApi,
} from "@hediet/debug-visualizer-data-extraction";
// The default data extractors are usually also registered by the VS Code extension.
// Registered them manually ensures that they are up to date.
registerDefaultDataExtractors();
getDataExtractorApi().registerExtractor({
id: "my-foo-extractor",
getExtractions: (data, collector) => {
if (data instanceof Foo) {
collector.addExtraction({
id: "my-foo-extraction",
name: "My Foo Extraction",
priority: 2000,
extractData: () => ({ kind: { text: true }, text: "Foo" }),
});
}
},
});
setTimeout(() => {
new Main().run();
}, 0);
class Foo {}
class Main {
run() {
const f = new Foo();
// if `f` is watched by the Debug Visualizer,
// `my-foo-extractor` will provide the data for the visualizers.
// See `CommonDataTypes` for data types that have built in visualizers.
debugger;
}
}
```

View File

@@ -25,5 +25,8 @@
"dev": "tsc --watch",
"build": "tsc"
},
"dependencies": {}
"dependencies": {},
"devDependencies": {
"typescript": "^"
}
}

View File

@@ -1,5 +1,14 @@
import { ExtractedData } from "./DataExtractionResult";
export type CommonDataType =
| CommonDataTypes.Text
| CommonDataTypes.Svg
| CommonDataTypes.Html
| CommonDataTypes.DotGraph
| CommonDataTypes.Tree
| CommonDataTypes.Ast
| CommonDataTypes.Graph;
export function isCommonDataType<T>(
data: ExtractedData,
kind: T
@@ -14,15 +23,6 @@ export function isCommonDataType<T>(
type Narrow<T, TKind> = T extends TKind ? T : never;
export type CommonDataType =
| CommonDataTypes.Text
| CommonDataTypes.Svg
| CommonDataTypes.Html
| CommonDataTypes.DotGraph
| CommonDataTypes.Tree
| CommonDataTypes.Ast
| CommonDataTypes.Graph;
export module CommonDataTypes {
export interface Text {
kind: { text: true };

View File

@@ -17,4 +17,4 @@ export type DataExtractorInfo = {
export type DataExtractorId = {
__brand: "DataExtractorId";
};
} & string;

View File

@@ -1,22 +1,45 @@
import { ExtractedData } from "../DataExtractionResult";
export interface DataExtractor<T extends ExtractedData> {
/**
* Must be unique among all data extractors.
*/
id: string;
getExtractions(
data: unknown,
extractionCollector: ExtractionCollector<T>,
evalFn: <TEval>(expression: string) => TEval
context: DataExtractorContext
): void;
}
export interface ExtractionCollector<T extends ExtractedData> {
/**
* Suggests a possible extraction.
*/
addExtraction(extraction: DataExtraction<T>): void;
}
export interface DataExtractorContext {
/**
* Evaluates an expression in the context of the active stack frame.
*/
evalFn: <TEval>(expression: string) => TEval;
}
export interface DataExtraction<TData extends ExtractedData> {
// the higher the better
/**
* Higher priorities are preferred.
*/
priority: number;
/**
* A unique id identifying this extraction among all extractions.
* Required to express extraction preferences.
*/
id: string;
/**
* A user friendly name of this extraction.
*/
name: string;
extractData(): TData;
}
@@ -24,7 +47,7 @@ export interface DataExtraction<TData extends ExtractedData> {
export function getExtractions<T extends ExtractedData>(
extractor: DataExtractor<T>,
data: unknown,
evalFn: <T>(expression: string) => T
context: DataExtractorContext
): DataExtraction<T>[] {
const extractions = new Array<DataExtraction<T>>();
const collector: ExtractionCollector<T> = {
@@ -32,6 +55,6 @@ export function getExtractions<T extends ExtractedData>(
extractions.push(extraction);
},
};
extractor.getExtractions(data, collector, evalFn);
extractor.getExtractions(data, collector, context);
return extractions;
}

View File

@@ -9,6 +9,35 @@ import {
ExtractionCollector,
} from "./DataExtractor";
export interface DataExtractorApi {
/**
* Registers a single extractor.
* @preferExisting if `true`, an existing extractor is not overwritten.
*/
registerExtractor<TExtractedData extends ExtractedData>(
extractor: DataExtractor<TExtractedData>,
preferExisting?: boolean
): void;
/**
* Registers multiple extractors.
* @preferExisting if `true`, an existing extractor is not overwritten.
*/
registerExtractors(
extractors: DataExtractor<ExtractedData>[],
preferExisting?: boolean
): void;
/**
* Extracts data from `value`.
*/
getData(
value: unknown,
evalFn: <T>(expression: string) => T,
preferredDataExtractorId: string | undefined
): JSONString<DataResult>;
}
export type DataResult =
| {
kind: "Data";
@@ -21,26 +50,18 @@ export interface JSONString<T> extends String {
__brand: { json: T };
}
export interface DataExtractorApi {
registerExtractor<TExtractedData extends ExtractedData>(
extractor: DataExtractor<TExtractedData>
): void;
registerExtractors(extractors: DataExtractor<ExtractedData>[]): void;
getData(
value: unknown,
evalFn: <T>(expression: string) => T,
preferredDataExtractorId: string | undefined
): JSONString<DataResult>;
}
declare const window: any;
/**
* @internal
*/
export function selfContainedInitDataExtractorApi(): boolean {
const obj = typeof window === "object" ? (window as any) : (global as any);
const globalObj =
typeof window === "object" ? (window as any) : (global as any);
const key = "@hediet/data-extractor/v1";
const prefix = key + "::";
let api: DataExtractorApi | undefined = obj[key];
let api: DataExtractorApi | undefined = globalObj[key];
if (api) {
return false;
}
@@ -49,29 +70,38 @@ export function selfContainedInitDataExtractorApi(): boolean {
return JSON.stringify(data) as any;
}
function getExtractors(): DataExtractor<ExtractedData>[] {
return Object.entries(obj)
return Object.entries(globalObj)
.filter(([key, value]) => key.startsWith(prefix))
.map(([key, value]) => value as DataExtractor<ExtractedData>);
}
obj["$asData"] = (x: unknown) => x;
obj[key] = api = {
registerExtractor(extractor) {
obj[prefix + extractor.id] = extractor;
globalObj["$asData"] = (x: unknown) => x;
globalObj[key] = api = {
registerExtractor(extractor, preferExisting) {
const key = prefix + extractor.id;
if (preferExisting && key in globalObj) {
// don't overwrite existing data extractor
return;
}
globalObj[key] = extractor;
},
registerExtractors(extractors: DataExtractor<ExtractedData>[]) {
registerExtractors(
extractors: DataExtractor<ExtractedData>[],
preferExisting
) {
for (const e of extractors) {
this.registerExtractor(e);
this.registerExtractor(e, preferExisting);
}
},
getData(value, evalFn, preferredDataExtractorId) {
const extractions = new Array<DataExtraction<ExtractedData>>();
const collector: ExtractionCollector<ExtractedData> = {
const extractionCollector: ExtractionCollector<ExtractedData> = {
addExtraction(extraction) {
extractions.push(extraction);
},
};
for (const e of getExtractors()) {
e.getExtractions(value, collector, evalFn);
e.getExtractions(value, extractionCollector, { evalFn });
}
extractions.sort((a, b) => b.priority - a.priority);
let usedExtraction = extractions[0];
@@ -111,10 +141,10 @@ export function selfContainedInitDataExtractorApi(): boolean {
};
api.registerExtractor({
id: "toString",
getExtractions(data, collector) {
id: "to-string",
getExtractions: (data, collector) => {
collector.addExtraction({
id: "toString",
id: "to-string",
name: "To String",
priority: 100,
extractData() {
@@ -131,8 +161,15 @@ export function selfContainedInitDataExtractorApi(): boolean {
return true;
}
/**
* This code is used to detect if the API has not been initialized yet.
* @internal
*/
export const ApiHasNotBeenInitializedCode = "EgH0cybXij1jYUozyakO" as const;
/**
* @internal
*/
export function selfContainedGetInitializedDataExtractorApi(): DataExtractorApi {
const obj = typeof window === "object" ? (window as any) : (global as any);
const key = "@hediet/data-extractor/v1";
@@ -147,6 +184,9 @@ export function selfContainedGetInitializedDataExtractorApi(): DataExtractorApi
return api;
}
/**
* Get the data extractor API.
*/
export function getDataExtractorApi(): DataExtractorApi {
selfContainedInitDataExtractorApi();
return selfContainedGetInitializedDataExtractorApi();

View File

@@ -1,12 +1,12 @@
import { ExtractedData } from "../../DataExtractionResult";
import { DataExtractor, ExtractionCollector } from "..";
import { DataExtractor, ExtractionCollector, DataExtractorContext } from "..";
export class AsIsDataExtractor implements DataExtractor<ExtractedData> {
readonly id = "as-is";
getExtractions(
data: unknown,
extractionCollector: ExtractionCollector<ExtractedData>,
evalFn: <TEval>(expression: string) => TEval
context: DataExtractorContext
): void {
if (typeof data !== "object" || !data || !("kind" in data)) {
return;

View File

@@ -1,13 +1,17 @@
import { ExtractedData } from "../../DataExtractionResult";
import { DataExtractor, ExtractionCollector } from "..";
import {
ExtractionCollector,
DataExtractor,
DataExtractorContext,
} from "../DataExtractor";
export class GetDebugVisualizationDataExtractor
implements DataExtractor<ExtractedData> {
readonly id = "get-debug-visualization";
getExtractions(
data: unknown,
extractionCollector: ExtractionCollector<ExtractedData>,
evalFn: <TEval>(expression: string) => TEval
collector: ExtractionCollector<ExtractedData>,
context: DataExtractorContext
): void {
if (
typeof data !== "object" ||
@@ -17,10 +21,10 @@ export class GetDebugVisualizationDataExtractor
return;
}
extractionCollector.addExtraction({
collector.addExtraction({
id: this.id,
name: "Use Method 'getDebugVisualization'",
priority: 500,
priority: 600,
extractData() {
return (data as any).getDebugVisualization();
},

View File

@@ -1,16 +1,20 @@
import * as ts from "typescript";
import { DataExtractor, ExtractionCollector } from "../DataExtractor";
import {
DataExtractor,
ExtractionCollector,
DataExtractorContext,
} from "../DataExtractor";
import { CommonDataTypes } from "../../CommonDataTypes";
// This class is self contained and can be injected into both nodejs and browser environments.
export class TypeScriptAstDataExtractor
implements DataExtractor<CommonDataTypes.Ast> {
readonly id = "TypeScriptAst";
readonly id = "typescript-ast";
getExtractions(
data: unknown,
collector: ExtractionCollector<CommonDataTypes.Ast>,
evalFn: <TEval>(expression: string) => TEval
{ evalFn }: DataExtractorContext
): void {
if (!data) {
return;

View File

@@ -1,21 +1,5 @@
import { getDataExtractorApi } from "../DataExtractorApi";
import { TypeScriptAstDataExtractor } from "./TypeScriptDataExtractors";
import { AsIsDataExtractor } from "./AsIsDataExtractor";
import { GetDebugVisualizationDataExtractor } from "./GetDebugVisualizationDataExtractor";
export { TypeScriptAstDataExtractor } from "./TypeScriptDataExtractors";
export { AsIsDataExtractor } from "./AsIsDataExtractor";
export { GetDebugVisualizationDataExtractor } from "./GetDebugVisualizationDataExtractor";
export {
AsIsDataExtractor,
TypeScriptAstDataExtractor,
GetDebugVisualizationDataExtractor,
};
export function registerAll() {
const api = getDataExtractorApi();
for (const item of [
new TypeScriptAstDataExtractor(),
new AsIsDataExtractor(),
new GetDebugVisualizationDataExtractor(),
]) {
api.registerExtractor(item);
}
}
export { registerDefaultDataExtractors } from "./registerDefaultDataExtractors";

View File

@@ -0,0 +1,19 @@
import { getDataExtractorApi } from "../DataExtractorApi";
import { TypeScriptAstDataExtractor } from "./TypeScriptDataExtractors";
import { AsIsDataExtractor } from "./AsIsDataExtractor";
import { GetDebugVisualizationDataExtractor } from "./GetDebugVisualizationDataExtractor";
/**
* The default data extractors should be registered by vs code anyways.
* This however ensures that the most up to date extractors are registered.
*/
export function registerDefaultDataExtractors() {
const api = getDataExtractorApi();
for (const item of [
new TypeScriptAstDataExtractor(),
new AsIsDataExtractor(),
new GetDebugVisualizationDataExtractor(),
]) {
api.registerExtractor(item);
}
}

View File

@@ -4,47 +4,10 @@ import {
NodeGraphData,
} from "../CommonDataTypes";
export function createGraphFromPointers<T>(
roots: Record<string, T | undefined | null>,
infoSelector: (
item: T
) => {
id?: string | number;
edges: ({ to: T } & Omit<EdgeGraphData, "from" | "to">)[];
} & Omit<NodeGraphData, "id">
): CommonDataTypes.Graph {
const marker = {};
interface Pointer {
marker: {};
name: string;
value: T | null | undefined;
}
const items = Object.entries(roots).map<Pointer>(([name, value]) => ({
marker,
name,
value,
}));
const includeLabelInName = false;
return createGraph<T | Pointer>(items, item => {
if ("marker" in item && item["marker"] === marker) {
return {
id: "label____" + item.name,
color: "orange",
label: item.name,
edges: [{ to: item.value!, color: "orange", label: "" }].filter(
t => !!t.to
),
};
} else {
return infoSelector(item as T);
}
});
}
/**
* Given a list of roots, it creates a graph by following their edges recursively.
* Tracks cycles.
*/
export function createGraph<T>(
roots: T[],
infoSelector: (
@@ -103,3 +66,46 @@ export function createGraph<T>(
}
return r;
}
/**
* Given a labeled list of roots, it creates a graph by following their edges recursively.
* Tracks cycles.
*/
export function createGraphFromPointers<T>(
roots: Record<string, T | undefined | null>,
infoSelector: (
item: T
) => {
id?: string | number;
edges: ({ to: T } & Omit<EdgeGraphData, "from" | "to">)[];
} & Omit<NodeGraphData, "id">
): CommonDataTypes.Graph {
const marker = {};
interface Pointer {
marker: {};
name: string;
value: T | null | undefined;
}
const items = Object.entries(roots).map<Pointer>(([name, value]) => ({
marker,
name,
value,
}));
return createGraph<T | Pointer>(items, item => {
if ("marker" in item && item["marker"] === marker) {
return {
id: "label____" + item.name,
color: "orange",
label: item.name,
edges: [{ to: item.value!, color: "orange", label: "" }].filter(
t => !!t.to
),
};
} else {
return infoSelector(item as T);
}
});
}

View File

@@ -0,0 +1,34 @@
import {
registerDefaultDataExtractors,
getDataExtractorApi,
} from "@hediet/debug-visualizer-data-extraction";
// Registers all existing extractors.
registerDefaultDataExtractors();
getDataExtractorApi().registerExtractor({
id: "my-extractor",
getExtractions: (data, collector) => {
if (data instanceof Foo) {
collector.addExtraction({
id: "my-extractor",
name: "My Extractor",
priority: 2000,
extractData: () => ({ kind: { text: true }, text: "Foo" }),
});
}
},
});
setTimeout(() => {
new Main().run();
}, 0);
class Foo {}
class Main {
run() {
const f = new Foo();
debugger;
}
}

View File

@@ -1,10 +1,9 @@
import {
registerAll,
CommonDataTypes,
registerDefaultDataExtractors,
createGraphFromPointers,
} from "@hediet/debug-visualizer-data-extraction";
registerAll();
registerDefaultDataExtractors();
setTimeout(() => {
new Main().run();

View File

@@ -1,8 +1,8 @@
import * as ts from "typescript";
import { registerAll } from "@hediet/debug-visualizer-data-extraction";
import { registerDefaultDataExtractors } from "@hediet/debug-visualizer-data-extraction";
import { MockLanguageServiceHost } from "./MockLanguageServiceHost";
registerAll();
registerDefaultDataExtractors();
setTimeout(() => {
new Main().run();

View File

@@ -3,3 +3,8 @@
## 0.9.0
- Initial release
## Current
- Minor refactoring of `@hediet/debug-visualizer-data-extraction`'s API
- Improved documentation.

View File

@@ -9,12 +9,15 @@ A VS Code extension for visualizing data structures during debugging.
## Usage
After installing this extension, use the command `Open a new Debug Visualizer View` to open a new visualizer view.
In this view you can enter an expression that is visualized when debugging your application, e.g.
In this view you can enter an expression that is evaluated and visualized while stepping through your application, e.g.
```ts
{ kind: { graph: true }, nodes: [ { id: "1", label: "1" }, { id: "2", label: "2" } ], edges: [{ from: "1", to: "2", label: "edge" }]}
```
You can implement your own functions to extract this debug data from your custom data structures.
See [here](../data-extraction/README.md) for documentation of the `createGraphFromPointers` helper.
## Integrated Visualizers
Visualizers present data extracted by a _Data Extractor_.
@@ -159,6 +162,11 @@ Calls `.getDebugVisualization()` on values and treats the result as direct input
Currently, only JavaScript (and thus TypeScript) values can be visualized and only a few visualizations are supported.
The architecture is solid enough to support other languages in the future.
# `@hediet/debug-visualizer-data-extraction`
A library that provides infrastructure to implement and register custom data extractors.
See [README](../data-extraction/README.md) of the library for more info.
# See Also
This extension works very well together with my library [`@hediet/node-reload`](https://github.com/hediet/node-reload).

View File

@@ -3,8 +3,7 @@ import {
EvaluationWatcher,
EvaluationWatcherOptions,
} from "./DataSource";
import { observable, autorun } from "mobx";
import * as vscode from "vscode";
import { observable, autorun, action } from "mobx";
import { Disposable } from "@hediet/std/disposable";
import {
selfContainedGetInitializedDataExtractorApi,
@@ -156,7 +155,8 @@ export class JsDebuggerSourceImplementation implements JsDataSource {
GetDebugVisualizationDataExtractor,
].map(e => `new (${e.toString()})()`);
expression += `(${selfContainedGetInitializedDataExtractorApi.toString()})()`;
expression += `.registerExtractors([${es.join(",")}])`;
// prefer existing is true, so that manually registered extractors are not overwritten.
expression += `.registerExtractors([${es.join(",")}], true)`;
const reply = await session.evaluate({
expression,
@@ -182,12 +182,13 @@ class ObservableEvaluationWatcher implements EvaluationWatcher {
}
@observable
_preferredDataExtractor: DataExtractorId | undefined = undefined;
private _preferredDataExtractor: DataExtractorId | undefined = undefined;
get preferredDataExtractor(): DataExtractorId | undefined {
public get preferredDataExtractor(): DataExtractorId | undefined {
return this._preferredDataExtractor;
}
@action
public setPreferredDataExtractor(id: DataExtractorId | undefined): void {
this._preferredDataExtractor = id;
this.refresh();