diff --git a/data-extraction/src/DataExtractionResult.ts b/data-extraction/src/DataExtractionResult.ts index 1493f7e..91e6c53 100644 --- a/data-extraction/src/DataExtractionResult.ts +++ b/data-extraction/src/DataExtractionResult.ts @@ -4,7 +4,9 @@ export type DataExtractionResult = { availableExtractors: DataExtractorInfo[]; }; -// Instances must be valid json values. +/** + * Instances must be valid json values. + */ export type ExtractedData = { kind: Record; }; @@ -18,3 +20,16 @@ export type DataExtractorInfo = { export type DataExtractorId = { __brand: "DataExtractorId"; } & string; + +export function isExtractedData(val: unknown): val is ExtractedData { + if (typeof val !== "object" || !val || !("kind" in val)) { + return false; + } + + const obj = val as any; + if (typeof obj.kind !== "object" || !obj.kind) { + return false; + } + + return Object.values(obj.kind).every(val => val === true); +} diff --git a/data-extraction/src/js/api/default-extractors/AsIsDataExtractor.ts b/data-extraction/src/js/api/default-extractors/AsIsDataExtractor.ts index 18ac0fa..8c1bded 100644 --- a/data-extraction/src/js/api/default-extractors/AsIsDataExtractor.ts +++ b/data-extraction/src/js/api/default-extractors/AsIsDataExtractor.ts @@ -1,4 +1,4 @@ -import { ExtractedData } from "../../../DataExtractionResult"; +import { ExtractedData, isExtractedData } from "../../../DataExtractionResult"; import { DataExtractor, ExtractionCollector, @@ -12,17 +12,7 @@ export class AsIsDataExtractor implements DataExtractor { extractionCollector: ExtractionCollector, context: DataExtractorContext ): void { - if (typeof data !== "object" || !data || !("kind" in data)) { - return; - } - - const obj = data as any; - if (typeof obj.kind !== "object" || !obj.kind) { - return; - } - - const areAllTrue = Object.values(obj.kind).every(val => val === true); - if (!areAllTrue) { + if (!isExtractedData(data)) { return; } @@ -31,7 +21,7 @@ export class AsIsDataExtractor implements DataExtractor { name: "As Is", priority: 500, extractData() { - return obj; + return data; }, }); } diff --git a/example-project/.vscode/launch.json b/example-project/.vscode/launch.json new file mode 100644 index 0000000..caa23fb --- /dev/null +++ b/example-project/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // 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": [ + { + "name": "Listen for XDebug", + "type": "php", + "request": "launch", + "port": 9000 + }, + { + "name": "Launch currently open script", + "type": "php", + "request": "launch", + "program": "${file}", + "cwd": "${fileDirname}", + "port": 9000 + } + ] +} diff --git a/example-project/src/demo.php b/example-project/src/demo.php new file mode 100644 index 0000000..246c3db --- /dev/null +++ b/example-project/src/demo.php @@ -0,0 +1,18 @@ + array("graph" => true), + "nodes" => [ + array("id" => "1", "label" => "1"), + array("id" => "2", "label" => "2"), + ], + "edges" => [ + array("from" => "1", "to" => "2", "label" => "edge") + ] + )); + + echo "test" . $i; +} diff --git a/extension/src/ConnectionHandler.ts b/extension/src/ConnectionHandler.ts index 2db4e2e..d66e69b 100644 --- a/extension/src/ConnectionHandler.ts +++ b/extension/src/ConnectionHandler.ts @@ -1,10 +1,9 @@ import { Disposable } from "@hediet/std/disposable"; import { debugVisualizerUIContract } from "@hediet/debug-visualizer-vscode-shared"; import { ConsoleRpcLogger, RequestHandlingError } from "@hediet/typed-json-rpc"; -import { EvaluationWatcher } from "./DataSource/DataSource"; +import { EvaluationWatcher, DataSource } from "./DataSource/DataSource"; import { WebSocketStream } from "@hediet/typed-json-rpc-websocket"; import { observable, autorun } from "mobx"; -import { Sources } from "./DataSource"; import { Server } from "./Server"; import * as open from "open"; import chromeLauncher = require("chrome-launcher"); @@ -16,7 +15,7 @@ export class ConnectionHandler { private watcher: EvaluationWatcher | undefined = undefined; constructor( - sources: Sources, + dataSource: DataSource, stream: WebSocketStream, server: Server, config: Config, @@ -61,12 +60,9 @@ export class ConnectionHandler { this.dispose.untrack(this.watcher).dispose(); } this.watcher = this.dispose.track( - sources.jsSource.createEvaluationWatcher( - newExpression, - { - preferredDataExtractor: oldPreferredDataExtractor, - } - ) + dataSource.createEvaluationWatcher(newExpression, { + preferredDataExtractor: oldPreferredDataExtractor, + }) ); }, openInBrowser: async ({}) => { @@ -96,7 +92,7 @@ export class ConnectionHandler { getCompletions: async ({ text, column }) => { throwIfNotAuthenticated(); - const completions = await sources.jsSource.getCompletions( + const completions = await dataSource.getCompletions( text, column ); diff --git a/extension/src/DataSource/DataExtractionProvider/ComposedDataExtractionProviderFactory.ts b/extension/src/DataSource/DataExtractionProvider/ComposedDataExtractionProviderFactory.ts new file mode 100644 index 0000000..ae3a563 --- /dev/null +++ b/extension/src/DataSource/DataExtractionProvider/ComposedDataExtractionProviderFactory.ts @@ -0,0 +1,22 @@ +import { + DataExtractionProviderFactory, + DataExtractionProvider, +} from "./DataExtractionProvider"; +import { VsCodeDebugSession } from "../../VsCodeDebugger"; + +export class ComposedDataExtractionProviderFactory + implements DataExtractionProviderFactory { + constructor(public readonly factories: DataExtractionProviderFactory[]) {} + + createDataExtractionProvider( + session: VsCodeDebugSession + ): DataExtractionProvider | undefined { + for (const f of this.factories) { + const provider = f.createDataExtractionProvider(session); + if (provider) { + return provider; + } + } + return undefined; + } +} diff --git a/extension/src/DataSource/DataExtractionProvider/DataExtractionProvider.ts b/extension/src/DataSource/DataExtractionProvider/DataExtractionProvider.ts new file mode 100644 index 0000000..caae320 --- /dev/null +++ b/extension/src/DataSource/DataExtractionProvider/DataExtractionProvider.ts @@ -0,0 +1,23 @@ +import { DataExtractionResult } from "@hediet/debug-visualizer-data-extraction"; +import { VsCodeDebugSession } from "../../VsCodeDebugger"; + +export interface DataExtractionProviderFactory { + createDataExtractionProvider( + session: VsCodeDebugSession + ): DataExtractionProvider | undefined; +} + +export interface DataExtractionProvider { + evaluate( + args: DataExtractionProviderArgs + ): Promise< + | { kind: "data"; result: DataExtractionResult } + | { kind: "error"; message: string } + >; +} + +export interface DataExtractionProviderArgs { + expression: string; + preferredExtractor: string | undefined; + frameId: number | undefined; +} diff --git a/extension/src/DataSource/DataExtractionProvider/GenericDataExtractionProviderFactory.ts b/extension/src/DataSource/DataExtractionProvider/GenericDataExtractionProviderFactory.ts new file mode 100644 index 0000000..b59ebaf --- /dev/null +++ b/extension/src/DataSource/DataExtractionProvider/GenericDataExtractionProviderFactory.ts @@ -0,0 +1,66 @@ +import { + DataResult, + DataExtractionResult, + ExtractedData, + isExtractedData, +} from "@hediet/debug-visualizer-data-extraction"; +import { VsCodeDebugSession } from "../../VsCodeDebugger"; +import { + DataExtractionProviderFactory, + DataExtractionProvider, + DataExtractionProviderArgs, +} from "./DataExtractionProvider"; + +export class GenericDataExtractionProviderFactory + implements DataExtractionProviderFactory { + createDataExtractionProvider( + session: VsCodeDebugSession + ): DataExtractionProvider | undefined { + return new GenericDataExtractionProvider(session); + } +} + +class GenericDataExtractionProvider implements DataExtractionProvider { + constructor(private readonly session: VsCodeDebugSession) {} + + public async evaluate({ + expression, + preferredExtractor, + frameId, + }: DataExtractionProviderArgs): Promise< + | { kind: "data"; result: DataExtractionResult } + | { kind: "error"; message: string } + > { + try { + const reply = await this.session.evaluate({ + expression, + frameId, + }); + const resultStr = reply.result; + const jsonData = resultStr.substr(1, resultStr.length - 2); + const result = JSON.parse(jsonData) as ExtractedData; + + if (!isExtractedData(result)) { + throw new Error("Invalid Data"); + } + + return { + kind: "data", + result: { + availableExtractors: [], + usedExtractor: { + id: "generic" as any, + name: "Generic", + priority: 1, + }, + data: result, + }, + }; + } catch (error) { + return { + kind: "error", + message: error.message, + }; + } + } +} diff --git a/extension/src/DataSource/DataExtractionProvider/JsDataExtractionProviderFactory.ts b/extension/src/DataSource/DataExtractionProvider/JsDataExtractionProviderFactory.ts new file mode 100644 index 0000000..f59bd1a --- /dev/null +++ b/extension/src/DataSource/DataExtractionProvider/JsDataExtractionProviderFactory.ts @@ -0,0 +1,121 @@ +import { + getExpressionForDataExtractorApi, + DataResult, + ApiHasNotBeenInitializedCode, + getExpressionToInitializeDataExtractorApi, + DataExtractionResult, +} from "@hediet/debug-visualizer-data-extraction"; +import { VsCodeDebugSession } from "../../VsCodeDebugger"; +import { + DataExtractionProviderFactory, + DataExtractionProvider, + DataExtractionProviderArgs, +} from "./DataExtractionProvider"; + +export class JsDataExtractionProviderFactory + implements DataExtractionProviderFactory { + createDataExtractionProvider( + session: VsCodeDebugSession + ): DataExtractionProvider | undefined { + const supportedDebugAdapters = [ + "node", + "node2", + "extensionHost", + "chrome", + ]; + if (supportedDebugAdapters.indexOf(session.session.type) !== -1) { + return new JsDataEvaluator(session); + } + return undefined; + } +} + +class JsDataEvaluator implements DataExtractionProvider { + constructor(private readonly session: VsCodeDebugSession) {} + + public async evaluate({ + expression, + preferredExtractor, + frameId, + }: DataExtractionProviderArgs): Promise< + | { kind: "data"; result: DataExtractionResult } + | { kind: "error"; message: string } + > { + while (true) { + try { + const preferredExtractorExpr = preferredExtractor + ? `"${preferredExtractor}"` + : "undefined"; + + const body = `${getExpressionForDataExtractorApi()}.getData( + e => (${expression}), + expr => eval(expr), + ${preferredExtractorExpr} + )`; + + const wrappedExpr = ` + (() => { + try { + return ${body}; + } catch (e) { + return JSON.stringify({ + kind: "Error", + message: e.message, + stack: e.stack + }); + } + })() + `; + + const reply = await this.session.evaluate({ + expression: wrappedExpr, + frameId, + }); + const resultStr = reply.result; + const jsonData = resultStr.substr(1, resultStr.length - 2); + const result = JSON.parse(jsonData) as DataResult; + + if (result.kind === "NoExtractors") { + throw new Error("No extractors"); + } else if (result.kind === "Error") { + throw new Error(result.message); + } else if (result.kind === "Data") { + return { + kind: "data", + result: result.extractionResult, + }; + } else { + throw new Error("Invalid Data"); + } + } catch (error) { + const msg = error.message as string | undefined; + if (msg && msg.includes(ApiHasNotBeenInitializedCode)) { + if (await this.initializeApi(frameId)) { + continue; + } + } + + return { + kind: "error", + message: error.message, + }; + } + } + } + + private async initializeApi(frameId: number | undefined): Promise { + try { + // prefer existing is true, so that manually registered (possibly newer) extractors are not overwritten. + const expression = `${getExpressionToInitializeDataExtractorApi()}.registerDefaultExtractors(true);`; + + await this.session.evaluate({ + expression, + frameId, + }); + + return true; + } catch (error) { + return false; + } + } +} diff --git a/extension/src/DataSource/DataExtractionProvider/index.ts b/extension/src/DataSource/DataExtractionProvider/index.ts new file mode 100644 index 0000000..9902e81 --- /dev/null +++ b/extension/src/DataSource/DataExtractionProvider/index.ts @@ -0,0 +1,12 @@ +export * from "./DataExtractionProvider"; + +import { ComposedDataExtractionProviderFactory } from "./ComposedDataExtractionProviderFactory"; +import { JsDataExtractionProviderFactory } from "./JsDataExtractionProviderFactory"; +import { GenericDataExtractionProviderFactory } from "./GenericDataExtractionProviderFactory"; + +export const defaultDataExtractionProviderFactory = new ComposedDataExtractionProviderFactory( + [ + new JsDataExtractionProviderFactory(), + new GenericDataExtractionProviderFactory(), + ] +); diff --git a/extension/src/DataSource/DataSource.ts b/extension/src/DataSource/DataSource.ts index 4dbf6f7..a8ea47f 100644 --- a/extension/src/DataSource/DataSource.ts +++ b/extension/src/DataSource/DataSource.ts @@ -4,30 +4,27 @@ import { CompletionItem, } from "@hediet/debug-visualizer-vscode-shared"; -export interface EvaluationWatcherOptions { - preferredDataExtractor?: DataExtractorId | undefined; -} - export interface DataSource { createEvaluationWatcher( expression: string, options: EvaluationWatcherOptions ): EvaluationWatcher; + getCompletions(text: string, column: number): Promise; } +export interface EvaluationWatcherOptions { + preferredDataExtractor?: DataExtractorId | undefined; +} + export interface EvaluationWatcher { readonly expression: string; - - // is observable + /** This field is observable */ + readonly state: DataExtractionState; + /** This field is observable */ readonly preferredDataExtractor: DataExtractorId | undefined; setPreferredDataExtractor(id: DataExtractorId | undefined): void; - refresh(): void; - - // is observable - state: DataExtractionState; - dispose(): void; } diff --git a/extension/src/DataSource/DataSourceImpl.ts b/extension/src/DataSource/DataSourceImpl.ts new file mode 100644 index 0000000..e6292b6 --- /dev/null +++ b/extension/src/DataSource/DataSourceImpl.ts @@ -0,0 +1,136 @@ +import { + DataSource, + EvaluationWatcher, + EvaluationWatcherOptions, +} from "./DataSource"; +import { observable, autorun, action } from "mobx"; +import { Disposable } from "@hediet/std/disposable"; +import { DataExtractorId } from "@hediet/debug-visualizer-data-extraction"; +import { + DataExtractionState, + CompletionItem, +} from "@hediet/debug-visualizer-vscode-shared"; +import { hotClass } from "@hediet/node-reload"; +import { VsCodeDebuggerView } from "../VsCodeDebugger"; +import { DataExtractionProviderFactory } from "./DataExtractionProvider/DataExtractionProvider"; + +@hotClass(module) +export class DataSourceImpl implements DataSource { + public readonly dispose = Disposable.fn(); + private readonly watchers = new Set(); + + constructor( + private readonly vsCodeDebuggerView: VsCodeDebuggerView, + private readonly dataExtractionProviderFactory: DataExtractionProviderFactory + ) { + this.dispose.track({ + dispose: autorun(() => { + if ( + vsCodeDebuggerView.activeDebugSession && + vsCodeDebuggerView.activeFrameId !== undefined + ) { + for (const w of this.watchers) { + w.refresh(); + } + } + }), + }); + } + + public createEvaluationWatcher( + expression: string, + options: EvaluationWatcherOptions + ): EvaluationWatcher { + const w = new ObservableEvaluationWatcher(expression, this, options); + this.watchers.add(w); + this.refresh(w); + return w; + } + + public removeWatcher(w: ObservableEvaluationWatcher) { + this.watchers.delete(w); + } + + public async refresh(w: ObservableEvaluationWatcher): Promise { + const session = this.vsCodeDebuggerView.activeDebugSession; + if (!session) { + w._state = { kind: "noDebugSession" }; + return; + } + + const frameId = this.vsCodeDebuggerView.activeFrameId; + + w._state = { kind: "loading" }; + + const extractionProvider = this.dataExtractionProviderFactory.createDataExtractionProvider( + session + ); + if (!extractionProvider) { + w._state = { + kind: "error", + message: `The debug adapter "${session.session.type}" is not supported.`, + }; + return; + } + + const result = await extractionProvider.evaluate({ + expression: w.expression, + frameId, + preferredExtractor: w.preferredDataExtractor, + }); + + w._state = result; + } + + public async getCompletions( + text: string, + column: number + ): Promise { + const session = this.vsCodeDebuggerView.activeDebugSession; + if (!session) { + return []; + } + return await session.getCompletions({ + text, + frameId: this.vsCodeDebuggerView.activeFrameId, + column, + }); + } +} + +class ObservableEvaluationWatcher implements EvaluationWatcher { + constructor( + public readonly expression: string, + private readonly source: DataSourceImpl, + options: EvaluationWatcherOptions + ) { + this._preferredDataExtractor = options.preferredDataExtractor; + } + + @observable + private _preferredDataExtractor: DataExtractorId | undefined = undefined; + + public get preferredDataExtractor(): DataExtractorId | undefined { + return this._preferredDataExtractor; + } + + @action + public setPreferredDataExtractor(id: DataExtractorId | undefined): void { + this._preferredDataExtractor = id; + this.refresh(); + } + + public refresh(): void { + this.source.refresh(this); + } + + @observable + public _state: DataExtractionState = { kind: "loading" }; + public get state(): DataExtractionState { + return this._state; + } + + public dispose(): void { + this.source.removeWatcher(this); + } +} diff --git a/extension/src/DataSource/JsDebuggerSource.ts b/extension/src/DataSource/JsDebuggerSource.ts deleted file mode 100644 index c7b6e8f..0000000 --- a/extension/src/DataSource/JsDebuggerSource.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - DataSource, - EvaluationWatcher, - EvaluationWatcherOptions, -} from "./DataSource"; -import { observable, autorun, action } from "mobx"; -import { Disposable } from "@hediet/std/disposable"; -import { - getExpressionForDataExtractorApi, - DataResult, - DataExtractorId, - ApiHasNotBeenInitializedCode, - getExpressionToInitializeDataExtractorApi, -} from "@hediet/debug-visualizer-data-extraction"; -import { - DataExtractionState, - CompletionItem, -} from "@hediet/debug-visualizer-vscode-shared"; -import { hotClass } from "@hediet/node-reload"; -import { VsCodeDebuggerView, VsCodeDebugSession } from "../VsCodeDebugger"; - -export function createJsDebuggerSource(args: { - vsCodeDebuggerView: VsCodeDebuggerView; -}): JsDataSource { - return new JsDebuggerSourceImplementation(args.vsCodeDebuggerView); -} - -export interface JsCode extends String { - __brand: "JsCode"; -} - -export interface JsDataSource extends DataSource { - registerDataExtractor(classExpression: JsCode): void; -} - -@hotClass(module) -export class JsDebuggerSourceImplementation implements JsDataSource { - public readonly dispose = Disposable.fn(); - private readonly watchers = new Set(); - - constructor(private readonly vsCodeDebuggerView: VsCodeDebuggerView) { - this.dispose.track({ - dispose: autorun(() => { - if ( - vsCodeDebuggerView.activeDebugSession && - vsCodeDebuggerView.activeFrameId !== undefined - ) { - for (const w of this.watchers) { - w.refresh(); - } - } - }), - }); - } - - registerDataExtractor(classExpression: JsCode): void { - // TODO implement - } - - public async getCompletions( - text: string, - column: number - ): Promise { - const session = this.vsCodeDebuggerView.activeDebugSession; - if (!session) { - return []; - } - return await session.getCompletions({ - text, - frameId: this.vsCodeDebuggerView.activeFrameId, - column, - }); - } - - public createEvaluationWatcher( - expression: string, - options: EvaluationWatcherOptions - ): EvaluationWatcher { - const w = new ObservableEvaluationWatcher(expression, this, options); - this.watchers.add(w); - this.refresh(w); - return w; - } - - public removeWatcher(w: ObservableEvaluationWatcher) { - this.watchers.delete(w); - } - - public async refresh(w: ObservableEvaluationWatcher): Promise { - const session = this.vsCodeDebuggerView.activeDebugSession; - if (!session) { - w._state = { kind: "noDebugSession" }; - return; - } - - const frameId = this.vsCodeDebuggerView.activeFrameId; - - try { - w._state = { kind: "loading" }; - - const preferredExtractor = w.preferredDataExtractor - ? `"${w.preferredDataExtractor}"` - : "undefined"; - - const body = `${getExpressionForDataExtractorApi()}.getData( - e => (${w.expression}), - expr => eval(expr), - ${preferredExtractor} - )`; - - const expression = ` - (() => { - try { - return ${body}; - } catch (e) { - return JSON.stringify({ - kind: "Error", - message: e.message, - stack: e.stack - }); - } - })() - `; - - const reply = await session.evaluate({ - expression, - frameId, - }); - const resultStr = reply.result; - const jsonData = resultStr.substr(1, resultStr.length - 2); - const result = JSON.parse(jsonData) as DataResult; - if (result.kind === "NoExtractors") { - throw new Error("No extractors"); - } else if (result.kind === "Error") { - throw new Error(result.message); - } - - w._state = { - kind: "data", - result: result.extractionResult, - }; - } catch (error) { - const msg = error.message as string | undefined; - if (msg && msg.includes(ApiHasNotBeenInitializedCode)) { - if (await this.initializeApi(session, frameId)) { - await this.refresh(w); - return; - } - } - - w._state = { - kind: "error", - message: error.message, - }; - } - } - - private async initializeApi( - session: VsCodeDebugSession, - frameId: number | undefined - ): Promise { - try { - // prefer existing is true, so that manually registered (possibly newer) extractors are not overwritten. - const expression = `${getExpressionToInitializeDataExtractorApi()}.registerDefaultExtractors(true);`; - - await session.evaluate({ - expression, - frameId, - }); - - return true; - } catch (error) { - return false; - } - } -} - -class ObservableEvaluationWatcher implements EvaluationWatcher { - constructor( - public readonly expression: string, - private readonly source: JsDebuggerSourceImplementation, - options: EvaluationWatcherOptions - ) { - this._preferredDataExtractor = options.preferredDataExtractor; - } - - @observable - private _preferredDataExtractor: DataExtractorId | undefined = undefined; - - public get preferredDataExtractor(): DataExtractorId | undefined { - return this._preferredDataExtractor; - } - - @action - public setPreferredDataExtractor(id: DataExtractorId | undefined): void { - this._preferredDataExtractor = id; - this.refresh(); - } - - public refresh(): void { - this.source.refresh(this); - } - - @observable - public _state: DataExtractionState = { kind: "loading" }; - public get state(): DataExtractionState { - return this._state; - } - - public dispose(): void { - this.source.removeWatcher(this); - } -} diff --git a/extension/src/DataSource/Sources.ts b/extension/src/DataSource/Sources.ts index a571dc9..d5ca2df 100644 --- a/extension/src/DataSource/Sources.ts +++ b/extension/src/DataSource/Sources.ts @@ -1,6 +1,7 @@ import { Disposable } from "@hediet/std/disposable"; import { VsCodeDebugger, VsCodeDebuggerView } from "../VsCodeDebugger"; -import { createJsDebuggerSource } from "./JsDebuggerSource"; +import { DataSourceImpl } from "./DataSourceImpl"; +import { defaultDataExtractionProviderFactory } from "./DataExtractionProvider"; export class Sources { public readonly dispose = Disposable.fn(); @@ -9,7 +10,8 @@ export class Sources { new VsCodeDebuggerView(this.debugger) ); - public readonly jsSource = createJsDebuggerSource({ - vsCodeDebuggerView: this.debuggerView, - }); + public readonly dataSource = new DataSourceImpl( + this.debuggerView, + defaultDataExtractionProviderFactory + ); } diff --git a/extension/src/Server.ts b/extension/src/Server.ts index d99a05e..c8fa347 100644 --- a/extension/src/Server.ts +++ b/extension/src/Server.ts @@ -24,7 +24,13 @@ export class Server { const wss = new WebSocket.Server({ server: this.server }); wss.on("connection", ws => { const stream = new WebSocketStream(ws); - new ConnectionHandler(sources, stream, this, config, this.secret); + new ConnectionHandler( + sources.dataSource, + stream, + this, + config, + this.secret + ); }); }