diff --git a/.huskyrc.js b/.huskyrc.js index 7663bd54..8e486223 100644 --- a/.huskyrc.js +++ b/.huskyrc.js @@ -1,5 +1,5 @@ module.exports = { 'hooks': { - 'pre-commit': 'npm run lint', + 'pre-commit': 'npm run lint && npm run test', }, }; \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 311c67d0..33811ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ Also want to check the development status, check the [commit history](https://gi * [#261](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/261) Support the expression in watch expression * [#269](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/269) Support the expression evaluate in debug console * [#270](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/270) Support expression in log point +* [#275](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/275) Change the syntax of debug directive +* [#288](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/288) Change the labelling process when outputting objects to the log ### Fixed * [#207](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/207) Attach fails if file path contains multibyte strings diff --git a/README.md b/README.md index 727c6e71..c3602dc6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ This extension is a debugger adapter for [VSCode](https://code.visualstudio.com/ * Changed: [#261](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/261) Support the expression in watch expression * Changed: [#269](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/269) Support the expression evaluate in debug console * Changed: [#270](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/270) Support expression in log point + * Changed: [#275](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/275) Change the syntax of debug directive + * Changed: [#288](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/288) Change the labelling process when outputting objects to the log + * Fixed: [#207](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/207) Attach fails if file path contains multibyte strings * Fixed: [#212](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/212) Some errors were not detected and raw error messages were output. This caused `useAutoJumpToError` to not work in some cases * Fixed: [#215](https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/215) The list of running AutoHotkey processes displayed before attaching does not display correctly when it contains multibyte strings diff --git a/demo/demo-debugdirective.ahk b/demo/demo-debugdirective.ahk new file mode 100644 index 00000000..5de76088 --- /dev/null +++ b/demo/demo-debugdirective.ahk @@ -0,0 +1,21 @@ +str := "abc" + +label := "obj" +obj := { key: "value" } +arr := [ 1, str, obj ] + +; @Debug-Output => {str} +; @Debug-Output => {label}{obj}{arr}{:break:} +a := "" + +; @Debug-Output => {:clear:}{:start:}A +; @Debug-Output => {:startCollapsed:}B +; @Debug-Output => C-1 +; @Debug-Output => C-2 +; @Debug-Output => {:end:}{:end:} +; @Debug-Output => {:break:} +b := "" + +; @Debug-ClearConsole +; @Debug-Breakpoint => cleard console! +return \ No newline at end of file diff --git a/demo/demo-debugdirective.ahk2 b/demo/demo-debugdirective.ahk2 new file mode 100644 index 00000000..aac990a2 --- /dev/null +++ b/demo/demo-debugdirective.ahk2 @@ -0,0 +1,22 @@ +str := "abc" + +label := "obj" +obj := { key: "value" } +arr := [ 1, str, obj ] + +; @Debug-Output => {str} +; @Debug-Output => {label}{obj}{arr}{:break:} +a := "" + +; @Debug-Output => {:clear:}{:start:}A +; @Debug-Output => {:startCollapsed:}B +; @Debug-Output => {:error:}C-1 +; @Debug-Output => {:info:}C-2 +; @Debug-Output => {:nortice:}C-3 +; @Debug-Output => {:end:}{:end:} +; @Debug-Output => {:break:} +b := "" + +; @Debug-ClearConsole +; @Debug-Breakpoint => cleard console! +return \ No newline at end of file diff --git a/demo/demo.ahk b/demo/demo.ahk index b25f7c31..131f17f2 100644 --- a/demo/demo.ahk +++ b/demo/demo.ahk @@ -5,7 +5,7 @@ globalVar := "Global" global SuperGlobalVar := "SuperGlobal" demo() - demo() { +demo() { static staticVar := "Static" ; Overwrite global var @@ -74,7 +74,7 @@ demo() } class Clazz extends ClazzBase { ; static - static staticField := "static fie ld" + static staticField := "staticfield" ; property _property_baking := "baking" diff --git a/src/ahkDebug.ts b/src/ahkDebug.ts index c5d2a9dc..c5290bd5 100644 --- a/src/ahkDebug.ts +++ b/src/ahkDebug.ts @@ -36,7 +36,7 @@ import * as dbgp from './dbgpSession'; import { AutoHotkeyLauncher, AutoHotkeyProcess } from './util/AutoHotkeyLuncher'; import { now, readFileCache, readFileCacheSync, searchPair, timeoutPromise, toFileUri } from './util/util'; import matcher from 'matcher'; -import { Categories, Category, MetaVariable, MetaVariableValue, MetaVariableValueMap, Scope, StackFrames, Variable, VariableManager, formatProperty } from './util/VariableManager'; +import { Categories, Category, MetaVariable, MetaVariableValueMap, Scope, StackFrames, Variable, VariableManager, formatProperty } from './util/VariableManager'; import { AhkConfigurationProvider, CategoryData } from './extension'; import { version as debuggerAdapterVersion } from '../package.json'; import { SymbolFinder } from './util/SymbolFinder'; @@ -45,6 +45,9 @@ import { enableRunToEndOfFunction, setEnableRunToEndOfFunction } from './command import { CaseInsensitiveMap } from './util/CaseInsensitiveMap'; import { IntelliSense } from './util/IntelliSense'; import { maskQuotes } from './util/ExpressionExtractor'; +import { DebugDirectiveParser } from './util/DebugDirectiveParser'; +import { LogData, LogEvaluator } from './util/evaluator/LogEvaluator'; +import { ActionLogPrefixData, CategoryLogPrefixData, GroupLogPrefixData } from './util/evaluator/LogParser'; export type AnnounceLevel = boolean | 'error' | 'detail'; export type FunctionBreakPointAdvancedData = { name: string; condition?: string; hitCondition?: string; logPoint?: string }; @@ -170,6 +173,7 @@ export class AhkDebugSession extends LoggingDebugSession { private raisedCriticalError?: boolean; // The warning message is processed earlier than the server initialization, so it needs to be delayed. private readonly delayedWarningMessages: string[] = []; + private logEvalutor?: LogEvaluator; constructor(provider: AhkConfigurationProvider) { super('autohotkey-debug.txt'); @@ -1153,18 +1157,16 @@ export class AhkDebugSession extends LoggingDebugSession { await vscode.commands.executeCommand('workbench.debug.panel.action.clearReplAction'); } private async registerDebugDirective(): Promise { + if (!this.session) { + return; + } if (!this.config.useDebugDirective) { return; } - const { - useBreakpointDirective, - useOutputDirective, - useClearConsoleDirective, - } = this.config.useDebugDirective; + const { useBreakpointDirective, useOutputDirective, useClearConsoleDirective } = this.config.useDebugDirective; + const parser = new DebugDirectiveParser(this.session.ahkVersion); const filePathListChunked = chunk(await this.getAllLoadedSourcePath(), 50); // https://github.com/zero-plusplus/vscode-autohotkey-debug/issues/203 - - // const DEBUG_start = process.hrtime(); for await (const filePathList of filePathListChunked) { await Promise.all(filePathList.map(async(filePath) => { const document = await vscode.workspace.openTextDocument(filePath); @@ -1172,36 +1174,15 @@ export class AhkDebugSession extends LoggingDebugSession { await Promise.all(range(document.lineCount).map(async(line_0base) => { const textLine = document.lineAt(line_0base); - const match = textLine.text.match(/^\s*;\s*@Debug-(?[\w_]+)(?::(?[\w_:]+))?\s*(?=\(|\[|-|=|$)(?:\((?[^\n)]+)\))?\s*(?:\[(?[^\n]+)\])?\s*(?:(?->|=>)?(?\|)?(?.*))?$/ui); - if (!match?.groups) { + const parsed = parser.parse(textLine.text); + if (!parsed) { return; } - const directiveType = match.groups.directiveType.toLowerCase(); - const { - condition = '', - hitCondition = '', - outputOperator, - message = '', - } = match.groups; - const params = match.groups.params ? match.groups.params.split(':') : ''; - const removeLeadingSpace = match.groups.leaveLeadingSpace ? !match.groups.leaveLeadingSpace : true; - - let logMessage = message; - if (removeLeadingSpace) { - logMessage = logMessage.trimLeft(); - } - if (outputOperator === '=>') { - logMessage += '\n'; - } - + const { name: directiveName, condition, hitCondition, message: logMessage } = parsed; try { const line = line_0base + 1; - if (useBreakpointDirective && directiveType === 'breakpoint') { - if (0 < params.length) { - return; - } - + if (useBreakpointDirective && directiveName === 'breakpoint') { const advancedData = { condition, hitCondition, @@ -1211,30 +1192,17 @@ export class AhkDebugSession extends LoggingDebugSession { } as BreakpointAdvancedData; await this.breakpointManager!.registerBreakpoint(fileUri, line, advancedData); } - else if (useOutputDirective && directiveType === 'output') { - let logGroup: string | undefined; - if (0 < params.length) { - if (equalsIgnoreCase(params[0], 'start')) { - logGroup = 'start'; - } - else if (equalsIgnoreCase(params[0], 'startCollapsed')) { - logGroup = 'startCollapsed'; - } - else if (equalsIgnoreCase(params[0], 'end')) { - logGroup = 'end'; - } - } + else if (useOutputDirective && directiveName === 'output') { const newCondition = condition; const advancedData = { condition: newCondition, hitCondition, logMessage, - logGroup, hidden: true, } as BreakpointAdvancedData; await this.breakpointManager!.registerBreakpoint(fileUri, line, advancedData); } - else if (useClearConsoleDirective && directiveType === 'clearconsole') { + else if (useClearConsoleDirective && directiveName === 'clearconsole') { const advancedData = { condition, hitCondition, @@ -1253,12 +1221,6 @@ export class AhkDebugSession extends LoggingDebugSession { })); })); } - // const DEBUG_hrtime = process.hrtime(DEBUG_start); - // // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - // // eslint-disable-next-line no-mixed-operators - // const DEBUG_ns = DEBUG_hrtime[0] * 1e9 + DEBUG_hrtime[1]; - // const DEBUG_s = DEBUG_ns / 1e9; - // this.printLogMessage(`elapsedTime: ${DEBUG_s}s`); } private createBreakpointAction(action?: HiddenBreakpointActionName): BreakpointAction | undefined { if (typeof action === 'undefined') { @@ -2027,39 +1989,63 @@ export class AhkDebugSession extends LoggingDebugSession { return false; } private async printLogMessage(breakpoint: Breakpoint, logCategory?: LogCategory): Promise { - const { logMessage, logGroup = undefined, hitCount } = breakpoint; + const { logMessage, hitCount } = breakpoint; const metaVariables = new MetaVariableValueMap(this.currentMetaVariableMap.entries()); metaVariables.set('hitCount', hitCount); try { - const evalucatedMessages = await this.evaluateLog(logMessage, { file: breakpoint.filePath, line: breakpoint.line }); - const stringMessages = evalucatedMessages.filter((message) => typeof message === 'string' || typeof message === 'number') as string[]; - const objectMessages = evalucatedMessages.filter((message) => typeof message === 'object') as Array; - if (objectMessages.length === 0) { - const event: DebugProtocol.OutputEvent = new OutputEvent(stringMessages.join(''), logCategory); - event.body.group = logGroup; - this.sendEvent(event); + const logDataList = await timeoutPromise(this.logEvalutor!.eval(logMessage), 5000).catch((e: unknown) => { + const messageHead = `Log Error at ${breakpoint.filePath}:${breakpoint.line}`; + if (e instanceof ParseError) { + const messageBody = e.message.split(/\r\n|\n/u).slice(1).join('\n'); + this.sendAnnounce(`${messageHead}\n${messageBody}`, 'stderr'); + return; + } + else if (e instanceof Error) { + this.sendAnnounce(`${messageHead}\n${e.message}`, 'stderr'); + return; + } + + if (e instanceof dbgp.DbgpCriticalError) { + this.criticalError(e.message); + return; + } + + // If the message is output in disconnectRequest, it may not be displayed, so output it here + this.isTimeout = true; + this.sendAnnounce('Debugging stopped for the following reasons: Timeout occurred in communication with the debugger when the following log was output.', 'stderr'); + this.sendAnnounce(`[${breakpoint.filePath}:${breakpoint.line}] ${logMessage}`, 'stderr'); + this.sendTerminateEvent(); + }); + + if (!logDataList) { return; } - let label = evalucatedMessages.reduce((prev: string, current): string => { - if (typeof current === 'string' || typeof current === 'number') { - return `${prev}${current}`; + const invokeBeforeAction = async(actionPrefixes?: ActionLogPrefixData[]): Promise => { + const clearAction = actionPrefixes?.find((action) => action.value === 'clear'); + if (clearAction) { + await (this.createBreakpointAction('ClearConsole')?.()); } - return prev; - }, ''); - if (!label) { - label = objectMessages.reduce((prev, current) => (prev ? `${prev}, ${current.name}` : current.name), ''); - } + }; + const invokeAfterAction = async(actionPrefixes?: ActionLogPrefixData[]): Promise => { + const breakAction = actionPrefixes?.find((action) => action.value === 'break'); + if (breakAction) { + this.sendStoppedEvent('breakpoint'); + } + return Promise.resolve(); + }; - const variableGroup = new MetaVariable(label, objectMessages.map((obj) => new MetaVariable(obj.name, obj))); - const variablesReference = this.variableManager!.createVariableReference(variableGroup); - this.logObjectsMap.set(variablesReference, variableGroup); + for await (const logData of logDataList) { + const logActions = logData.prefixes.filter((prefix): prefix is ActionLogPrefixData => prefix.type === 'action'); - const event: DebugProtocol.OutputEvent = new OutputEvent(label, logCategory); - event.body.group = logGroup; - event.body.variablesReference = variablesReference; - this.sendEvent(event); + await invokeBeforeAction(logActions); + const outputEvent = this.createOutputEvent(logData, { source: { path: breakpoint.filePath }, line: breakpoint.line }); + if (outputEvent) { + this.sendEvent(outputEvent); + } + await invokeAfterAction(logActions); + } } catch (e: unknown) { if (e instanceof dbgp.DbgpCriticalError) { @@ -2067,93 +2053,44 @@ export class AhkDebugSession extends LoggingDebugSession { } } } + private createOutputEvent(logData: LogData, extraInfo?: Partial): DebugProtocol.OutputEvent | undefined { + const label = logData.type === 'primitive' ? String(logData.value) : logData.label; + if ((/^\s*$/u).test(label)) { + return undefined; + } + + const group = logData.prefixes.reverse().find((prefix) => prefix.type === 'group') as GroupLogPrefixData | undefined; + const category = logData.prefixes.reverse().find((prefix) => prefix.type === 'category') as CategoryLogPrefixData | undefined; + const createCategory = (category: CategoryLogPrefixData | undefined): string | undefined => { + switch (category?.value) { + case 'error': return 'stderr'; + case 'info': return 'console'; + case 'nortice': return 'important'; + default: return undefined; + } + }; + + const event: DebugProtocol.OutputEvent = new OutputEvent(label, createCategory(category)); + if (logData.type === 'object') { + const variableGroup = new MetaVariable(logData.label, logData.value.map((obj) => new MetaVariable(obj.name, obj))); + const variablesReference = this.variableManager!.createVariableReference(variableGroup); + this.logObjectsMap.set(variablesReference, variableGroup); + event.body.variablesReference = variablesReference; + } + + event.body.group = group?.value; + event.body = { + ...extraInfo, + ...event.body, + }; + return event; + } private criticalError(message: string): void { this.raisedCriticalError = true; const fixedMessage = this.fixPathOfRuntimeError(message); this.sendAnnounce(fixedMessage, 'stderr'); this.sendTerminateEvent(); } - private async evaluateLog(format: string, source: { file: string; line: number }): Promise { - const results: MetaVariableValue[] = []; - - let current = ''; - try { - let blockCount = 0; - for (let i = 0; i < format.length; i++) { - const char = format.charAt(i); - - if (0 < blockCount) { - if (char === '}') { - blockCount--; - if (blockCount === 0) { - const timeout_ms = 30 * 1000; - if (current === '') { - results.push('{}'); - continue; - } - // eslint-disable-next-line no-await-in-loop - const value = await timeoutPromise(this.evaluator.eval(current), timeout_ms).catch((e: unknown) => { - if (e instanceof dbgp.DbgpCriticalError) { - this.criticalError(e.message); - return; - } - - // If the message is output in disconnectRequest, it may not be displayed, so output it here - this.isTimeout = true; - this.sendAnnounce('Debugging stopped for the following reasons: Timeout occurred in communication with the debugger when the following log was output', 'stderr'); - this.sendAnnounce(`[${source.file}:${source.line}] ${format}`, 'stderr'); - this.sendTerminateEvent(); - }); - - if (value instanceof dbgp.ObjectProperty) { - results.push(new Variable(this.session!, value)); - } - else if (typeof value !== 'undefined') { - results.push(value); - } - current = ''; - } - continue; - } - - current += char; - continue; - } - - if (char === '\\' && [ '{' ].includes(format.charAt(i + 1))) { - current += format.slice(i + 1, i + 2); - i++; - continue; - } - - if (char === '{') { - blockCount++; - if (current) { - results.push(current); - current = ''; - } - continue; - } - - current += char; - } - - if (current) { - results.push(current); - } - } - catch (e: unknown) { - const messageHead = `Log Error at ${source.file}:${source.line}`; - if (e instanceof ParseError) { - const messageBody = e.message.split(/\r\n|\n/u).slice(1).join('\n'); - this.sendAnnounce(`${messageHead}\n${messageBody}`, 'stderr'); - } - else if (e instanceof Error) { - this.sendAnnounce(`${messageHead}\n${e.message}`, 'stderr'); - } - } - return results; - } private async displayPerfTips(metaVariableMap: MetaVariableValueMap): Promise { if (!this.config.usePerfTips) { return; @@ -2171,13 +2108,24 @@ export class AhkDebugSession extends LoggingDebugSession { } const { format } = this.config.usePerfTips; - const message = (await this.evaluateLog(format, { file: source.path, line: line_0base })).reduce((prev: string, current) => { - if (typeof current === 'string') { - return prev + current; + const logDataList = await timeoutPromise(this.logEvalutor!.eval(format), 5000).catch((e: unknown) => { + if (e instanceof dbgp.DbgpCriticalError) { + this.criticalError(e.message); + return; } - return prev; - }, ''); + // If the message is output in disconnectRequest, it may not be displayed, so output it here + this.isTimeout = true; + this.sendAnnounce('Debugging stopped for the following reasons: Timeout occurred in communication with the debugger when the following perftips was output.', 'stderr'); + this.sendAnnounce(`[${source.path}:${line}] ${format}`, 'stderr'); + this.sendTerminateEvent(); + }); + + if (!logDataList || logDataList.length === 0 || logDataList[0].type === 'object') { + return; + } + + const message = String(logDataList[0].value); const decorationType = vscode.window.createTextEditorDecorationType({ after: { fontStyle: this.config.usePerfTips.fontStyle, @@ -2236,6 +2184,7 @@ export class AhkDebugSession extends LoggingDebugSession { } this.evaluator = new ExpressionEvaluator(this.session, this.currentMetaVariableMap); + this.logEvalutor = new LogEvaluator(this.evaluator); this.intellisense = new IntelliSense(this.session); completionItemProvider.useIntelliSenseInDebugging = this.config.useIntelliSenseInDebugging; completionItemProvider.intellisense = this.intellisense; diff --git a/src/util/DebugDirectiveParser.ts b/src/util/DebugDirectiveParser.ts new file mode 100644 index 00000000..51c48c67 --- /dev/null +++ b/src/util/DebugDirectiveParser.ts @@ -0,0 +1,125 @@ +import { AhkVersion } from '@zero-plusplus/autohotkey-utilities'; +import { maskQuotes } from './ExpressionExtractor'; +import { searchPair } from './util'; + +export const directiveNames = [ + 'breakpoint', + 'output', + 'clearconsole', +] as const; +export type DebugDirectiveName = typeof directiveNames[number]; +export interface DebugDirectiveParsedData { + name: DebugDirectiveName; + condition?: string; + hitCondition?: string; + message?: string; +} + +export class DebugDirectiveParser { + public readonly ahkVersion: AhkVersion; + constructor(ahkVersion: AhkVersion) { + this.ahkVersion = ahkVersion; + } + public parse(text: string): DebugDirectiveParsedData | undefined { + const match = text.match(/^\s*(?[\w_]*))(\s*(,|\s)\s*|\s*$)/ui); + if (match?.index === undefined) { + return undefined; + } + + const type = this.normalizeType(match.groups?.type); + if (!type) { + return undefined; + } + + let condition: string | undefined; + let hitCondition: string | undefined; + let expression = text.slice(match.index + match[0].length); + if ((/^\s*$/u).test(expression)) { + return { name: type }; + } + + const parsedCondition = this.parseCondition(expression); + if (parsedCondition) { + condition = parsedCondition.condition; + hitCondition = parsedCondition.hitCondition; + expression = parsedCondition.expression; + } + + const operatorMatch = expression.match(/^(?(?)(\|)?)/u); + const operator = operatorMatch?.groups?.operator ?? ''; + expression = expression.slice(operatorMatch?.index ? operatorMatch.index + operatorMatch[0].length : 0); + switch (operator) { + case '->': { + expression = expression.slice('->'.length).trim(); + break; + } + case '->|': { + expression = expression.slice('->|'.length).trimRight(); + break; + } + case '=>': { + expression = `${expression.slice('=>'.length).trim()}\n`; + break; + } + case '=>|': { + expression = `${expression.slice('=>|'.length).trimRight()}\n`; + break; + } + default: { + if ((/^`(-|=)/u).test(expression)) { + expression = expression.slice('`'.length); + } + expression = `${expression.trim()}\n`; + break; + } + } + return { + name: type, + condition, + hitCondition, + message: expression, + }; + } + private parseCondition(expression: string): { condition?: string; hitCondition?: string; expression: string } | undefined { + let condition: string | undefined; + let hitCondition: string | undefined; + let currentExpression = expression.trimLeft(); + for (let i = 0, max = 2; i < max; i++) { + if (currentExpression.startsWith('(')) { + const masked = maskQuotes(this.ahkVersion, currentExpression); + const index = searchPair(masked, '(', ')'); + if (-1 < index) { + condition = currentExpression.slice(1, index).trim(); + currentExpression = currentExpression.slice(index + 1).trimLeft(); + } + } + else if (currentExpression.startsWith('[')) { + const masked = maskQuotes(this.ahkVersion, currentExpression); + const index = searchPair(masked, '[', ']'); + if (-1 < index) { + hitCondition = currentExpression.slice(1, index).trim(); + currentExpression = currentExpression.slice(index + 1).trimLeft(); + } + } + currentExpression.trimLeft(); + } + + return { + condition, + hitCondition, + expression: currentExpression, + }; + } + private normalizeType(type: string | undefined): DebugDirectiveName | undefined { + if (!type) { + return undefined; + } + + const regexp = new RegExp(`${directiveNames.join('|')}`, 'ui'); + if (!regexp.test(type)) { + return undefined; + } + + return type.toLowerCase() as DebugDirectiveName; + } +} diff --git a/src/util/evaluator/ExpressionEvaluator.ts b/src/util/evaluator/ExpressionEvaluator.ts index 393c542f..11f2dea1 100644 --- a/src/util/evaluator/ExpressionEvaluator.ts +++ b/src/util/evaluator/ExpressionEvaluator.ts @@ -116,7 +116,9 @@ export type Function = (...params: any[]) => string | number | undefined; export type Library = { [key: string]: (...params: any[]) => string | number | undefined; }; -export type EvaluatedValue = undefined | string | number | dbgp.ObjectProperty | MetaVariable; +export type EvaluatedValue = undefined | EvaluatedPrimitiveValue | EvaluatedObjectValue; +export type EvaluatedPrimitiveValue = string | number; +export type EvaluatedObjectValue = dbgp.ObjectProperty | MetaVariable; export class EvaluatedNode { public readonly type: string; public readonly node: ohm.Node | ohm.Node[]; @@ -436,17 +438,19 @@ export class ParseError extends Error { } export class ExpressionEvaluator { public readonly session: dbgp.Session; + public readonly ahkVersion: AhkVersion; private readonly metaVariableMap: MetaVariableValueMap; private readonly parser: ExpressionParser; private readonly library: CaseInsensitiveMap; private readonly withoutFunction: boolean; constructor(session: dbgp.Session, metaVariableMap?: MetaVariableValueMap, withoutFunction = false) { this.session = session; + this.ahkVersion = session.ahkVersion; this.metaVariableMap = metaVariableMap ?? new MetaVariableValueMap(); - this.library = 2.0 <= session.ahkVersion.mejor + this.library = 2.0 <= this.ahkVersion.mejor ? library_for_v2 : library_for_v1; - this.parser = new ExpressionParser(session.ahkVersion); + this.parser = new ExpressionParser(this.ahkVersion); this.withoutFunction = withoutFunction; } public async eval(expression: string, stackFrame?: dbgp.StackFrame, maxDepth = 1): Promise { diff --git a/src/util/evaluator/LogEvaluator.ts b/src/util/evaluator/LogEvaluator.ts new file mode 100644 index 00000000..c860bb0c --- /dev/null +++ b/src/util/evaluator/LogEvaluator.ts @@ -0,0 +1,122 @@ +import { maskQuotes } from '../ExpressionExtractor'; +import { searchPair } from '../util'; +import { EvaluatedObjectValue, EvaluatedPrimitiveValue, EvaluatedValue, ExpressionEvaluator } from './ExpressionEvaluator'; +import { LogParser, LogPrefixData } from './LogParser'; + +export type LogData = PrimitiveLogData | ObjectLogData; +export interface LogDataBase { + prefixes: LogPrefixData[]; +} +export interface PrimitiveLogData extends LogDataBase { + type: 'primitive'; + value: EvaluatedPrimitiveValue; +} +export interface ObjectLogData extends LogDataBase { + type: 'object'; + label: string; + value: EvaluatedObjectValue[]; +} + +export class LogEvaluator { + public readonly expressionEvaluator: ExpressionEvaluator; + private readonly parser: LogParser; + constructor(evaluator: ExpressionEvaluator) { + this.expressionEvaluator = evaluator; + this.parser = new LogParser(); + } + public async eval(text: string): Promise { + const parsedData = this.parser.parse(text); + return (await Promise.all(parsedData.map(async(data): Promise => { + const values = await this.evalLogText(data.message); + if (values.length === 0) { + return [ + { + type: 'primitive', + prefixes: data.prefixes, + value: '', + }, + ]; + } + + let labelStack: string[] = []; + let objectStack: EvaluatedObjectValue[] = []; + const results: LogData[] = []; + for (let i = 0; i < values.length; i++) { + const value = values[i]; + const nextValue = values[i + 1]; + if (typeof value === 'object') { + objectStack.push(value); + if (typeof nextValue === 'object') { + continue; + } + + results.push({ + type: 'object', + prefixes: data.prefixes, + label: labelStack.join(''), + value: objectStack.slice(), + }); + labelStack = []; + objectStack = []; + continue; + } + + labelStack.push(String(value ?? '')); + } + + if (0 < labelStack.length) { + results.push({ + type: 'primitive', + prefixes: data.prefixes, + value: labelStack.join(''), + }); + labelStack = []; + } + + return results; + }))).flat(); + } + private async evalLogText(text: string): Promise { + const chars = text.split(''); + + const results: EvaluatedValue[] = []; + let current = ''; + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; + switch (char) { + case '{': { + if (current !== '') { + results.push(current); + current = ''; + } + + const afterText = text.slice(i); + const maskedAfterText = maskQuotes(this.expressionEvaluator.ahkVersion, afterText); + const pairIndex = searchPair(maskedAfterText, '{', '}'); + if (pairIndex === -1) { + current += afterText; + break; + } + const expression = afterText.slice(1, pairIndex); // {expression} -> expression + // eslint-disable-next-line no-await-in-loop + const evalutedValue = await this.expressionEvaluator.eval(expression); + if (evalutedValue !== undefined) { + results.push(evalutedValue); + } + + i += pairIndex; + break; + } + default: { + current += char; + break; + } + } + } + + if (current !== '') { + results.push(current); + } + return results; + } +} diff --git a/src/util/evaluator/LogParser.ts b/src/util/evaluator/LogParser.ts new file mode 100644 index 00000000..6e366360 --- /dev/null +++ b/src/util/evaluator/LogParser.ts @@ -0,0 +1,128 @@ +export const groupLogPrefixes = [ + 'startCollapsed', + 'start', + 'end', +] as const; +export const categoryLogPrefixes = [ + 'info', + 'error', + 'nortice', +] as const; + +export const actionLogPrefixes = [ + 'break', + 'clear', +] as const; +export const logPrefixes = [ + ...groupLogPrefixes, + ...categoryLogPrefixes, + ...actionLogPrefixes, +] as const; +export type LogPrefix = GroupLogPrefix | CategoryLogPrefix | ActionLogPrefix; +export type GroupLogPrefix = typeof groupLogPrefixes[number]; +export type CategoryLogPrefix = typeof categoryLogPrefixes[number]; +export type ActionLogPrefix = typeof actionLogPrefixes[number]; + +export type LogPrefixData = CategoryLogPrefixData | GroupLogPrefixData | ActionLogPrefixData; +export interface GroupLogPrefixData { + type: 'group'; + value: GroupLogPrefix; +} +export interface CategoryLogPrefixData { + type: 'category'; + value: CategoryLogPrefix; +} +export interface ActionLogPrefixData { + type: 'action'; + value: ActionLogPrefix; +} + +export interface ParsedLogData { + prefixes: LogPrefixData[]; + message: string; +} + +export const logPrefixeRegExp = new RegExp(`${logPrefixes.map((prefix) => `\\{:${prefix}:\\}`).join('|')}`, 'gui'); +export const logPrefixesRegExp = new RegExp(`(${logPrefixeRegExp.source})+`, 'gui'); +export class LogParser { + public parse(text: string): ParsedLogData[] { + const logPrefixRegExp = new RegExp(`${logPrefixes.map((prefix) => `\\{:${prefix}:\\}`).join('|')}`, 'gui'); + const logPrefixesRegExp = new RegExp(`(${logPrefixRegExp.source})+`, 'gui'); + + const prefixesMatches = [ ...text.matchAll(logPrefixesRegExp) ]; + return this.splitLogText(text, prefixesMatches); + } + private splitLogText(text: string, matches: RegExpMatchArray[]): ParsedLogData[] { + if (matches.length === 0) { + return [ { prefixes: [], message: text } ]; + } + + let currentIndex = 0; + let prefixes: LogPrefixData[] = []; + const splited: ParsedLogData[] = [ ]; + for (const match of matches) { + if (match.index !== undefined) { + const message = text.slice(currentIndex, match.index); + if (message !== '') { + splited.push({ + prefixes, + message, + }); + currentIndex += message.length; + } + + const nextIndex = match.index + match[0].length; + const rawPrefixes = text.slice(currentIndex, nextIndex); + prefixes = this.normalizePrefixes(rawPrefixes) ?? []; + + currentIndex = nextIndex; + continue; + } + + break; + } + + splited.push({ + prefixes, + message: text.slice(currentIndex), + }); + return splited; + } + private normalizePrefixes(rawPrefixes: string | undefined): LogPrefixData[] | undefined { + if (!rawPrefixes) { + return undefined; + } + + return [ ...rawPrefixes.matchAll(new RegExp(`\\{:(${logPrefixes.join('|')}):\\}`, 'gui')) ].map((match) => { + const prefix = match[0] + .slice(2, -2) // {:Break:} -> break + .toLowerCase(); + + switch (prefix) { + case '>>': return 'startCollapsed'; + case '>': return 'start'; + case '<': return 'end'; + case 'startcollapsed': return 'startCollapsed'; + default: return prefix as LogPrefix; + } + }).map((prefix) => this.createPrefixData(prefix)); + } + private createPrefixData(prefix: LogPrefix): LogPrefixData { + if (groupLogPrefixes.some((groupPrefix) => groupPrefix === prefix)) { + return { + type: 'group', + value: prefix as GroupLogPrefix, + }; + } + if (categoryLogPrefixes.some((categoryPrefix) => categoryPrefix === prefix)) { + return { + type: 'category', + value: prefix as CategoryLogPrefix, + }; + } + return { + type: 'action', + value: prefix as ActionLogPrefix, + }; + } +} diff --git a/test/DebugDirectiveParser.test.ts b/test/DebugDirectiveParser.test.ts new file mode 100644 index 00000000..1f3886fb --- /dev/null +++ b/test/DebugDirectiveParser.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from '@jest/globals'; +import { AhkVersion } from '@zero-plusplus/autohotkey-utilities'; +import { DebugDirectiveParsedData, DebugDirectiveParser } from '../src/util/DebugDirectiveParser'; + +const createResult = (data: Partial): Omit => { + return { + message: undefined, + condition: undefined, + hitCondition: undefined, + ...data, + }; +}; + +describe('DebugDirective', () => { + const parser = new DebugDirectiveParser(new AhkVersion('1.0')); + test('parse', () => { + expect(parser.parse('; @debug-breakpoint')).toEqual(createResult({ name: 'breakpoint' })); + expect(parser.parse('; @debug-output test')).toEqual(createResult({ name: 'output', message: 'test\n' })); + expect(parser.parse('; @Debug-Output, test')).toEqual(createResult({ name: 'output', message: 'test\n' })); + expect(parser.parse('; @Debug-Output, => test')).toEqual(createResult({ name: 'output', message: 'test\n' })); + expect(parser.parse('; @Debug-Output, =>| test')).toEqual(createResult({ name: 'output', message: ' test\n' })); + expect(parser.parse('; @Debug-Output, -> test')).toEqual(createResult({ name: 'output', message: 'test' })); + expect(parser.parse('; @Debug-Output, ->| test')).toEqual(createResult({ name: 'output', message: ' test' })); + expect(parser.parse('; @Debug-Output, `=> test')).toEqual(createResult({ name: 'output', message: '=> test\n' })); + }); + test('parse failure', () => { + expect(parser.parse(';; @Debug-Output, test')).toEqual(undefined); + expect(parser.parse('; aa ; @Debug-Output, test')).toEqual(undefined); + expect(parser.parse('; a @Debug-Output, test')).toEqual(undefined); + }); + test('parse with condition', () => { + expect(parser.parse('; @debug-breakpoint (a == b)test')).toEqual(createResult({ name: 'breakpoint', condition: 'a == b', message: 'test\n' })); + expect(parser.parse('; @debug-breakpoint (a == b) test')).toEqual(createResult({ name: 'breakpoint', condition: 'a == b', message: 'test\n' })); + expect(parser.parse('; @debug-breakpoint (a == b) => test')).toEqual(createResult({ name: 'breakpoint', condition: 'a == b', message: 'test\n' })); + expect(parser.parse('; @debug-breakpoint (a == b)[=2] -> test')).toEqual(createResult({ name: 'breakpoint', condition: 'a == b', hitCondition: '=2', message: 'test' })); + expect(parser.parse('; @debug-breakpoint [=2](a == b) -> test')).toEqual(createResult({ name: 'breakpoint', condition: 'a == b', hitCondition: '=2', message: 'test' })); + expect(parser.parse('; @debug-breakpoint [=2] (a == b) -> test')).toEqual(createResult({ name: 'breakpoint', condition: 'a == b', hitCondition: '=2', message: 'test' })); + }); +}); diff --git a/test/SymbolFinderFinder/SymbolFinder.test.ts b/test/SymbolFinderFinder/SymbolFinder.test.ts index d9045124..80c207fe 100644 --- a/test/SymbolFinderFinder/SymbolFinder.test.ts +++ b/test/SymbolFinderFinder/SymbolFinder.test.ts @@ -6,30 +6,20 @@ const sampleDir = path.resolve(`${__dirname}/sample`); describe('SymbolFinder', () => { const finder = new SymbolFinder('1.1.35.0'); - test('v1-1', () => { + test('v1', () => { const result = finder.find(`${sampleDir}/A.ahk`).filter((node) => [ 'function', 'getter', 'setter' ].includes(node.type)) as NamedNodeBase[]; - expect(result[0].fullname).toBe('A.B.Accessor_1.get'); - expect(result[1].fullname).toBe('A.B.Accessor_1.set'); - expect(result[2].fullname).toBe('A.B.Accessor_2.get'); - expect(result[3].fullname).toBe('A.B.B_Method_1'); - expect(result[4].fullname).toBe('A.B.B_Method_2'); - expect(result[5].fullname).toBe('A.A_Method_1.A_Method_1_Inner'); - expect(result[6].fullname).toBe('A.A_Method_1'); - expect(result[7].fullname).toBe('A.A_Method_2'); - expect(result[8].fullname).toBe('A.C.C_Method_1'); - expect(result[9].fullname).toBe('Func_1'); - expect(result[10].fullname).toBe('Func_2'); - }); - test('v1-2', () => { - const result = finder.find(`${__dirname}/../../demo/demo.ahk`).filter((node) => [ 'function', 'getter', 'setter' ].includes(node.type)) as NamedNodeBase[]; - - expect(result[0].fullname).toBe('Util_CreateLargeArray'); - expect(result[1].fullname).toBe('Util_CreateGiantArray'); - expect(result[2].fullname).toBe('Util_CreateMaxSizeArray'); - expect(result[3].fullname).toBe('demo'); - expect(result[4].fullname).toBe('Clazz.property.get'); - expect(result[5].fullname).toBe('Clazz.property.set'); - expect(result[6].fullname).toBe('Clazz.method'); + expect(result[0].fullname).toBe('Lib_Test'); + expect(result[1].fullname).toBe('Func_1'); + expect(result[2].fullname).toBe('Func_2'); + expect(result[3].fullname).toBe('A.B.Accessor_1.get'); + expect(result[4].fullname).toBe('A.B.Accessor_1.set'); + expect(result[5].fullname).toBe('A.B.Accessor_2.get'); + expect(result[6].fullname).toBe('A.B.B_Method_1'); + expect(result[7].fullname).toBe('A.B.B_Method_2'); + expect(result[8].fullname).toBe('A.A_Method_1.A_Method_1_Inner'); + expect(result[9].fullname).toBe('A.A_Method_1'); + expect(result[10].fullname).toBe('A.A_Method_2'); + expect(result[11].fullname).toBe('A.C.C_Method_1'); }); }); diff --git a/test/SymbolFinderFinder/sample/A.ahk b/test/SymbolFinderFinder/sample/A.ahk index 86a7aa36..0ad0cf8b 100644 --- a/test/SymbolFinderFinder/sample/A.ahk +++ b/test/SymbolFinderFinder/sample/A.ahk @@ -1,4 +1,12 @@ -/** +Lib_Test() + +Func_1() { +} +Func_2() +{ +} + +/** * abc */ class A { @@ -19,9 +27,4 @@ class A { C_Method_1() { } } -} -Func_1() { -} -Func_2() -{ } \ No newline at end of file diff --git a/test/SymbolFinderFinder/sample/lib/Lib.ahk b/test/SymbolFinderFinder/sample/lib/Lib.ahk new file mode 100644 index 00000000..98718c18 --- /dev/null +++ b/test/SymbolFinderFinder/sample/lib/Lib.ahk @@ -0,0 +1,2 @@ +Lib_Test() { +} \ No newline at end of file diff --git a/test/evaluator/LogEvaluator.test.ts b/test/evaluator/LogEvaluator.test.ts new file mode 100644 index 00000000..dabd13a8 --- /dev/null +++ b/test/evaluator/LogEvaluator.test.ts @@ -0,0 +1,82 @@ +import * as net from 'net'; +import { ChildProcess } from 'child_process'; +import * as path from 'path'; +import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; +import * as dbgp from '../../src/dbgpSession'; +import { ExpressionEvaluator } from '../../src/util/evaluator/ExpressionEvaluator'; +import { closeSession, launchDebug } from '../util'; +import { LogData, LogEvaluator } from '../../src/util/evaluator/LogEvaluator'; + +const sampleDir = path.resolve(__dirname, 'ahk'); +const hostname = '127.0.0.1'; + +const simplifyDataList = (dataList: LogData[]): any => { + return dataList.map((data) => { + if (data.type === 'primitive') { + return { + ...data, + prefixes: data.prefixes.map((prefix) => { + return prefix.value; + }), + }; + } + + return { + ...data, + prefixes: data.prefixes.map((prefix) => { + return prefix.value; + }), + value: data.value.map((value) => { + return typeof value === 'object' ? value.name : value; + }), + }; + }); +}; + +describe('LogEvaluator for AutoHotkey-v1', (): void => { + let process: ChildProcess; + let server: net.Server | undefined; + let session: dbgp.Session; + let evaluator: LogEvaluator; + + beforeAll(async() => { + const data = await launchDebug('AutoHotkey.exe', path.resolve(sampleDir, 'sample.ahk'), 49156, hostname); + process = data.process; + server = data.server; + session = data.session; + + evaluator = new LogEvaluator(new ExpressionEvaluator(session)); + }); + afterAll(async() => { + server?.close(); + await closeSession(session, process); + }); + + test('eval', async() => { + expect(await evaluator.eval('label: {str_alpha}')).toEqual([ { type: 'primitive', prefixes: [], value: 'label: aBc' } ]); + expect(simplifyDataList(await evaluator.eval('label: {str_alpha}{obj}'))).toEqual([ { type: 'object', prefixes: [], label: 'label: aBc', value: [ 'obj' ] } ]); + expect(simplifyDataList(await evaluator.eval('[obj]{obj}'))).toEqual([ { type: 'object', prefixes: [], label: '[obj]', value: [ 'obj' ] } ]); + expect(simplifyDataList(await evaluator.eval('[obj, T]{obj}{T}'))).toEqual([ { type: 'object', prefixes: [], label: '[obj, T]', value: [ 'obj', 'T' ] } ]); + + expect(simplifyDataList(await evaluator.eval('label: {str_alpha}{obj}label: {str_alpha}'))).toEqual([ + { type: 'object', prefixes: [], label: 'label: aBc', value: [ 'obj' ] }, + { type: 'primitive', prefixes: [], value: 'label: aBc' }, + ]); + expect(simplifyDataList(await evaluator.eval('label: {str_alpha}{obj}[obj, T]{obj}{T}'))).toEqual([ + { type: 'object', prefixes: [], label: 'label: aBc', value: [ 'obj' ] }, + { type: 'object', prefixes: [], label: '[obj, T]', value: [ 'obj', 'T' ] }, + ]); + }); + test('eval with prefix', async() => { + expect(simplifyDataList(await evaluator.eval('{:break:}label: {str_alpha}'))).toEqual([ { type: 'primitive', prefixes: [ 'break' ], value: 'label: aBc' } ]); + expect(simplifyDataList(await evaluator.eval('{:break:}{:error:}label: {str_alpha}{:break:}'))).toEqual([ + { type: 'primitive', prefixes: [ 'break', 'error' ], value: 'label: aBc' }, + { type: 'primitive', prefixes: [ 'break' ], value: '' }, + ]); + expect(simplifyDataList(await evaluator.eval('{:start:}A{:startCollapsed:}A-A{:end:}{:end:}'))).toEqual([ + { type: 'primitive', prefixes: [ 'start' ], value: 'A' }, + { type: 'primitive', prefixes: [ 'startCollapsed' ], value: 'A-A' }, + { type: 'primitive', prefixes: [ 'end', 'end' ], value: '' }, + ]); + }); +}); diff --git a/test/evaluator/LogParser.test.ts b/test/evaluator/LogParser.test.ts new file mode 100644 index 00000000..495cf0e3 --- /dev/null +++ b/test/evaluator/LogParser.test.ts @@ -0,0 +1,29 @@ +import { describe, expect } from '@jest/globals'; +import { LogParser, ParsedLogData } from '../../src/util/evaluator/LogParser'; + +const simplifyDataList = (dataList: ParsedLogData[]): any => { + return dataList.map((data) => { + return { + ...data, + prefixes: data.prefixes.map((prefix) => { + return prefix.value; + }), + }; + }); +}; + +describe('LogParser', () => { + const parser = new LogParser(); + + test('parse', () => { + expect(simplifyDataList(parser.parse('{:startCollapsed:}A-A'))).toEqual([ { prefixes: [ 'startCollapsed' ], message: 'A-A' } ]); + expect(simplifyDataList(parser.parse('{:break:}{:error:}label: {str_alpha}{:break:}'))).toEqual([ + { prefixes: [ 'break', 'error' ], message: 'label: {str_alpha}' }, + { prefixes: [ 'break' ], message: '' }, + ]); + expect(simplifyDataList(parser.parse('abc{:break:}abc'))).toEqual([ + { prefixes: [], message: 'abc' }, + { prefixes: [ 'break' ], message: 'abc' }, + ]); + }); +});