From 8f5e823873e3e23f19c5d577fe609f3b55b51c6c Mon Sep 17 00:00:00 2001 From: Bjoern Hoehrmann Date: Sat, 16 Mar 2019 21:52:42 +0100 Subject: [PATCH] fix(variables): fix variable handling --- debugger-functions.pl | 630 +++++++++++++++++++++++ src/adapter.ts | 330 ++++++------ src/extension.ts | 10 +- src/perlDebug.ts | 848 ++++++++++++++----------------- src/remoteSession.ts | 11 +- src/tests/adapter.test.ts | 131 ++++- src/tests/connection.test.ts | 22 - src/tests/data/vars_test.pl | 38 ++ src/tests/variableParser.test.ts | 257 ---------- src/variableParser.ts | 203 -------- 10 files changed, 1333 insertions(+), 1147 deletions(-) create mode 100644 debugger-functions.pl create mode 100644 src/tests/data/vars_test.pl delete mode 100644 src/tests/variableParser.test.ts delete mode 100644 src/variableParser.ts diff --git a/debugger-functions.pl b/debugger-functions.pl new file mode 100644 index 0000000..1e0d7ae --- /dev/null +++ b/debugger-functions.pl @@ -0,0 +1,630 @@ +package DB; + +##################################################################### +# Copyright (c) 2019 Bjoern Hoehrmann . Licensed +# under the same terms as https://github.com/raix/vscode-perl-debug/ +##################################################################### + +##################################################################### +# Since `perl5db.pl` is intended for interactive use by human users, +# it can be difficult to extract information through the commands it +# provides. This module provides some helper functions to work around +# such problems on a best-effort basis. Additions very welcome. +##################################################################### + +##################################################################### +# FIXME: All the functions defined here are only for the extension. +# Like `perl5db.pl` does for its functions, the debugger should be +# essentially disabled while executing them. For instance, when users +# set `w $DB::package` manually in the debugger, or function break- +# points on these functions, that should not cause the debugger to +# stop inside our functions. To the extent possible. Perhaps we could +# do something akin to `local($DB::trace, $DB::single);` towards the +# desired effect? +##################################################################### + +##################################################################### +# FIXME: As of 2019-03 this completely ignores overloaded objects. In +# part as a feature, since some object have impractical overloads, +# for instance, `Graph.pm` has a stringify overload that essentially +# dumps the whole graph which can be very large; and it is not clear +# where and how it would make sense to consider overloads. +##################################################################### + +BEGIN { + + # Try to load various modules we want to use when available. When + # they are not available or fail to load, the rest of the code in + # general tries to work around their absence. + + eval "require Scalar::Util"; # core since v5.7.3 + eval "require Sys::Hostname"; # core since v5 + eval "require Sub::Identify"; # not in core as of 5.28.1 + eval "require PadWalker"; # not in core as of 5.28.1 + +}; + +# Package variable to indicate the code here has already been loaded, +# so it is not loaded again when a process is forked (is this really +# needed, or does Perl notice that through %INC or somesuch?) +$Devel::vscode::DEBUGGER_FUNCTIONS = 1; + +##################################################################### +# JSON encoding functions. +##################################################################### + +sub Devel::vscode::_json_encode_string { + + my ($s) = @_; + + return 'null' unless defined $s; + + $s =~ s/(["\\\x00-\x1F\x80-\xff])/ + sprintf "\\u%04x", ord($1) + /ge; + + return "\"$s\""; +} + +sub Devel::vscode::_json_encode_array { + + my ($r) = @_; + + return '[' . join(',', map { + ref($_) + ? Devel::vscode::_json_encode_array($_) + : Devel::vscode::_json_encode_string($_) + } @$r) . ']' + +} + +##################################################################### +# Sorting functions. +##################################################################### + +sub Devel::vscode::_sort_without_sigil { + + # It probably makes sense to put %INC and @INC next to one another, + # so this function allows sorting ignoring the sigil. It should not + # be used when sorting keys in hashes, sigils are meaningless there. + + return sort { + + my ($k1, $k2) = map { + + /^[\%\$\@\&]?(.*)/s; + $1 + + } map { + + defined($_) ? $_ : '' + + } $a, $b; + + $k1 cmp $k2; + + } @_; + +} + +##################################################################### +# Formatting functions. +##################################################################### + +sub Devel::vscode::_format_refval { + + my ($r) = @_; + + return unless ref $r; + + if (defined &Scalar::Util::reftype) { + + if ( + 'CODE' eq Scalar::Util::reftype($r) + and + defined &Sub::Identify::sub_fullname + ) { + return sprintf "\\&%s", Sub::Identify::sub_fullname($r); + } + + # FOO(0xDEADBEEF) or Foo::Bar(0xDEADBEEF) + return sprintf "%s(0x%08x)", + Scalar::Util::blessed($r) ? + Scalar::Util::blessed($r) : + Scalar::Util::reftype($r), + Scalar::Util::refaddr($r); + + } else { + + return "$r"; + + } + +} + +sub Devel::vscode::_truncate { + + my ($s) = @_; + my $max_len = 1024; + my $mark = '[...]'; + return unless defined $s; + return $s if length $s < $max_len; + return substr($s, 0, $max_len - length $mark) . $mark; +} + +sub Devel::vscode::_escape_double { + + my ($s) = @_; + + return 'undef' unless defined $s; + + # TODO(bh): This could produce prettier strings, like using `\t` + # instead of `\x09`, but it should do for now. + + # Delimiter and characters that might cause interpolation + $s =~ s/([\\\"\$\@\%])/\\$1/g; + + # C0 controls and non-ascii < U+0100 + $s =~ s/([\0-\x1f\x80-\xFF])/sprintf("\\x%02x",ord($1))/ge; + + # rest of non-ascii + $s =~ s/([\x{100}-\x{10ffff}])/sprintf("\\x{%04x}",ord($1))/ge; + + return "\"$s\""; +} + +sub Devel::vscode::_format_scalar { + + my ($s) = @_; + + if ( + defined(&Scalar::Util::looks_like_number($s)) + and + Scalar::Util::looks_like_number($s) + ) { + return $s; + } else { + return Devel::vscode::_truncate( + Devel::vscode::_escape_double($s) + ); + } + +} + +##################################################################### +# Find details to symbols. +##################################################################### + +sub Devel::vscode::_count_named_children { + my ($ref) = @_; + + my $reftype = Scalar::Util::reftype($ref); + + return scalar( keys %$ref ) if $reftype eq 'HASH'; + return 1 if $reftype eq 'REF'; + return 1 if $reftype eq 'SCALAR'; + return; + +} + +sub Devel::vscode::_count_indexed_children { + my ($ref) = @_; + + my $reftype = Scalar::Util::reftype($ref); + + return scalar( @$ref ) if $reftype eq 'ARRAY'; + return; + +} + +sub Devel::vscode::_hash_for_package { + + my ($package) = @_; + my %h; + + my $p = $package eq 'main' ? "::" : "${package}::"; + + for (sort grep { not /::/ and not /^_{$n}) eq 'REF' ? $n : '\\' . $n; + \@x + } Devel::vscode::_h_to_vars($h->{$n}, $n); + } + + return \@r; +} + + +##################################################################### +# Lexical (my, our, state) symbols. +##################################################################### + +sub Devel::vscode::_lexical_vars { + + my ($level) = @_; + + return [[ + 'padwalker_missing', '"cpanm PadWalker"' + ]] unless defined &PadWalker::peek_my; + + # Core module missing? + return [[ + 'scalar_util_missing', '"cpanm Scalar::Util"' + ]] unless defined &Scalar::Util::reftype; + + # NOTE(bh): Like the `y` command in `perl5db.pl`, this only offers + # `my` variables and not `our` variables. + + my $h = PadWalker::peek_my($level); + my @r; + + for my $n (Devel::vscode::_sort_without_sigil(keys %$h)) { + push @r, map { + my @x = @$_; + + my $reftype = Scalar::Util::reftype($h->{$n}); + + if ( + $reftype eq 'REF' + and + Scalar::Util::reftype(${ $h->{$n} }) =~ /^(?:ARRAY|HASH)$/ + ) { + $x[4] = sprintf "\${ PadWalker::peek_my(%u)->{%s} }", + $level - 2, Devel::vscode::_escape_double($n); + } else { + $x[4] = sprintf "PadWalker::peek_my(%u)->{%s}", + $level - 2, Devel::vscode::_escape_double($n); + } + + \@x + } Devel::vscode::_h_to_vars($h->{$n}, $n); + } + + return \@r; +} + +##################################################################### +# Children (elements in arrays, key-value pairs in hashes). +##################################################################### + +sub Devel::vscode::_get_element_symbols_json { + + my ($h) = @_; + + my $has_reftype = defined &Scalar::Util::reftype; + + if ($has_reftype and 'HASH' eq Scalar::Util::reftype($h)) { + + return Devel::vscode::_json_encode_array( + Devel::vscode::_hashelems($h) + ); + + } elsif ($has_reftype and 'ARRAY' eq Scalar::Util::reftype($h)) { + + return Devel::vscode::_json_encode_array( + Devel::vscode::_arrayelems($h) + ); + + } elsif ($has_reftype and 'REF' eq Scalar::Util::reftype($h)) { + + my $deref = Devel::vscode::_h_to_vars( + $$h, + Devel::vscode::_format_refval($$h) + ); + + $deref->[4] = '->$*'; + + return Devel::vscode::_json_encode_array([$deref]); + + } else { + + # ... + + } + + return Devel::vscode::_json_encode_array([]); + +} + +sub Devel::vscode::_hashelems { + + my ($h) = @_; + + my @r = map { + Devel::vscode::_h_to_vars($h->{$_}, $_) + } sort keys %$h; + + for (@r) { + $_->[4] = sprintf( + '->{%s}', + Devel::vscode::_escape_double($_->[0]) + ); + } + + return \@r; +} + +sub Devel::vscode::_arrayelems { + + my ($arrayref) = @_; + + my @r = map { + Devel::vscode::_h_to_vars($arrayref->[$_], $_) + } 0 .. scalar(@$arrayref) - 1; + + for (@r) { + $_->[4] = sprintf '->[%u]', $_->[1]; + } + + return \@r; +} + +##################################################################### +# Variable retrieval. +##################################################################### + +sub Devel::vscode::_get_lexical_symbols_json { + + my ($level) = @_; + + my $return = Devel::vscode::_json_encode_array( + Devel::vscode::_lexical_vars($level + 4), + ); + + return $return; + +} + +sub Devel::vscode::_get_package_symbols_json { + + my ($pkg) = @_; + + return Devel::vscode::_json_encode_array( + Devel::vscode::_package_vars($pkg), + ); + +} + +##################################################################### +# Variable setting. +##################################################################### + +sub Devel::vscode::_set_variable { + + my ($lhs, $elem, $rhs) = @_; + + if (defined &Scalar::Util::reftype) { + if (Scalar::Util::reftype($lhs) eq 'HASH') { + return Devel::vscode::_format_scalar($$lhs->{$elem} = $rhs); + } elsif (Scalar::Util::reftype($lhs) eq 'ARRAY') { + return Devel::vscode::_format_scalar($$lhs->[$elem] = $rhs); + } else { + return Devel::vscode::_format_scalar($$lhs = $rhs); + } + } + + return; +} + +##################################################################### +# Source code retrieval. +##################################################################### + +sub Devel::vscode::_get_unreported_sources_json { + + # NOTE: This maintains a cache of already reported sources. It sets + # the cache values to the current process identifier to account for + # forked children. They inherit a copy of the cache, but have their + # own connection to the debug extension, where previously reported + # sources would count as already-reported otherwise. + + return Devel::vscode::_json_encode_array([ + grep { + my $old = $Devel::vscode::_reported_sources{$_}; + $Devel::vscode::_reported_sources{$_} = $$; + not defined $old or $old ne $$ + } grep { /^_<[^(]/ } keys %main:: + ]); + +} + +sub Devel::vscode::_get_source_code_json { + + my ($path) = @_; + + # Perl stores file source code in `@{main::_= $num; + } + + return Devel::vscode::_json_encode_array(\@result); + +} + +##################################################################### +# Wrapper for DB::postponed +##################################################################### + +*DB::postponed = sub { + + # As perl `perldebguts`, "After each required file is compiled, + # but before it is executed, DB::postponed(*{"_<$filename"}) is + # called if the subroutine DB::postponed exists." and "After + # each subroutine subname is compiled, the existence of + # $DB::postponed{subname} is checked. If this key exists, + # DB::postponed(subname) is called if the DB::postponed + # subroutine also exists." + # + # Overriding the function with a thin wrapper like this would + # give us a chance to report any newly loaded source directly + # instead of repeatedly polling for it, which could be used to + # make breakpoints more reliable. Same probably for function + # breakpoints if they are registered as explained above. + # + # Note that when a Perl process is `fork`ed, we may already have + # wrapped the original function and must avoid doing it again. + # This is not actually used at the moment. We cannot usefully + # break into the debugger here, since there is no good way to + # resume exactly as the user originally intended. There would + # have to be a way to process such messages asynchronously as + # they arrive. + + my ($old_postponed) = @_; + + $Devel::vscode::_overrode_postponed = 1; + + # + + return sub { + if ('GLOB' eq ref(\$_[0]) and $_[0] =~ /<(.*)\s*$/s) { + print { $DB::OUT } "vscode: new loaded source $1\n"; + } else { + print { $DB::OUT } "vscode: new subroutine $_[0]\n"; + } + &{$old_postponed}; + }; + +}->(\&DB::postponed) unless $Devel::vscode::_overrode_postponed; + +##################################################################### +# ... +##################################################################### + +1; + + +__END__ diff --git a/src/adapter.ts b/src/adapter.ts index 102c1fd..e21393e 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -5,7 +5,6 @@ import * as path from 'path'; import {spawn} from 'child_process'; import {StreamCatcher} from './streamCatcher'; import * as RX from './regExp'; -import variableParser, { ParsedVariable, ParsedVariableScope } from './variableParser'; import { DebugSession } from './session'; import { LocalSession } from './localSession'; @@ -44,11 +43,12 @@ interface Variable { } interface StackFrame { - v: string, - name: string, filename: string, caller: string, - ln: number, + line: number, + column?: number, + endLine?: number, + endColumn?: number, } export interface RequestResponse { @@ -634,6 +634,31 @@ export class PerlDebuggerConnection extends EventEmitter { return true; } + private getDebuggerFunctionsPath() { + + let plPath = ( + path.dirname(process.argv0) + + + '/../debugger-functions.pl' + ); + + // When `EMBED_DEBUG_ADAPTER` in extension.ts is set, `argv0` + // points to vscode itself instead of our `debugAdapter.ts`. + + // FIXME(bh): Only alternative to get the path to this file, + // or the extension directory in general, during debugging and + // when properly installed, seems to be getting a path from a + // stack trace, which is not very nice and not very portable. + + if (!fs.existsSync(plPath)) { + plPath = new Error().stack.match(/(\/\S+):\d+:\d+/)[1]; + plPath = path.dirname(plPath) + '/../debugger-functions.pl'; + } + + return plPath; + + } + private async installSubroutines() { // https://metacpan.org/pod/Devel::vscode register a namespace @@ -645,73 +670,19 @@ export class PerlDebuggerConnection extends EventEmitter { // features. For these, it is not necessary for users of the // extension to install or otherwise load `Devel::vscode`. - const singleLine = (strings, ...args) => { - return strings.join('').replace(/\n/g, " "); - }; + const path = this.getDebuggerFunctionsPath(); + const contents = fs.readFileSync(path).toString(); + const escaped = this.escapeForDoubleQuotes(contents); + + await this.request( + `eval "${escaped}" unless $Devel::vscode::DEBUGGER_FUNCTIONS` + ); + + // Clear after exec() + await this.request( + `%Devel::vscode::_reported_sources = ()` + ); - const unreportedSources = singleLine` - sub Devel::vscode::_unreportedSources { - return join "\t", grep { - my $old = $Devel::vscode::_reportedSources{$_}; - $Devel::vscode::_reportedSources{$_} = $$; - not defined $old or $old ne $$ - } grep { /^_<[^(]/ } keys %main:: - } - `; - - // Perl stores file source code in `@{main::_(\\&DB::postponed) unless $Devel::vscode::_overrodePostponed; - `; - - await this.request(unreportedSources); - await this.request(getSourceCode); - await this.request(breakOnLoad); } async launchRequest( @@ -849,6 +820,8 @@ export class PerlDebuggerConnection extends EventEmitter { // Initial data from debugger this.logData('', data.slice(0, data.length-2)); + await this.installSubroutines(); + // While `runInTerminal` is supposed to give us the pid of the // spawned `perl -d` process, that does not work very well as of // 2019-02. Instead we ask Perl for the host process id. Note @@ -879,19 +852,12 @@ export class PerlDebuggerConnection extends EventEmitter { try { this.padwalkerVersion = await this.getPadwalkerVersion(); + this.scopeBaseLevel = await this.getVariableBaseLevel(); } catch(ignore) { // xxx: Ignore errors - it should not break anything, this is used to // inform the user of a missing dependency install of PadWalker } - if (this.padwalkerVersion.length > 0) { - try { - this.scopeBaseLevel = await this.getVariableBaseLevel(); - } catch (ignore) { - // ignore the error - } - } - await this.installSubroutines(); return this.parseResponse(data); @@ -986,115 +952,103 @@ export class PerlDebuggerConnection extends EventEmitter { return await this.request('R'); } - async getVariableReference(name: string): Promise { - const res = await this.request(`p \\${name}`); - return res.data[0]; - } + async getLexicalVariables(frameId: number): Promise { + const data = await this.getExpressionValue( + `Devel::vscode::_get_lexical_symbols_json(${frameId})` + ); - async getExpressionValue(expression: string): Promise { - const res = await this.request(`p ${expression}`); - return res.data.pop(); + return JSON.parse(data); } - /** - * Prints out a nice indent formatted list of variables with - * array references resolved. - */ - async requestVariableOutput(level: number) { - const variables: Variable[] = []; - const res = await this.request(`y ${level + this.scopeBaseLevel - 1}`); - const result = []; + async getPackageVariables(pkg: string): Promise { - if (/^Not nested deeply enough/.test(res.data[0])) { - return []; - } + const data = await this.getExpressionValue( + `Devel::vscode::_get_package_symbols_json('${ + this.escapeForSingleQuotes(pkg) + }')` + ); - if (RX.codeErrorMissingModule.test(res.data[0])) { - throw new Error(res.data[0]); - } + return JSON.parse(data); + } - // Resolve all Array references - for (let i = 0; i < res.data.length; i++) { - const line = res.data[i]; - if (/\($/.test(line)) { - const name = line.split(' = ')[0]; - const reference = await this.getVariableReference(name); - result.push(`${name} = ${reference}`); - } else if (line !== ')') { - result.push(line); - } - } + async getExprVariables(expr: string): Promise { - return result; - } + const data = await this.getExpressionValue( + `Devel::vscode::_get_element_symbols_json(${expr})` + ); + + return JSON.parse(data); - async getVariableList(level: number, scopeName?: string): Promise { - const variableOutput = await this.requestVariableOutput(level); - //console.log('RESOLVED:'); - //console.log(variableOutput); - return variableParser(variableOutput, scopeName); } - async variableList(scopes): Promise { - // If padwalker not found then tell the user via the variable inspection - // instead of being empty. - if (!this.padwalkerVersion) { - return { - local_0: [{ - name: 'PadWalker', - value: 'Not installed', - type: 'string', - variablesReference: '0', - }], - }; - } + async getExpressionValue(expression: string): Promise { - const keys = Object.keys(scopes); - let result: ParsedVariableScope = {}; + // NOTE(bh): It is important to force a string context here, + // otherwise we might get multiple values from the expression, + // or in case of overloaded objects, might get a non-string + // value. It might then make sense to have other methods that + // force different contexts. + // + // Users should not be able to notice what we do here, so we + // temporarily disable the debugger for the duration of the + // request. When users specifically ask to break inside the + // debugger, like with `w $DB::sub` or `w $DB::package`, they + // might still intercept us here; that is probbaly ultimately + // a problem in `perl5db.pl` which is also affected. - for (let i = 0; i < keys.length; i++) { - const name = keys[i]; - const level = scopes[name]; - Object.assign(result, await this.getVariableList(level, name)); - } - return result; + const res = await this.request( + `; { local *DB::DB = sub {}; print { \$DB::OUT } ( '' . (${ + expression + }) ) }` + ); + + return res.data.pop(); } async getStackTrace(): Promise { - const res = await this.request('T'); - const result: StackFrame[] = []; - - res.data.forEach((line, i) => { - // > @ = DB::DB called from file 'lib/Module2.pm' line 5 - // > . = Module2::test2() called from file 'test.pl' line 12 - const m = line.match(/^(\S+) = (\S+) called from file \'(\S+)\' line ([0-9]+)$/); - - if (m !== null) { - const [, v, caller, name, ln] = m; - const filename = absoluteFilename(this.rootPath, name); - result.push({ - v, - name, - filename, - caller, - ln: +ln, - }); - } + + const data = await this.getExpressionValue( + `Devel::vscode::_get_callers_json(0)` + ); + + const frames = JSON.parse(data).map(item => { + + const [ + pkg, filename, line, sub, hasargs, wantarray, + evaltext, is_require, hints, bitmask, hinthash + ] = item; + + const frame: StackFrame = { + line: parseInt(line, 10), + caller: sub, + filename: filename, + }; + + return frame; }); - return result; + frames.forEach((item, ix) => { + item.caller = frames[ix+1] + ? frames[ix+1].caller + : '(anonymous code)'; + }); + + return frames; + } async getLoadedFiles(): Promise { const loadedFiles = await this.getExpressionValue( - 'Devel::vscode::_unreportedSources() if defined &Devel::vscode::_unreportedSources' + `defined &Devel::vscode::_get_unreported_sources_json ${ + '' // just for a line wrap + } ? Devel::vscode::_get_unreported_sources_json() : "[]"` ); - return (loadedFiles || '') - .split(/\t/) + return JSON.parse(loadedFiles || '[]') .filter(x => !/^_<\(eval \d+\)/.test(x)) + .filter(x => x.length > 0) .map(x => x.replace(/^_ { - const res = await this.request( - 'p sub { local $@; eval "require PadWalker; PadWalker->VERSION()" }->()' + const version = await this.getExpressionValue( + 'PadWalker->VERSION()' ); - const version = res.data[0]; if (/^[0-9]+\.?([0-9]?)+$/.test(version)) { return version; } - return JSON.stringify(res.data); } async getVariableBaseLevel() { @@ -1157,32 +1111,28 @@ export class PerlDebuggerConnection extends EventEmitter { } async getDebuggerPid(): Promise { - const res = await this.request( - 'p $$' - ); - return parseInt(res.data[0]); + return parseInt(await this.getExpressionValue( + '$$' + ), 10); } async getHostname(): Promise { - const res = await this.request( - 'p sub { local $@; eval "require Sys::Hostname; Sys::Hostname::hostname()" }->()' + return await this.getExpressionValue( + 'Sys::Hostname::hostname()' ); - return res.data[0]; } async getDevelVscodeVersion(): Promise { - const res = await this.request( - 'p sub { local $@; eval "\$Devel::vscode::VERSION" }->()' + return await this.getExpressionValue( + '$Devel::vscode::VERSION' ); - const [ result = undefined ] = res.data; - return result; } async getProgramBasename(): Promise { - const res = await this.request( - 'p $0' + const name = await this.getExpressionValue( + '$0' ); - return (res.data[0] || '').replace(/.*[\/\\](.*)/, '$1'); + return (name || '').replace(/.*[\/\\](.*)/, '$1'); } public getThreadName(): string { @@ -1192,9 +1142,9 @@ export class PerlDebuggerConnection extends EventEmitter { } async resolveFilename(filename): Promise { - const res = await this.request(`p $INC{"${filename}"};`); - const [ result = '' ] = res.data; - return result; + return await this.getExpressionValue( + `$INC{"${this.escapeForDoubleQuotes(filename)}"}; + `); } public escapeForSingleQuotes(unescaped: string): string { @@ -1204,6 +1154,14 @@ export class PerlDebuggerConnection extends EventEmitter { ); } + public escapeForDoubleQuotes(unescaped: string): string { + return unescaped.replace( + /([^a-zA-Z0-9])/ug, + (whole, elem) => `\\x{${elem.codePointAt(0).toString(16)}}` + ); + } + + public terminateDebugger(): boolean { if (this.canSignalDebugger) { diff --git a/src/extension.ts b/src/extension.ts index e3fe70b..ab21829 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -184,7 +184,15 @@ class PerlDebugConfigurationProvider implements vscode.DebugConfigurationProvide const editor = vscode.window.activeTextEditor; - if (!config.request && editor.document.languageId === 'perl') { + const perlEditor = ( + editor + && + editor.document + && + editor.document.languageId === 'perl' + ); + + if (!config.request && perlEditor) { const defaultConfig = vscode.extensions.getExtension( "mortenhenriksen.perl-debug" diff --git a/src/perlDebug.ts b/src/perlDebug.ts index 88be8c1..7cc244b 100644 --- a/src/perlDebug.ts +++ b/src/perlDebug.ts @@ -14,7 +14,6 @@ import {basename, dirname, join} from 'path'; import {spawn, ChildProcess} from 'child_process'; const { Subject } = require('await-notify'); import { PerlDebuggerConnection, RequestResponse } from './adapter'; -import { variableType, ParsedVariable, ParsedVariableScope, resolveVariable } from './variableParser'; /** * This interface should always match the schema found in the perl-debug extension manifest. @@ -44,10 +43,84 @@ export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArgum console?: string, /** Log raw I/O with debugger in output channel */ debugRaw?: boolean, + /** Log debugging messages in output channel */ + debugLog?: boolean, /** How to handle forked children or multiple connections */ sessions?: string, } +class PerlVariable { + parent?: number; + step?: string; + constructor(parent?: number, step?: string) { + this.parent = parent; + this.step = step; + } +} + +class PerlVariableRoot extends PerlVariable { + scope: string; + frameId: number; + pkg: string; + constructor(scope: string, frameId?: number, pkg?: string) { + super(); + this.scope = scope; + this.frameId = frameId; + this.pkg = pkg; + } +} + +class PerlVariableMap { + + _next: number = 1; + _data: Map = new Map(); + + public clear() { + // NOTE: _next is never reset to prevent accidental re-use of IDs + this._data.clear(); + } + + public get(variablesReference: number) { + return this._data.get(variablesReference); + } + + public create(variable: PerlVariable) { + this._data.set(this._next, variable); + return this._next++; + } + + public expr(variableReference: number): string { + + const map = this._data; + + const g = function*(v: number) { + while (v) { + const perlVar = map.get(v); + if (perlVar.step !== '' && perlVar.step !== undefined) { + yield perlVar.step; + } + v = perlVar.parent; + } + } + + // NOTE(bh): Older versions of Perl do not support the postfix + // dereference syntax `->$*` to dereference (scalar) references. + // Therefore such steps are mapped to prefix syntax `${ ... }`, + // wrapping the expression up to that point. + + const result = Array + .from(g(variableReference)) + .reverse() + .reduce((prev, current, idx, arr) => { + return current === '->$*' ? `\$\{${prev}\}` : prev + current; + }, ""); + + return result; + + } + +} + export class PerlDebugSession extends LoggingDebugSession { private static THREAD_ID = 1; @@ -60,9 +133,10 @@ export class PerlDebugSession extends LoggingDebugSession { private _loadedSources = new Map(); - private _variableHandles = new Handles(); + private _variableMap = new PerlVariableMap(); public dcSupportsRunInTerminal: boolean = false; + public dcSupportsVariablePaging: boolean = false; private adapter: PerlDebuggerConnection; @@ -79,17 +153,27 @@ export class PerlDebugSession extends LoggingDebugSession { private _configurationDone = new Subject(); - /* protected convertClientPathToDebugger(clientPath: string): string { - return clientPath.replace(this.rootPath, ''); - } + private sendStoppedEvent(reason: string) { + + // https://github.com/Microsoft/debug-adapter-protocol/issues/35 + // It is not really clear how long and to what end established + // `variablesReference` values should stay valid, but as a rule + // of thumb, when the stack frame has changed, the scopes and + // associated variables probably have changed. We cannot keep + // old variables around indefinitely, so we clear them here. + + this._variableMap.clear(); - protected convertDebuggerPathToClient(debuggerPath: string): string { - return join(this.rootPath, debuggerPath); - }*/ + this.sendEvent( + new StoppedEvent(reason, PerlDebugSession.THREAD_ID) + ); + + } protected initializeRequest(response: DebugProtocol.InitializeResponse, args: DebugProtocol.InitializeRequestArguments): void { this.dcSupportsRunInTerminal = !!args.supportsRunInTerminalRequest; + this.dcSupportsVariablePaging = !!args.supportsVariablePaging; this.adapter.on('perl-debug.output', (text) => { this.sendEvent(new OutputEvent(`${text}\n`)); @@ -108,19 +192,16 @@ export class PerlDebugSession extends LoggingDebugSession { }); this.adapter.on('perl-debug.stopped', (x) => { + // FIXME(bh): `breakpoint` is not always correct here. - this.sendEvent(new StoppedEvent("breakpoint", PerlDebugSession.THREAD_ID)); + this.sendStoppedEvent('breakpoint'); + }); this.adapter.on('perl-debug.close', (x) => { this.sendEvent(new TerminatedEvent()); }); - this.adapter.on('perl-debug.debug', (...x) => { - // FIXME: needs to check launch options - this.sendEvent(new Event('perl-debug.debug', x)); - }); - this.adapter.on('perl-debug.new-source', () => { // FIXME(bh): There is probably a better way to re-use the code @@ -152,9 +233,6 @@ export class PerlDebugSession extends LoggingDebugSession { // make VS Code to use 'evaluate' when hovering over source response.body.supportsEvaluateForHovers = true; - // make VS Code to show a 'step back' button - response.body.supportsStepBack = false; - response.body.supportsFunctionBreakpoints = true; response.body.supportsLoadedSourcesRequest = true; @@ -198,15 +276,14 @@ export class PerlDebugSession extends LoggingDebugSession { if (sessions === 'watch') { this.adapter.request('c'); + // FIXME: should only be sent when `c` is actually sent to debugger this.sendEvent( new ContinuedEvent(PerlDebugSession.THREAD_ID) ); } else if (sessions === 'break') { - this.sendEvent( - new StoppedEvent("postfork", PerlDebugSession.THREAD_ID) - ); + this.sendStoppedEvent("postfork"); } @@ -234,6 +311,13 @@ export class PerlDebugSession extends LoggingDebugSession { }); } + this.adapter.removeAllListeners('perl-debug.debug'); + if (args.debugLog) { + this.adapter.on('perl-debug.debug', (...x) => { + this.sendEvent(new Event('perl-debug.debug', x)); + }); + } + // TODO(bh): If the user manually launches two debug sessions in // parallel, this would clear output from one of the sessions // when starting the other one. That is not ideal. @@ -247,6 +331,11 @@ export class PerlDebugSession extends LoggingDebugSession { this ); + await this.loadedSourcesRequest( + {} as DebugProtocol.LoadedSourcesResponse, + {} + ); + // NOTE(bh): This extension used to send the `InitializedEvent` // at the beginning of the `initializeRequest`. That was taken // as a signal that we can accept configurations right away, but @@ -270,7 +359,7 @@ export class PerlDebugSession extends LoggingDebugSession { this.sendResponse(response); // we stop on the first line - this.sendEvent(new StoppedEvent("entry", PerlDebugSession.THREAD_ID)); + this.sendStoppedEvent("entry"); } else { // we just start to run until we hit a breakpoint or an exception this.continueRequest( @@ -326,9 +415,10 @@ export class PerlDebugSession extends LoggingDebugSession { protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) : void { this.sendEvent(new OutputEvent(`ERR>Reverse continue not implemented\n\n`)); + // Perl does not support this. + response.success = false; this.sendResponse(response); - this.sendEvent(new StoppedEvent("entry", PerlDebugSession.THREAD_ID)); } /** @@ -337,9 +427,10 @@ export class PerlDebugSession extends LoggingDebugSession { protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments): void { this.sendEvent(new OutputEvent(`ERR>Step back not implemented\n`)); + // Perl does not support this. + response.success = false; this.sendResponse(response); - this.sendEvent(new StoppedEvent("entry", PerlDebugSession.THREAD_ID)); } @@ -480,10 +571,7 @@ export class PerlDebugSession extends LoggingDebugSession { !!pathPos, parseInt(bpFirst), undefined, - new Source( - bpFile, - bpFile, - ) + this.findOrCreateSource(bpFile) ); } @@ -559,259 +647,23 @@ export class PerlDebugSession extends LoggingDebugSession { /** * Set variable */ - protected setVariableRequest(response: DebugProtocol.SetVariableResponse, args: DebugProtocol.SetVariableArguments): void { - // Get type of variable contents - const name = this.getVariableName(args.name, args.variablesReference) - .then((variableName) => { - - // We use perl's eval() here for maximum flexibility so that the user can actually change the type of the value in the VS Code - // GUI on-the-fly. E.g. by simply changing a scalar value into an array ref by typing [1,2,3] as the new value of the scalar. - // E.g. if the user has an array @a = (1,2,3) and edits the 2nd element (2) by typing [4,5,6] in the GUI, the scalar value of 2 - // will be replaced with the expected array ref, so the updated array will look like @a = (1,[4,5,6],3) after the edit. - // This allows the user to arbitrarily change the structure of composite types (like arrays and hashes) on-the-fly during debugging - // using the VS Code GUI. In fact, it also allows you to do arithmetic during editing a value so you can just simply type "11 + 22" - // as the new value and it becomes 33. And you can use any valid perl expression for the new value e.g. $x + $y will set the value - // to the sum of vars $x and $y (they obviously have to be defined in the perl code and visible in the given scope). - // - // Note that we also support perl string interpolation just like perl does. - // This means that what the new value will be depends on whether or how the specified value is quoted by the user in the GUI. - // - // Assume we have two perl vars $x and $y, with values 1 and 2 respectively, then quoting will work as a perl programmer expects it - // to work: - // - // 1. Unquoted expression: $x + $y evaluates to the value 3 because $x = 1 and $y = 2. (3 = 1 + 2) - // - // 2. Quoted expression: - // - // 1. Single-quoted: '$x + $y' evaluates to the string '$x + $y'. No variable interpolation - // - // 2. double-quoted: "$x + $y" evaluates to the string '1 + 2'. Variable interpolation - // - // - // Note that the following assignment operations assume that the user types in meaningful perl expressions. - // - // Otherwise GIGO rules apply. That is, if the user specifies a garbage expression, then the result will be a garbage value or a failed assignment. - // If the user-specified string is not a valid perl expression then it will be assigned as an interpolated perl string (unless it's single quoted, - // in which case it's not interpolated). This is not a bug but a feature (and hence it won't be "fixed"). The malformed expression will be assigned - // as a string to the variable so that the user can easily see that they made a mistake (because they will get a string instead of e.g. the expected - // array). But for normal everyday well-formed user input this will not happen so it will not be an issue. - // - // That is, the following implementation provides perl programmers with an intuitive set of assignment operations but it assumes that the - // user actually knows what perl expressions actually look like (so that they don't type garbage expressions in). - // - // Note that some of the heuristic assignment rules below deliberately use extended semantics compared to perl to make value assignments uniform - // for both composite (arrays, hashes, objects) and scalar variables so that you don't have to care about whether you have to specify an array or - // an array ref when e.g. creating an array. This is not a bug but a feature. So that you can just simply use the same array (1,2,3,4) and assign - // this in the VS Code GUI to any type of variable and you will get an array in the appropriate form (array or array ref). In other words, these - // assignment rules automatically convert between arrays and array refs as needed depending on context, hence allowing intuitive uniform assingments - // to variables. E.g. in the VS Code GUI you can assign the array (1,2,3,4) to an array @a, an element in the array $a[i], to a hash %h, to a key - // in the hash $h{k} or a scalar $s and you will get an array in the given position - // - // @a = (1,2,3,4) - // $a[i] = [1,2,3,4] - // %h = (1,2,3,4) - // $h{k} = [1,2,3,4] - // $s = [1,2,3,4] - // - // Note that some of these arrays are actually array refs but you don't have to care about this difference. The appropriate form (ref or non-ref) - // will be used automatically. - // - // So you can just simply assign an array value of (1,2,3,4) to any variable without having to care about whether you have to use an array ref or - // not in the given context. But this also means that these intuitive assignments are NOT (necessarily) perl assignment instructions. Because perl - // doesn't do this automatic conversion between refs and nonrefs. So in perl the above assignments would be - // - // @a = (1,2,3,4) - // $a[i] = (1,2,3,4) - // %h = (1,2,3,4) - // $h{k} = (1,2,3,4) - // $s = (1,2,3,4) - // - // and although the assignments to @a and %h would work the same way as above, but perl would assign the last element (4) to all the scalar variables - // $a[i], $h{k} and $s (so $a[i] = 4, $h{k} = 4, $s = 4) because of the scalar context, which is obviously not what the user's intention was when they - // specified (1,2,3,4) in the GUI. - // - // So the intuitive heuristic assignment rules below do what the user would intuitively expect to happen and not necessarily what perl would do:) - // - // - // The following array (@a), hash (%h) and scalar ($s) assigment operations are supported: - // --------------------------------------------------------------------------------------- - // - // The "User Input" column shows what the user types into the VS Code GUI edit box when editing a variable (@a, %h, $s). - // The "Assignment" column shows what perl assignment will be done as a result. - // - // E.g. typing @x into the edit box while editing the value of @a will copy the elements of @x over into @a as in @a = @x. - // - // - // User Input Assignment - // - // @a: - // @x @a = @x - // %h @a = %h - // $s @a = ($s) - // {1,2} @a = (1,2) - // ({1,2}) @a = ({1,2}) - // 1,2,3 @a = (1,2,3) - // (1,2,3) @a = (1,2,3) - // [1,2,3] @a = (1,2,3) - // ([1,2,3]) @a = ([1,2,3]) - // - // %h: - // %x %h = %x - // @a %h = @a - // {1,2} %h = (1,2) - // 1,2,3,4 %h = (1,2,3,4) (hence (1 => 2, 3 => 4)) - // (1,2,3,4) %h = (1,2,3,4) - // [1,2,3,4] %h = (1,2,3,4) - // - // $s: - // $x $s = $x - // @a $s = [@a] - // %h $s = {@{[%h]}} (a hashref to a copy of %h) - // {1,2} $s = {1,2} - // 1,2,3 $s = "1,2,3" - // (1,2,3) $s = [1,2,3] - // [1,2,3] $s = [1,2,3] - // ([1,2,3]) $s = [[1,2,3]] - // undef $s = undef - // - // String ops with the usual quoting rules and variable interpolation are also supported. - // - // Plus some simpler perl expressions. Invalid expressions are treated as double-quoted strings and will be interpolated accordingly. - // Some examples: assuming $x = 1 and $y = 2 - // - // 11 + 22 $s = 11 + 22 (hence $s = 33) - // $x + $y $s = 1 + 2 (hence $s = 3) - // '$x + $y' $s = '$x + $y' - // "$x + $y" $s = "1 + 2" - // - // $x,$y,3 $s = "1,2,3" - // ($x,$y,3) $s = [1,2,3] - // [$x,$y,3] $s = [1,2,3] - // - // Invalid expressions are treated as double-quoted strings and interpolated accordingly e.g. - // - // 11 + $s = "11 +" - // $x + $s = "1 +" - // - // Quoted invalid expressions are strings and hence are interpolated according to their quotation marks e.g. - // - // '$x +' $s = '$x +' - // "$x +" $s = "1 +" - // - // Special case for x'y. Old-style class reference lookalikes are interpreted as strings and not as x::y because it's extremely unlikely - // that actually old-style class references are used by the user in the expression. This way strings with single-quotes will work - // seamlessly (e.g. "There's" remains "There's" intead of becoming "There::s":) - // - // x'y $s = "x'y" - // - // The above assignment ops provide perl programmers with an intuitive set of assignment operations for everyday use cases. - - // Note that initially the user-specified value will appear in the GUI as specified by the user, that is, as the original string because - // VS Code doesn't update the GUI immediately. But once the user does a single-step in the debugger the evaluated expression value - // will be displayed in the GUI as the GUI updates. - - let value = args.value.replace(/^\s*/,'').replace(/\s*$/,''); // remove leading and trailing whitespace if any - - if (!/^'.*'$/.test(value) && value != 'undef' && !/^"\s+"$/.test(value)) { // Single-quoted strings, undef, double-quoted whitespace strings - if (value == '' || value == "''" || value == '""') { value = "''"; } // and empty strings are passed through directly - else if (/^[ '"]+$/.test(value)) { - // Quote trolling empty strings and pass them through:) - // There is no real-life need to specify a value string like this one so the user is obviously trying to troll this parser.:) - // Or they might just have a very "special" pathological use case.:) - - // This string might be a malformed quotation mark fest so we replace the outermost quotation mark pair (if any) - if (/^'.*'$/.test(value)) { value = value.replace(/^'(.*)'$/,'$1'); } - else if (/^".*"$/.test(value)) { value = value.replace(/^"(.*)"$/,'$1'); } - - // And then we quote the string properly so that it's a properly delimited string and hence it can be assigned to a variable - value = "'" + value.replace(/'/g,"\\'") + "'"; - } - else if (/^\S*\w'\w\S*$/.test(value)) { value = '"' + value + '"'; } // Pass old-style class ref single words of the form a'b'c through - else { // because eval() converts them to a::b::c, which we don't want - // This is a non-empty value string that's not 'undef' - let use_eval = true; - let eval_type_prefix = ''; // not needed for scalar types. Only needed for arrays and hashes - let eval_type_suffix = ''; - - if (/^@/.test(variableName) || /^%/.test(variableName)) { - if (/^@/.test(value) || /^%/.test(value)) { use_eval = false; } // because it can be directly assigned e.g. @a = @x, %h = %x - else { - - if (/^\{.*\}$/.test(value)) { value = value.replace(/^./,'[').replace(/.$/,']'); } // {expr} -> [expr] - else if (!/^\[.*\]$/.test(value) && !/^\(.*\)$/.test(value)) { // if not array (expr) or [expr] - value = '[' + value + ']'; // value -> [value] - } - - eval_type_prefix = '@'; // we use arrays internally during evaluation. Even for hashes - } - } - - if (use_eval) { - - if (/^\(.*\)$/.test(value) || /^\{.*\}$/.test(value)) { // value = {expr} or value = (expr) - - if (/^\{/.test(value)) { - eval_type_prefix = '{@'; // to generate a hash ref from the array (ref) after evaluation - eval_type_suffix = '}'; - } - - value = value.replace(/^./,'[').replace(/.$/,']'); // (expr)|{expr} -> [expr] - } - else if (/^@/.test(value)) { value = '[' + value + ']'; } // @a -> [@a] - else if (/^%/.test(value)) { - value = '[' + value + ']'; // %h -> [%h] - eval_type_prefix = '{@'; // to generate a hash ref from the array (ref) after evaluation - eval_type_suffix = '}'; - } - else if (!/^\[.*\]$/.test(value) && !/^{.*}$/.test(value) && /,/.test(value)) { - // This might be a list e.g. 1, 2, 3. If it is then we treat it as a simple string and double-quote it - let v = value.replace(/(".*?),(.*?")/g,'$1 $2').replace(/('.*?),(.*?')/g,'$1 $2'); // mask commas inside strings - - // Double-quote it if it looks like a list - if (/,/.test(v)) { value = '"' + value.replace(/"/g,'\\"') + '"'; } // 1, 2, 3 -> "1, 2, 3" - } - - // Escape the single-quotes in the value (if any) TWICE for the nested single-quoting in the eval() below - value = value.replace(/'/g,"\\\\\\'"); // ' -> \\\' - - // If it's a valid perl expression then we use the value of the expression otherwise we use the value directly as a string. - // This way users can just simply type strings in without having to quote the string in the GUI. So they can just type - // expressions or strings in and it will just work in both cases - value = "eval('no warnings; " + - - "my $v = \\'" + value + "\\'; " + // single-quoting to prevent string interpolation at this point - - "my $r; eval { $r = eval($v) }; " + // check if it's a valid expression and get its value if it is - - "if (not defined $r) { " + // and if it's not then treat it as a double-quoted string and interpolate - "my $qv = $v !~ /^\".*\"$/ ? \\'\"\\'.$v.\\'\"\\' : $v; " + - "eval { $r = eval(\"$qv\"); }; " + - "} " + - - "defined($r) ? $r : $v')"; // if all else fails then we just return the original value string as the result - - if (eval_type_prefix) { // The eval type prefix and suffix are used for type conversion to convert the array ref eval result - value = eval_type_prefix + '{' + value + '}' + eval_type_suffix; // to the target type - } - } - } - } + protected async setVariableRequest(response: DebugProtocol.SetVariableResponse, args: DebugProtocol.SetVariableArguments) { - return this.adapter.request(`${variableName}=${value}`) - .then(() => { - response.body = { - value: args.value, - type: variableType(args.value), - }; - this.sendResponse(response); - }); - }) - .catch((err) => { - const [ error = err ] = err.errors || []; - this.sendEvent(new OutputEvent(`ERR>setVariableRequest error: ${error.message}\n`)); - response.success = false; - this.sendResponse(response); - }); + const expr = this._variableMap.expr(args.variablesReference); + const escapedName = this.adapter.escapeForSingleQuotes(args.name); + + const evalThis = `Devel::vscode::_set_variable(\\${expr.length ? expr : args.name}, '${escapedName}', scalar(${args.value}))`; + + const answer = await this.adapter.getExpressionValue( + evalThis + ); + + response.body = { + value: answer, + }; + + response.success = true; + this.sendResponse(response); } /** @@ -838,7 +690,8 @@ export class PerlDebugSession extends LoggingDebugSession { if (res.finished) { this.sendEvent(new TerminatedEvent()); } else { - this.sendEvent(new StoppedEvent("entry", PerlDebugSession.THREAD_ID)); + // FIXME: probably not correct to do this here. + this.sendStoppedEvent("entry"); } return response; @@ -946,102 +799,184 @@ export class PerlDebugSession extends LoggingDebugSession { /** * Scope request */ - protected scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments): void { - const frameReference = args.frameId; - const scopes = new Array(); - scopes.push(new Scope("Local", this._variableHandles.create("local_" + frameReference), false)); - scopes.push(new Scope("Closure", this._variableHandles.create("closure_" + frameReference), false)); - scopes.push(new Scope("Global", this._variableHandles.create("global_" + frameReference), true)); + protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) { + + const pkg = await this.adapter.getExpressionValue('$DB::package'); + + const lexicalRef = this._variableMap.create( + new PerlVariableRoot("lexical", args.frameId) + ); + + const packageRef = this._variableMap.create( + new PerlVariableRoot("package", args.frameId, pkg) + ); + + // TODO(bh): The global scope could be split thematically like in + // the `perldoc perlvar` documentation, e.g. + // + // * Global: General + // * Global: Regex + // * Global: Filehandles + // * Global: Errors + // * Global: Interpreter + // * Global: Deprecated + // + // Or they could be split into common ones like `$_` and `%ENV` + // and obscure ones like `$)` and `$^P`. An interesting grouping + // would be "has same value as when interpreter started" vs not, + // or an approximation of that. + // + // Relatedly, at the moment we do not show some global symbols, + // and neither some package symbols, like those for subroutines. + // They are hidden to avoid cluttering the user interface, but it + // might be useful to show them in special `functions` scopes? + + const globalRef = this._variableMap.create( + new PerlVariableRoot("global", args.frameId) + ); + + const lexicalScope = new Scope("Lexical", lexicalRef, false); + const packageScope = new Scope(`Package ${pkg}`, packageRef, false); + const globalScope = new Scope("Global", globalRef, true); + + if (pkg === 'main') { + // The global scope is the same as the `main` package scope. + response.body = { + scopes: [ + lexicalScope, + globalScope, + ] + }; + + } else { + response.body = { + scopes: [ + lexicalScope, + packageScope, + globalScope, + ] + }; + + } - response.body = { - scopes: scopes - }; this.sendResponse(response); - } - private getVariableName(name: string, variablesReference: number): Promise { - let id = this._variableHandles.get(variablesReference); - return this.adapter.variableList({ - global_0: 0, - local_0: 1, - closure_0: 2, - }) - .then(variables => { - return resolveVariable(name, id, variables); - }); } /** * Variable scope */ - protected variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments): void { - const id = this._variableHandles.get(args.variablesReference); - - this.adapter.variableList({ - global_0: 0, - local_0: 1, - closure_0: 2, - }) - .then(variables => { - const result = []; - - if (id != null && variables[id]) { - const len = variables[id].length; - const result = variables[id].map(variable => { - // Convert the parsed variablesReference into Variable complient references - if (variable.variablesReference === '0') { - variable.variablesReference = 0; - } else { - variable.variablesReference = this._variableHandles.create(`${variable.variablesReference}`); - } - return variable; - }); - - response.body = { - variables: result - }; - this.sendResponse(response); - } else { - this.sendResponse(response); + protected async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) { + + const perlVar = this._variableMap.get(args.variablesReference); + + const convert = (x) => { + + const newRef = x[2] || x[3] ? + this._variableMap.create( + new PerlVariable(args.variablesReference, x[4]) + ) + : undefined; + + const newVar: DebugProtocol.Variable = new Variable( + x[0], + x[1], + newRef, + x[2], + x[3] + ); + + newVar.evaluateName = this._variableMap.expr(newRef); + + return newVar; + + }; + + if (perlVar instanceof PerlVariableRoot) { + + if (perlVar.scope === 'lexical') { + + const vars = await this.adapter.getLexicalVariables( + perlVar.frameId + ); + + response.body = { + variables: vars.map(convert) } - }) - .catch(() => { - response.success = false; - this.sendResponse(response); - }); + + } + + if (perlVar.scope === 'package') { + + const vars = await this.adapter.getPackageVariables( + await this.adapter.getExpressionValue('$DB::package') + ); + + response.body = { + variables: vars.map(convert) + } + + } + + if (perlVar.scope === 'global') { + + const vars = await this.adapter.getPackageVariables( + await this.adapter.getExpressionValue('main') + ); + + response.body = { + variables: vars.map(convert) + } + + } + + } else if (perlVar instanceof PerlVariable) { + + const expr = this._variableMap.expr(args.variablesReference); + const vars = await this.adapter.getExprVariables(expr); + + response.body = { + variables: vars.map(convert) + }; + + } else { + + response.success = false; + + } + + this.sendResponse(response); + } /** * Evaluate hover */ - private evaluateHover(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) { + private async evaluateHover(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) { + + // FIXME: This evaluates method calls `$obj->method` which + // is fairly likely to have destructive side-effects. + if (/^[\$|\@]/.test(args.expression)) { const expression = args.expression.replace(/\.(\'\w+\'|\w+)/g, (...a) => `->{${a[1]}}`); - this.adapter.getExpressionValue(expression) - .then(result => { - if (/^HASH/.test(result)) { - response.body = { - result: result, - variablesReference: this._variableHandles.create(result), - type: 'string' - }; - } else { - response.body = { - result: result, - variablesReference: 0 - }; - } - this.sendResponse(response); - }) - .catch(() => { - response.body = { - result: undefined, - variablesReference: 0 - }; - this.sendResponse(response); - }); + const result = await this.adapter.getExpressionValue(expression); + if (/^HASH/.test(result)) { + response.body = { + result: result, + variablesReference: 0, + type: 'string' + }; + } else { + response.body = { + result: result, + variablesReference: 0 + }; + } + this.sendResponse(response); + } else { + response.success = false; this.sendResponse(response); } } @@ -1050,93 +985,71 @@ export class PerlDebugSession extends LoggingDebugSession { /** * Evaluate command line */ - private evaluateCommandLine(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) { - this.adapter.request(args.expression) - .then((res) => { - if (res.data.length > 1) { - res.data.forEach((line) => { - this.sendEvent(new OutputEvent(`> ${line}\n`)); - }); - response.body = { - result: `Result:`, - variablesReference: 0, - }; - } else { - response.body = { - result: `${res.data[0]}`, - variablesReference: 0 - }; - } - this.sendResponse(response); - }); - }; - - /** - * Fetch expression value - */ - async fetchExpressionRequest(clientExpression): Promise { - - const isVariable = /^([\$|@|%])([a-zA-Z0-9_\'\.]+)$/.test(clientExpression); - - const expression = isVariable ? clientExpression.replace(/\.(\'\w+\'|\w+)/g, (...a) => `->{${a[1]}}`) : clientExpression; + private async evaluateCommandLine(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) { + const res = await this.adapter.request(args.expression); - let value = await this.adapter.getExpressionValue(expression); - if (/^Can\'t use an undefined value as a HASH reference/.test(value)) { - value = undefined; - } - - const reference = isVariable ? await this.adapter.getVariableReference(expression) : null; - if (typeof value !== 'undefined' && /^HASH|ARRAY/.test(reference)) { - return { - value: reference, - reference: reference, + if (res.data.length > 1) { + res.data.forEach((line) => { + this.sendEvent(new OutputEvent(`> ${line}\n`)); + }); + response.body = { + result: `Result:`, + variablesReference: 0, + }; + } else { + response.body = { + result: `${res.data[0]}`, + variablesReference: 0 }; } - return { - value: value, - reference: null, - }; - } - /** - * Evaluate watch - * Note: We don't actually levarage the debugger watch capabilities yet - */ - protected evaluateWatch(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments): void { - // Clear watch if last request wasn't setting a watch? - this.fetchExpressionRequest(args.expression) - .then(result => { - // this.sendEvent(new OutputEvent(`${args.expression}=${result.value} ${typeof result.value} ${result.reference}$')\n`)); - if (typeof result.value !== 'undefined') { - response.body = { - result: result.value, - variablesReference: result.reference === null ? 0 : this._variableHandles.create(result.reference), - }; - } - this.sendResponse(response); - }) - .catch(() => {}); - } + // FIXME(bh): It might make sense to send another stopped event + // here which would trigger an update of the variables view, + // which would be useful if variables have been modified through + // the REPL interface. + // this.sendStoppedEvent("postrepl"); + + this.sendResponse(response); + }; /** * Evaluate */ - protected evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments): void { + protected async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) { if (args.context === 'repl') { + + // The `repl` is a special case since it can be used to send + // commands to the debugger instead of just evaluating actual + // Perl expressions. this.evaluateCommandLine(response, args); + } else if (args.context === 'hover') { + + // And `hover` is a special case because we need to filter out + // bogus or dangerous evaluation requests that cannot or should + // not be satisfied (syntax errors, evaluations with possible + // side effects, and similar issues). this.evaluateHover(response, args); - } else if (args.context === 'watch') { - this.evaluateWatch(response, args); + } else { - this.sendEvent(new OutputEvent(`evaluate(context: '${args.context}', '${args.expression}')`)); + + this.sendEvent(new OutputEvent( + `evaluate(context: '${args.context}', '${args.expression}')` + )); + + const result = await this.adapter.getExpressionValue( + args.expression + ); + response.body = { - result: `evaluate(context: '${args.context}', '${args.expression}')`, + result: result, variablesReference: 0 }; + this.sendResponse(response); } + } /** @@ -1149,22 +1062,31 @@ export class PerlDebugSession extends LoggingDebugSession { // functions, and we might be trying to break on one of them... const stacktrace = await this.adapter.getStackTrace(); - const frames = new Array(); - - // In case this is a trace run on end, we want to return the file with the exception in the @ position - let endFrame = null; - - stacktrace.forEach((trace, i) => { - const frame = new StackFrame(i, `${trace.caller}`, new Source(basename(trace.filename), - this.convertDebuggerPathToClient(trace.filename)), - trace.ln, 0); - frames.push(frame); - if (trace.caller === 'DB::END()') { - endFrame = frame; - } + + const frames = stacktrace.map((trace, i) => { + + return new StackFrame( + i, + `${trace.caller}`, + /^\(eval \d+\)/.test(trace.caller) + ? null + : this.findOrCreateSource( + trace.filename + ), + trace.line, + 0 + ); + }); - if (endFrame) { + // In case this is a trace run on end, we want to return the + // file with the exception in the @ position + + // FIXME(bh): This does not really make sense, for a number of + // reasons. One is that this re-uses a `StackFrame.id`. + + let endFrame = frames[frames.length - 1]; + if (endFrame && endFrame.name === 'DB::END') { frames.unshift(endFrame); } @@ -1191,33 +1113,39 @@ export class PerlDebugSession extends LoggingDebugSession { }); } - private async loadedSourcesRequestAsync(response: DebugProtocol.LoadedSourcesResponse, args: DebugProtocol.LoadedSourcesArguments): Promise { + private findOrCreateSource(path: string): Source { - const loadedFiles = await this.adapter.getLoadedFiles(); + const oldSource = this._loadedSources.get(path); + + if (oldSource) { + return oldSource; + } - const newFiles = loadedFiles.filter( - x => !this._loadedSources.has(x) + const newSource = new Source( + basename(path), + path, + // no sourceReference when debugging locally, so vscode will + // open the local file rather than retrieving a read-only + // version of the code through the debugger (that lacks code + // past `__END__` markers, among possibly other limitations). + this.adapter.canSignalDebugger + ? 1 + this._loadedSources.size // FIXME: XXX + : 1 + this._loadedSources.size ); - for (const file of newFiles) { - - const newSource = new Source( - file, - file, - // no sourceReference when debugging locally, so vscode will - // open the local file rather than retrieving a read-only - // version of the code through the debugger (that lacks code - // past `__END__` markers, among possibly other limitations). - this.adapter.canSignalDebugger - ? 0 - : this._loadedSources.size - ); + this._loadedSources.set(path, newSource); - this.sendEvent(new LoadedSourceEvent("new", newSource)); + this.sendEvent(new LoadedSourceEvent("new", newSource)); - this._loadedSources.set(file, newSource); + return newSource; - } + } + + private async loadedSourcesRequestAsync(response: DebugProtocol.LoadedSourcesResponse, args: DebugProtocol.LoadedSourcesArguments): Promise { + + const loadedFiles = await this.adapter.getLoadedFiles(); + + loadedFiles.forEach(x => this.findOrCreateSource(x)); response.body = { sources: [...this._loadedSources.values()] diff --git a/src/remoteSession.ts b/src/remoteSession.ts index 4efd3d9..ea2497d 100644 --- a/src/remoteSession.ts +++ b/src/remoteSession.ts @@ -100,16 +100,7 @@ export class RemoteSession extends EventEmitter implements DebugSession { } socket.on('data', data => { - // const str = data.toString('utf8'); - // const signature = str.split('\n').pop(); - // xxx: We should figure out a more stable way of differentiating between - // command result and application output - this.stderr.push(data); // xxx: For now we don't forward application output - /* if (debuggerSignature.test(signature)) { - this.stderr.push(data); - } else { - this.stdout.push(data); - }*/ + this.stderr.push(data); }); socket.on('end', data => { diff --git a/src/tests/adapter.test.ts b/src/tests/adapter.test.ts index fdb896d..6be089a 100644 --- a/src/tests/adapter.test.ts +++ b/src/tests/adapter.test.ts @@ -1,8 +1,3 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - import assert = require('assert'); import * as Path from 'path'; import * as fs from 'fs'; @@ -27,6 +22,7 @@ describe('Perl debug Adapter', () => { const FILE_PRINT_ARGUMENTS = 'print_arguments.pl'; const FILE_FAST_TEST_PL = 'fast_test.pl'; const FILE_LONG_RUNNING_PL = 'long_running.pl'; + const FILE_VARS_TEST_PL = "vars_test.pl"; const PERL_DEBUG_LOG = 'perl_debugger.log'; @@ -40,6 +36,11 @@ describe('Perl debug Adapter', () => { program: Path.join(DATA_ROOT, FILE_FAST_TEST_PL), inc: [], args: [], + env: { + // User perlbrew installations should take priority over system + // Perl installations. + PATH: process.env.PATH, + }, stopOnEntry: false, console: 'none', trace: false, @@ -61,6 +62,29 @@ describe('Perl debug Adapter', () => { } }; + const getScopedVars = async ( + dc: DebugClient, + frameId: number, + name: string, + ): Promise => { + + const st = await dc.stackTraceRequest({ + threadId: undefined + }); + + const scopes = await dc.scopesRequest({ + frameId: st.body.stackFrames[frameId].id + }); + + const vars = await dc.variablesRequest({ + variablesReference: scopes.body.scopes.filter( + x => x.name === name + )[0].variablesReference + }); + + return vars; + + }; let dc: DebugClient; @@ -152,6 +176,97 @@ describe('Perl debug Adapter', () => { }); }); + describe('variables', () => { + + it('variable retrieval should work', async () => { + + await dc.launch(Configuration({ + program: FILE_VARS_TEST_PL, + stopOnEntry: true + })); + + const globalVars = await getScopedVars(dc, 0, 'Global'); + + const perlVer = globalVars.body.variables.filter( + x => x.name === '$]' + )[0]; + + assert.ok(/^5\./.test(perlVer.value)); + + const bpRespone = await dc.setBreakpointsRequest({ + source: { + path: FILE_VARS_TEST_PL, + }, + lines: [17], + }); + + assert(bpRespone.success, 'set breakpoint'); + + await Promise.all([ + dc.continueRequest({ threadId: undefined }), + dc.assertStoppedLocation('breakpoint', { + line: 17 + }) + ]); + + const lexicals1 = async () => { + + const lexicalVars = await getScopedVars(dc, 0, 'Lexical'); + + assert.ok( + lexicalVars.body.variables.filter( + x => x.name === '$PKG_MY' && x.value.indexOf('PKG_MY') > 0 + ).length > 0, + 'can see a PKG_MY variable' + ); + + assert.ok( + lexicalVars.body.variables.filter( + x => x.name === '$arg' && x.value.indexOf('inner') > 0 + ).length > 0, + 'can see a arg variable' + ); + + assert.ok( + lexicalVars.body.variables.filter( + x => x.name === '$outer_my' && x.value.indexOf('outer_my') > 0 + ).length === 0, + 'cannot see a $outer_my variable from other stack frame' + ); + + const globalVars = await getScopedVars(dc, 0, 'Global'); + + assert.ok( + globalVars.body.variables.filter( + x => x.name === '$/' + && + x.value.toLowerCase().indexOf('20ac') > 0 + ).length > 0, + 'can see a localised $/ variable set to EURO sign' + ); + + }; + + const lexicals2 = async () => { + + const lexicalVars = await getScopedVars(dc, 1, 'Lexical'); + + assert.ok( + lexicalVars.body.variables.filter( + x => x.name === '$outer_my' && x.value.indexOf('outer_my') > 0 + ).length > 0, + 'can see a $outer_my variable in middle stack frame' + ); + + }; + + await lexicals1(); + await lexicals2(); + + }); + + }); + describe('pause', () => { (platform() === "win32" ? it.skip : it)('should be able to pause programs', async () => { @@ -172,7 +287,7 @@ describe('Perl debug Adapter', () => { // NOTE(bh): Perl's built-in `sleep` function only supports // integer resolution sleeps, so this test is a bit slow. - await new Promise(resolve => setTimeout(resolve, 2200)); + await new Promise(resolve => setTimeout(resolve, 1200)); await dc.pauseRequest({ threadId: undefined, @@ -192,8 +307,8 @@ describe('Perl debug Adapter', () => { }); assert.ok( - parseInt(result.body.result) > 3, - 'must have gone at least twice through the loop' + parseInt(result.body.result, 10) > 2, + 'must have gone at least once through the loop' ); }); diff --git a/src/tests/connection.test.ts b/src/tests/connection.test.ts index 9be67e3..be34e65 100644 --- a/src/tests/connection.test.ts +++ b/src/tests/connection.test.ts @@ -319,28 +319,6 @@ describe('Perl debugger connection', () => { }); }); - describe('getVariableList', () => { - it('Should get more scope variables types', async function() { - await testLaunch(conn, FILE_TEST_PL, DATA_ROOT, []); - await conn.setBreakPoint(23, FILE_MODULE); - - await conn.continue(); - - const vars0 = await conn.getVariableList(0); - const actual = Object.keys(vars0).length; - const expected = [0, 44, 45, 46]; // xxx: Investigate 44+46 might be a perser issue - assert(expected.indexOf(actual) > -1, 'variable count level 0, actual: ' + - actual + ' expected: ' + expected.join(' or ')); - - const vars1 = await conn.getVariableList(1); - assert.equal(Object.keys(vars1).length, 7, 'variable count level 1'); - const vars2 = await conn.getVariableList(2); - assert.equal(Object.keys(vars2).length, 1, 'variable count level 2'); - const vars3 = await conn.getVariableList(3); - assert.equal(Object.keys(vars3).length, 0, 'variable count level 3'); - }); - }); - describe('restart', () => { it('Should start from the beginning', async () => { let res = await testLaunch(conn, FILE_TEST_PL, DATA_ROOT, []); diff --git a/src/tests/data/vars_test.pl b/src/tests/data/vars_test.pl new file mode 100644 index 0000000..76e60c8 --- /dev/null +++ b/src/tests/data/vars_test.pl @@ -0,0 +1,38 @@ +#!/usr/bin/env perl +package Local::Package; +use strict; +use warnings; + +our $PKG_OUR = "our Local::Package PKG_OUR"; +my $PKG_MY = "my Local::Package PKG_MY"; + +sub outer_sub { + my $outer_my = "outer_my"; + inner_sub("argument to inner_sub"); +} + +sub inner_sub { + my ($arg) = @_; + local $/ = "\x{20ac}"; + return $arg; +} + +package main; +use strict; +use warnings; + +my $main_my = "main_my"; +my %hash = ("\%hash_key" => "\%hash_value"); +my $hash_ref = { + "hash_ref_key" => "hash_ref_value" +}; + +my $array_ref = [1..9]; + +my $string = "string"; + +my $ref_to_ref_to_string = \(\("ref_to_ref_to_string")); + +Local::Package::outer_sub(); + +exit 0; diff --git a/src/tests/variableParser.test.ts b/src/tests/variableParser.test.ts deleted file mode 100644 index 9a283f8..0000000 --- a/src/tests/variableParser.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import assert = require('assert'); -import asyncAssert from './asyncAssert'; -import * as Path from 'path'; -import variableParser, { resolveVariable } from '../variableParser'; - -const data = [ '$bar = \'bar\'', - '$hello = HASH(0x7fd2689527f0)', - ' \'bar\' => 12', - ' \'foo\' => \'bar\'', - ' \'really\' => \'true\'', - '$i = 12', - '$obj = HASH(0x7fd26896ecb0)', - ' 8 => \'-9\'', - ' \'bar\' => HASH(0x7fd2689527f0)', - ' \'bar\' => 12', - ' \'foo\' => \'bar\'', - ' \'really\' => \'true\'', - ' \'foo\' => \'bar\'', - ' \'list\' => ARRAY(0x7fd269242a50)', - ' 0 \'a\'', - ' 1 \'\\\'b\'', - ' 2 \'c\'', - ' \'ownObj\' => HASH(0x7fd26892c6c0)', - ' \'ownFoo\' => \'own?\'', - ' \'ownlist\' => 7', - '@list1 = ARRAY(0x7fd269242a50)', - ' 0 \'a\'', - ' 1 \'\\\'b\'', - ' 2 \'c\'', - '@list2 = ARRAY(0x7fd269242a68)', - ' 0 1', - ' 1 2', - ' 2 3', - '@list3 = ARRAY(0x7fd269242b10)', - ' 0 \'a\'', - ' 1 \'\\\'b\'', - ' 2 \'c\'', - ' 3 1', - ' 4 2', - ' 5 3' -]; - -const dataFaulty = [ '$bar = \'bar\'', - '$hello = HASH(0x7fd2689527f0)', - ' \'bar\' => 12', - ' \'foo\' => ', - '\'bar\'', - ' \'really\' => \'true\'', - '$i = 12', - '$obj = ', - 'HASH(0x7fd26896ecb0)', - ' 8 => \'-9\'', - ' \'bar\' => HASH(0x7fd2689527f0)', - ' \'bar\' => 12', - ' \'foo\' => ', - '\'bar\'', - ' \'really\' => \'true\'', - ' \'foo\' => \'bar\'', - ' \'list\' => ', - 'ARRAY(0x7fd269242a50)', - ' 0 \'a\'', - ' 1 ', - '\'\\\'b\'', - ' 2 \'c\'', - ' \'ownObj\' => ', - 'HASH(0x7fd26892c6c0)', - ' \'ownFoo\' => \'own?\'', - ' \'ownlist\' => 7', - '@list1 = ARRAY(0x7fd269242a50)', - ' 0 \'a\'', - ' 1 \'\\\'b\'', - ' 2 \'c\'', - '@list2 = ARRAY(0x7fd269242a68)', - ' 0 1', - ' 1 2', - ' 2 3', - '@list3 = ARRAY(0x7fd269242b10)', - ' 0 \'a\'', - ' 1 \'\\\'b\'', - ' 2 \'c\'', - ' 3 1', - ' 4 2', - ' 5 3' -]; - -const expectedResult = { - 'local_0': - [ { name: '$bar', - value: 'bar', - type: 'string', - variablesReference: '0' }, - { name: '$hello', - value: 'HASH(0x7fd2689527f0)', - type: 'object', - variablesReference: 'HASH(0x7fd2689527f0)' }, - { name: '$i', - value: '12', - type: 'integer', - variablesReference: '0' }, - { name: '$obj', - value: 'HASH(0x7fd26896ecb0)', - type: 'object', - variablesReference: 'HASH(0x7fd26896ecb0)' }, - { name: '@list1', - value: 'ARRAY(0x7fd269242a50)', - type: 'array', - variablesReference: 'ARRAY(0x7fd269242a50)' }, - { name: '@list2', - value: 'ARRAY(0x7fd269242a68)', - type: 'array', - variablesReference: 'ARRAY(0x7fd269242a68)' }, - { name: '@list3', - value: 'ARRAY(0x7fd269242b10)', - type: 'array', - variablesReference: 'ARRAY(0x7fd269242b10)' } ], - 'HASH(0x7fd2689527f0)': - [ { name: 'bar', - value: '12', - type: 'integer', - variablesReference: '0' }, - { name: 'foo', - value: 'bar', - type: 'string', - variablesReference: '0' }, - { name: 'really', - value: 'true', - type: 'boolean', - variablesReference: '0' }, - { name: 'bar', - value: '12', - type: 'integer', - variablesReference: '0' }, - { name: 'foo', - value: 'bar', - type: 'string', - variablesReference: '0' }, - { name: 'really', - value: 'true', - type: 'boolean', - variablesReference: '0' } ], - 'HASH(0x7fd26896ecb0)': - [ { name: '8', - value: '-9', - type: 'integer', - variablesReference: '0' }, - { name: 'bar', - value: 'HASH(0x7fd2689527f0)', - type: 'object', - variablesReference: 'HASH(0x7fd2689527f0)' }, - { name: 'foo', - value: 'bar', - type: 'string', - variablesReference: '0' }, - { name: 'list', - value: 'ARRAY(0x7fd269242a50)', - type: 'array', - variablesReference: 'ARRAY(0x7fd269242a50)' }, - { name: 'ownObj', - value: 'HASH(0x7fd26892c6c0)', - type: 'object', - variablesReference: 'HASH(0x7fd26892c6c0)' }, - { name: 'ownlist', - value: '7', - type: 'integer', - variablesReference: '0' } ], - 'ARRAY(0x7fd269242a50)': - [ { name: '0', - value: 'a', - type: 'string', - variablesReference: '0' }, - { name: '1', - value: '\\\'b', - type: 'string', - variablesReference: '0' }, - { name: '2', - value: 'c', - type: 'string', - variablesReference: '0' }, - { name: '0', - value: 'a', - type: 'string', - variablesReference: '0' }, - { name: '1', - value: '\\\'b', - type: 'string', - variablesReference: '0' }, - { name: '2', - value: 'c', - type: 'string', - variablesReference: '0' } ], - 'HASH(0x7fd26892c6c0)': - [ { name: 'ownFoo', - value: 'own?', - type: 'string', - variablesReference: '0' } ], - 'ARRAY(0x7fd269242a68)': - [ { name: '0', - value: '1', - type: 'integer', - variablesReference: '0' }, - { name: '1', - value: '2', - type: 'integer', - variablesReference: '0' }, - { name: '2', - value: '3', - type: 'integer', - variablesReference: '0' } ], - 'ARRAY(0x7fd269242b10)': - [ { name: '0', - value: 'a', - type: 'string', - variablesReference: '0' }, - { name: '1', - value: '\\\'b', - type: 'string', - variablesReference: '0' }, - { name: '2', - value: 'c', - type: 'string', - variablesReference: '0' }, - { name: '3', - value: '1', - type: 'integer', - variablesReference: '0' }, - { name: '4', - value: '2', - type: 'integer', - variablesReference: '0' }, - { name: '5', - value: '3', - type: 'integer', - variablesReference: '0' }] -}; - -describe('variableParser', () => { - it('works on good data', () => { - const result = variableParser(data, 'local_0'); - - assert.deepEqual(result, expectedResult); - }); - it('works on faulty data', () => { - const result = variableParser(dataFaulty, 'local_0'); - - assert.deepEqual(result, expectedResult); - }); -}); - -describe('resolveVariable', () => { - it('works', () => { - const variables = variableParser(data, 'local_0'); - assert.equal(resolveVariable('8', 'HASH(0x7fd26896ecb0)', variables), '$obj->{8}'); - assert.equal(resolveVariable('$bar', 'local_0', variables), '$bar'); - assert.equal(resolveVariable('8', 'ARRAY(0x7fd269242a50)', variables), '$list1[8]'); - assert.equal(resolveVariable('ownFoo', 'HASH(0x7fd26892c6c0)', variables), '$obj->{ownObj}{ownFoo}'); - }); -}); diff --git a/src/variableParser.ts b/src/variableParser.ts deleted file mode 100644 index 1aa76d5..0000000 --- a/src/variableParser.ts +++ /dev/null @@ -1,203 +0,0 @@ -/* - -{ - 'HASH(0x7f92619e1b00)': [ - { - name: 'bar', - value: 12, - type: 'integer', - variablesReference: 0, - } - ] - 0: [ - { - name: '$hello', - value: 'HASH(0x7f92619e1b00)', - type: 'object', - variablesReference: 'HASH(0x7f92619e1b00)', - } - ] -} - -*/ - -function getIndent(text: string) { - return text.match(/^\s*/)[0].length; -} - -const indent = 3; // Perl debugger variable indent - -export function variableType(value) { - if (/^\'?(\-)?[0-9]+\'?$/.test(value)) return 'integer'; - if (/^\'?(\-)?[0-9.,]+\'?$/.test(value)) return 'float'; - if (/true|false/.test(value)) return 'boolean'; - if (/^\'/.test(value)) return 'string'; - if (/^ARRAY/.test(value)) return 'array'; - if (/^HASH/.test(value)) return 'object'; - return 'unknown'; -} - -function variableReference(value: string): string { - if (/^ARRAY|HASH/.test(value)) return value; - return '0'; -} - -function cleanString(value: string): string { - if (/^\'/.test(value) && /\'$/.test(value)) { - return value.replace(/^\'/, '').replace(/\'$/, '') - } - return value; -} - -export interface ParsedVariable { - name: string, - value: string, - type: string, - variablesReference: number | string, -} - -export interface ParsedVariableScope { - [id: string]: ParsedVariable[] -} - -function createVariable(key: string, val: string): ParsedVariable { - const name: string = cleanString(key); - const value: string = cleanString(val); - return { - name, - value, - type: variableType(val), - variablesReference: variableReference(value) - }; -} - -interface VariableSearchResult { - variable: ParsedVariable, - parentName: string | number, -} - -function findVariableReference(variables: ParsedVariableScope, variablesReference: string): VariableSearchResult | null { - const variableScopes = Object.keys(variables); - let parentName = 0; - let variable: ParsedVariable | null = null; - for (let i = 0; i < variableScopes.length; i++) { - const parentName = variableScopes[i]; - const scope = variables[parentName]; - for (let b = 0; b < scope.length; b++) { - variable = scope[b]; - // Check if we found the needle - if (variable.variablesReference === variablesReference) { - return { - variable, - parentName, - } - } - } - } - return null; -} - -const topScope = /global_0|local_0|closure_0/; - -export function resolveVariable(name, variablesReference, variables) { - // Resolve variables - let limit = 0; - let id = variablesReference; - let key = name; - const result = []; - - while (limit < 50 && !topScope.test(id)) { - const parent = findVariableReference(variables, id); - if (!parent) { - throw new Error(`Cannot find variable "${id}"`); - } - if (parent.variable.type == 'array') { - result.unshift(`[${key}]`); - } else if (parent.variable.type == 'object') { - result.unshift(`{${key}}`); - } else { - throw new Error('This dosnt look right'); - } - - id = parent.parentName; - key = parent.variable.name; - - limit++; - } - - // return the var if it's a simple non-composite scalar var - if (!result.length) { return key; } - - // this is a structured var: array (@), hash (%), object ($) or a ref ($) to them - if (/^\$/.test(key)) { result.unshift(key,'->'); } // it's a ref, so we need $ar->[k], $hr->{k}, $obj->{k} - else { - // non-ref: @a or %h, so we need $a[k] and $h{k} - // So we replace the @ and % sigils with $ - key = key.replace(/^(@|%)/,'$'); - result.unshift(key); - } - - return result.join(''); // combine the var name with the dereference chain -} - -/** - * Fixes faulty variable data, an issue on windows - * - * Eg.: These lines are symptoms off an issue - * ' 1 ' - * ' \'list\' => ' - * '$obj = ' - */ -function fixFaultyData(data: string[]): string[] { - const result: string[] = []; - let merge = ''; - data.forEach(line => { - if (/=>? $/.test(line) || /([0-9]+) $/.test(line)) { - merge = line; - } else { - result.push(merge + line); - merge = ''; - } - }); - return result; -} - -export default function(data: string[], scopeName: string = '0'): ParsedVariableScope { - const result = {}; - const context: string[] = [scopeName]; - let lastReference = scopeName; - // console.log('-----> SCOPE', scopeName); - fixFaultyData(data).forEach(line => { - const contextIndent = context.length - 1; - const lineIndent = getIndent(line) / indent; - - try { - const [name, value] = line.match(/^([\s+]{0,})(\S+) =?>? ([\S\s]+)/).splice(2, 2); - if (contextIndent > lineIndent) { - context.splice(0, contextIndent - lineIndent); - } else if (contextIndent < lineIndent) { - context.unshift(lastReference); - } - // Check the indent poping / pushing context - // console.log(lineIndent, line, `Context: "${context[0]}"`); - - // Ensure reference container - if (typeof result[context[0]] === 'undefined') { - result[context[0]] = []; - } - - // Push variable to reference container - result[context[0]].push(createVariable(name, value)); - - // Post - lastReference = value; - } catch(err) { - // TODO: Figure out why this happens... - // console.log('ERR:', line); - } - }); - - // console.log(result); - - return result; -}