src/adapter/daffodilRuntime.ts (311 lines of code) (raw):
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { EventEmitter } from 'events'
export interface FileAccessor {
readFile(path: string): Promise<string>
}
export interface IDaffodilBreakpoint {
id: number
line: number
verified: boolean
}
interface IStepInTargets {
id: number
label: string
}
interface IStackFrame {
index: number
name: string
file: string
line: number
column?: number
}
interface IStack {
count: number
frames: IStackFrame[]
}
/**
* A Daffodil runtime with minimal debugger functionality.
*/
export class DaffodilRuntime extends EventEmitter {
// the initial (and one and only) file we are 'debugging'
private _sourceFile: string = ''
public get sourceFile() {
return this._sourceFile
}
// the contents (= lines) of the one and only file
private _sourceLines: string[] = []
// This is the next line that will be 'executed'
private _currentLine = 0
private _currentColumn: number | undefined
// maps from sourceFile to array of Daffodil breakpoints
private _breakPoints = new Map<string, IDaffodilBreakpoint[]>()
// since we want to send breakpoint events, we will assign an id to every event
// so that the frontend can match events with breakpoints.
private _breakpointId = 1
private _breakAddresses = new Set<string>()
private _noDebug = false
private _namedException: string | undefined
private _otherExceptions = false
constructor(private _fileAccessor: FileAccessor) {
super()
}
/**
* Start executing the given program.
*/
public async start(
program: string,
stopOnEntry: boolean,
noDebug: boolean
): Promise<void> {
this._noDebug = noDebug
await this.loadSource(program)
this._currentLine = -1
await this.verifyBreakpoints(this._sourceFile)
if (stopOnEntry) {
// we step once
this.step(false, 'stopOnEntry')
} else {
// we just start to run until we hit a breakpoint or an exception
this.continue()
}
}
/**
* Continue execution to the end/beginning.
*/
public continue(reverse = false) {
this.run(reverse, undefined)
}
/**
* Step to the next/previous non empty line.
*/
public step(reverse = false, event = 'stopOnStep') {
this.run(reverse, event)
}
/**
* "Step into" for Daffodil Debug means: go to next character
*/
public stepIn(targetId: number | undefined) {
if (typeof targetId === 'number') {
this._currentColumn = targetId
this.sendEvent('stopOnStep')
} else {
if (typeof this._currentColumn === 'number') {
if (
this._currentColumn <= this._sourceLines[this._currentLine].length
) {
this._currentColumn += 1
}
} else {
this._currentColumn = 1
}
this.sendEvent('stopOnStep')
}
}
/**
* "Step out" for Daffodil Debug means: go to previous character
*/
public stepOut() {
if (typeof this._currentColumn === 'number') {
this._currentColumn -= 1
if (this._currentColumn === 0) {
this._currentColumn = undefined
}
}
this.sendEvent('stopOnStep')
}
public getStepInTargets(frameId: number): IStepInTargets[] {
const line = this._sourceLines[this._currentLine].trim()
// every word of the current line becomes a stack frame.
const words = line.split(/\s+/)
// return nothing if frameId is out of range
if (frameId < 0 || frameId >= words.length) {
return []
}
// pick the frame for the given frameId
const frame = words[frameId]
const pos = line.indexOf(frame)
// make every character of the frame a potential "step in" target
return frame.split('').map((c, ix) => {
return {
id: pos + ix,
label: `target: ${c}`,
}
})
}
/**
* Returns a fake 'stacktrace' where every 'stackframe' is a word from the current line.
*/
public stack(startFrame: number, endFrame: number): IStack {
const words = this._sourceLines[this._currentLine].trim().split(/\s+/)
const frames = new Array<IStackFrame>()
// every word of the current line becomes a stack frame.
for (let i = startFrame; i < Math.min(endFrame, words.length); i++) {
const name = words[i] // use a word of the line as the stackframe name
const stackFrame: IStackFrame = {
index: i,
name: `${name}(${i})`,
file: this._sourceFile,
line: this._currentLine,
}
if (typeof this._currentColumn === 'number') {
stackFrame.column = this._currentColumn
}
frames.push(stackFrame)
}
return {
frames: frames,
count: words.length,
}
}
public getBreakpoints(path: string, line: number): number[] {
const l = this._sourceLines[line]
let sawSpace = true
const bps: number[] = []
for (let i = 0; i < l.length; i++) {
if (l[i] !== ' ') {
if (sawSpace) {
bps.push(i)
sawSpace = false
}
} else {
sawSpace = true
}
}
return bps
}
/*
* Set breakpoint in file with given line.
*/
public async setBreakPoint(
path: string,
line: number
): Promise<IDaffodilBreakpoint> {
const bp: IDaffodilBreakpoint = {
verified: false,
line,
id: this._breakpointId++,
}
let bps = this._breakPoints.get(path)
if (!bps) {
bps = new Array<IDaffodilBreakpoint>()
this._breakPoints.set(path, bps)
}
bps.push(bp)
await this.verifyBreakpoints(path)
return bp
}
/*
* Clear breakpoint in file with given line.
*/
public clearBreakPoint(
path: string,
line: number
): IDaffodilBreakpoint | undefined {
const bps = this._breakPoints.get(path)
if (bps) {
const index = bps.findIndex((bp) => bp.line === line)
if (index >= 0) {
const bp = bps[index]
bps.splice(index, 1)
return bp
}
}
return undefined
}
/*
* Clear all breakpoints for file.
*/
public clearBreakpoints(path: string): void {
this._breakPoints.delete(path)
}
/*
* Set data breakpoint.
*/
public setDataBreakpoint(address: string): boolean {
if (address) {
this._breakAddresses.add(address)
return true
}
return false
}
public setExceptionsFilters(
namedException: string | undefined,
otherExceptions: boolean
): void {
this._namedException = namedException
this._otherExceptions = otherExceptions
}
/*
* Clear all data breakpoints.
*/
public clearAllDataBreakpoints(): void {
this._breakAddresses.clear()
}
// private methods
private async loadSource(file: string): Promise<void> {
if (this._sourceFile !== file) {
this._sourceFile = file
const contents = await this._fileAccessor.readFile(file)
this._sourceLines = contents.split(/\r?\n/)
}
}
/**
* Run through the file.
* If stepEvent is specified only run a single step and emit the stepEvent.
*/
private run(reverse = false, stepEvent?: string) {
if (reverse) {
for (let ln = this._currentLine - 1; ln >= 0; ln--) {
if (this.fireEventsForLine(ln, stepEvent)) {
this._currentLine = ln
this._currentColumn = undefined
return
}
}
// no more lines: stop at first line
this._currentLine = 0
this._currentColumn = undefined
this.sendEvent('stopOnEntry')
} else {
for (
let ln = this._currentLine + 1;
ln < this._sourceLines.length;
ln++
) {
if (this.fireEventsForLine(ln, stepEvent)) {
this._currentLine = ln
this._currentColumn = undefined
return true
}
}
// no more lines: run to end
this.sendEvent('end')
}
}
private async verifyBreakpoints(path: string): Promise<void> {
if (this._noDebug) {
return
}
const bps = this._breakPoints.get(path)
if (bps) {
await this.loadSource(path)
bps.forEach((bp) => {
if (!bp.verified && bp.line < this._sourceLines.length) {
const srcLine = this._sourceLines[bp.line].trim()
// if a line is empty or starts with '+' we don't allow to set a breakpoint but move the breakpoint down
if (srcLine.length === 0 || srcLine.indexOf('+') === 0) {
bp.line++
}
// if a line starts with '-' we don't allow to set a breakpoint but move the breakpoint up
if (srcLine.indexOf('-') === 0) {
bp.line--
}
// don't set 'verified' to true if the line contains the word 'lazy'
// in this case the breakpoint will be verified 'lazy' after hitting it once.
if (srcLine.indexOf('lazy') < 0) {
bp.verified = true
this.sendEvent('breakpointValidated', bp)
}
}
})
}
}
/**
* Fire events if line has a breakpoint or the word 'exception' or 'exception(...)' is found.
* Returns true if execution needs to stop.
*/
private fireEventsForLine(ln: number, stepEvent?: string): boolean {
if (this._noDebug) {
return false
}
const line = this._sourceLines[ln].trim()
// if 'log(...)' found in source -> send argument to debug console
const matches = /log\((.*)\)/.exec(line)
if (matches && matches.length === 2) {
this.sendEvent('output', matches[1], this._sourceFile, ln, matches.index)
}
// if a word in a line matches a data breakpoint, fire a 'dataBreakpoint' event
const words = line.split(' ')
for (const word of words) {
if (this._breakAddresses.has(word)) {
this.sendEvent('stopOnDataBreakpoint')
return true
}
}
// if pattern 'exception(...)' found in source -> throw named exception
const matches2 = /exception\((.*)\)/.exec(line)
if (matches2 && matches2.length === 2) {
const exception = matches2[1].trim()
if (this._namedException === exception) {
this.sendEvent('stopOnException', exception)
return true
} else {
if (this._otherExceptions) {
this.sendEvent('stopOnException', undefined)
return true
}
}
} else {
// if word 'exception' found in source -> throw exception
if (line.indexOf('exception') >= 0) {
if (this._otherExceptions) {
this.sendEvent('stopOnException', undefined)
return true
}
}
}
// is there a breakpoint?
const breakpoints = this._breakPoints.get(this._sourceFile)
if (breakpoints) {
const bps = breakpoints.filter((bp) => bp.line === ln)
if (bps.length > 0) {
// send 'stopped' event
this.sendEvent('stopOnBreakpoint')
// the following shows the use of 'breakpoint' events to update properties of a breakpoint in the UI
// if breakpoint is not yet verified, verify it now and send a 'breakpoint' update event
if (!bps[0].verified) {
bps[0].verified = true
this.sendEvent('breakpointValidated', bps[0])
}
return true
}
}
// non-empty line
if (stepEvent && line.length > 0) {
this.sendEvent(stepEvent)
return true
}
// nothing interesting found -> continue
return false
}
private sendEvent(event: string, ...args: any[]) {
setImmediate((_) => {
this.emit(event, ...args)
})
}
}