[dds] Add expression eval support to DAP
Change-Id: I0c55b4dde12d40467f8243e4b0c0ccc882eb045d
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/203243
Reviewed-by: Ben Konyi <bkonyi@google.com>
Commit-Queue: Ben Konyi <bkonyi@google.com>
diff --git a/pkg/dds/lib/src/dap/adapters/dart.dart b/pkg/dds/lib/src/dap/adapters/dart.dart
index 50f5e74..12fc769 100644
--- a/pkg/dds/lib/src/dap/adapters/dart.dart
+++ b/pkg/dds/lib/src/dap/adapters/dart.dart
@@ -23,6 +23,18 @@
/// client requests 500 items in a variablesRequest for a list.
const maxToStringsPerEvaluation = 10;
+/// An expression that evaluates to the exception for the current thread.
+///
+/// In order to support some functionality like "Copy Value" in VS Code's
+/// Scopes/Variables window, each variable must have a valid "evaluateName" (an
+/// expression that evaluates to it). Since we show exceptions in there we use
+/// this magic value as an expression that maps to it.
+///
+/// This is not intended to be used by the user directly, although if they
+/// evaluate it as an expression and the current thread has an exception, it
+/// will work.
+const threadExceptionExpression = r'$_threadException';
+
/// A base DAP Debug Adapter implementation for running and debugging Dart-based
/// applications (including Flutter and Tests).
///
@@ -268,6 +280,94 @@
sendResponse();
}
+ /// evaluateRequest is called by the client to evaluate a string expression.
+ ///
+ /// This could come from the user typing into an input (for example VS Code's
+ /// Debug Console), automatic refresh of a Watch window, or called as part of
+ /// an operation like "Copy Value" for an item in the watch/variables window.
+ ///
+ /// If execution is not paused, the `frameId` will not be provided.
+ @override
+ Future<void> evaluateRequest(
+ Request request,
+ EvaluateArguments args,
+ void Function(EvaluateResponseBody) sendResponse,
+ ) async {
+ final frameId = args.frameId;
+ // TODO(dantup): Special handling for clipboard/watch (see Dart-Code DAP) to
+ // avoid wrapping strings in quotes, etc.
+
+ // If the frameId was supplied, it maps to an ID we provided from stored
+ // data so we need to look up the isolate + frame index for it.
+ ThreadInfo? thread;
+ int? frameIndex;
+ if (frameId != null) {
+ final data = _isolateManager.getStoredData(frameId);
+ if (data != null) {
+ thread = data.thread;
+ frameIndex = (data.data as vm.Frame).index;
+ }
+ }
+
+ if (thread == null || frameIndex == null) {
+ // TODO(dantup): Dart-Code evaluates these in the context of the rootLib
+ // rather than just not supporting it. Consider something similar (or
+ // better here).
+ throw UnimplementedError('Global evaluation not currently supported');
+ }
+
+ // The value in the constant `frameExceptionExpression` is used as a special
+ // expression that evaluates to the exception on the current thread. This
+ // allows us to construct evaluateNames that evaluate to the fields down the
+ // tree to support some of the debugger functionality (for example
+ // "Copy Value", which re-evaluates).
+ final expression = args.expression.trim();
+ final exceptionReference = thread.exceptionReference;
+ final isExceptionExpression = expression == threadExceptionExpression ||
+ expression.startsWith('$threadExceptionExpression.');
+
+ vm.Response? result;
+ if (exceptionReference != null && isExceptionExpression) {
+ result = await _evaluateExceptionExpression(
+ exceptionReference,
+ expression,
+ thread,
+ );
+ } else {
+ result = await vmService?.evaluateInFrame(
+ thread.isolate.id!,
+ frameIndex,
+ expression,
+ disableBreakpoints: true,
+ );
+ }
+
+ if (result is vm.ErrorRef) {
+ throw DebugAdapterException(result.message ?? '<error ref>');
+ } else if (result is vm.Sentinel) {
+ throw DebugAdapterException(result.valueAsString ?? '<collected>');
+ } else if (result is vm.InstanceRef) {
+ final resultString = await _converter.convertVmInstanceRefToDisplayString(
+ thread,
+ result,
+ allowCallingToString: true,
+ );
+ // TODO(dantup): We may need to store `expression` with this data
+ // to allow building nested evaluateNames.
+ final variablesReference =
+ _converter.isSimpleKind(result.kind) ? 0 : thread.storeData(result);
+
+ sendResponse(EvaluateResponseBody(
+ result: resultString,
+ variablesReference: variablesReference,
+ ));
+ } else {
+ throw DebugAdapterException(
+ 'Unknown evaluation response type: ${result?.runtimeType}',
+ );
+ }
+ }
+
/// [initializeRequest] is the first call from the client during
/// initialization and allows exchanging capabilities and configuration
/// between client and server.
@@ -662,7 +762,7 @@
// TODO(dantup): evaluateName
// should be built taking the parent into account, for ex. if
// args.variablesReference == thread.exceptionReference then we need to
- // use some sythensized variable name like $e.
+ // use some sythensized variable name like `frameExceptionExpression`.
variables.addAll(await _converter.convertVmInstanceToVariablesList(
thread,
object,
@@ -683,6 +783,38 @@
sendResponse(VariablesResponseBody(variables: variables));
}
+ /// Handles evaluation of an expression that is (or begins with)
+ /// `threadExceptionExpression` which corresponds to the exception at the top
+ /// of [thread].
+ Future<vm.Response?> _evaluateExceptionExpression(
+ int exceptionReference,
+ String expression,
+ ThreadInfo thread,
+ ) async {
+ final exception = _isolateManager.getStoredData(exceptionReference)?.data
+ as vm.InstanceRef?;
+
+ if (exception == null) {
+ return null;
+ }
+
+ if (expression == threadExceptionExpression) {
+ return exception;
+ }
+
+ // Strip the prefix off since we'll evaluate against the exception
+ // by its ID.
+ final expressionWithoutExceptionExpression =
+ expression.substring(threadExceptionExpression.length + 1);
+
+ return vmService?.evaluate(
+ thread.isolate.id!,
+ exception.id!,
+ expressionWithoutExceptionExpression,
+ disableBreakpoints: true,
+ );
+ }
+
void _handleDebugEvent(vm.Event event) {
_isolateManager.handleEvent(event);
}
diff --git a/pkg/dds/lib/src/dap/base_debug_adapter.dart b/pkg/dds/lib/src/dap/base_debug_adapter.dart
index cff12c2..e3c53db 100644
--- a/pkg/dds/lib/src/dap/base_debug_adapter.dart
+++ b/pkg/dds/lib/src/dap/base_debug_adapter.dart
@@ -4,6 +4,7 @@
import 'dart:async';
+import 'exceptions.dart';
import 'logging.dart';
import 'protocol_common.dart';
import 'protocol_generated.dart';
@@ -64,6 +65,12 @@
void Function() sendResponse,
);
+ Future<void> evaluateRequest(
+ Request request,
+ EvaluateArguments args,
+ void Function(EvaluateResponseBody) sendResponse,
+ );
+
/// Calls [handler] for an incoming request, using [fromJson] to parse its
/// arguments from the request.
///
@@ -114,7 +121,7 @@
requestSeq: request.seq,
seq: _sequence++,
command: request.command,
- message: '$e',
+ message: e is DebugAdapterException ? e.message : '$e',
body: '$s',
);
_channel.sendResponse(response);
@@ -279,6 +286,8 @@
handle(request, scopesRequest, ScopesArguments.fromJson);
} else if (request.command == 'variables') {
handle(request, variablesRequest, VariablesArguments.fromJson);
+ } else if (request.command == 'evaluate') {
+ handle(request, evaluateRequest, EvaluateArguments.fromJson);
} else {
final response = Response(
success: false,
diff --git a/pkg/dds/lib/src/dap/protocol_converter.dart b/pkg/dds/lib/src/dap/protocol_converter.dart
index 7291ce8..85ef5f8 100644
--- a/pkg/dds/lib/src/dap/protocol_converter.dart
+++ b/pkg/dds/lib/src/dap/protocol_converter.dart
@@ -106,7 +106,7 @@
final associations = instance.associations;
final fields = instance.fields;
- if (_isSimpleKind(instance.kind)) {
+ if (isSimpleKind(instance.kind)) {
// For simple kinds, just return a single variable with their value.
return [
await convertVmResponseToVariable(
@@ -235,7 +235,7 @@
// For non-simple variables, store them and produce a new reference that
// can be used to access their fields/items/associations.
final variablesReference =
- _isSimpleKind(response.kind) ? 0 : thread.storeData(response);
+ isSimpleKind(response.kind) ? 0 : thread.storeData(response);
return dap.Variable(
name: name ?? response.kind.toString(),
@@ -371,6 +371,17 @@
}
}
+ /// Whether [kind] is a simple kind, and does not need to be mapped to a variable.
+ bool isSimpleKind(String? kind) {
+ return kind == 'String' ||
+ kind == 'Bool' ||
+ kind == 'Int' ||
+ kind == 'Num' ||
+ kind == 'Double' ||
+ kind == 'Null' ||
+ kind == 'Closure';
+ }
+
/// Invokes the toString() method on a [vm.InstanceRef] and converts the
/// response to a user-friendly display string.
///
@@ -434,15 +445,4 @@
return getterNames;
}
-
- /// Whether [kind] is a simple kind, and does not need to be mapped to a variable.
- bool _isSimpleKind(String? kind) {
- return kind == 'String' ||
- kind == 'Bool' ||
- kind == 'Int' ||
- kind == 'Num' ||
- kind == 'Double' ||
- kind == 'Null' ||
- kind == 'Closure';
- }
}
diff --git a/pkg/dds/test/dap/integration/debug_eval_test.dart b/pkg/dds/test/dap/integration/debug_eval_test.dart
new file mode 100644
index 0000000..00a4408
--- /dev/null
+++ b/pkg/dds/test/dap/integration/debug_eval_test.dart
@@ -0,0 +1,159 @@
+// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:dds/src/dap/adapters/dart.dart';
+import 'package:test/test.dart';
+
+import 'test_client.dart';
+import 'test_support.dart';
+
+main() {
+ testDap((dap) async {
+ group('debug mode evaluation', () {
+ test('evaluates expressions with simple results', () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ var a = 1;
+ var b = 2;
+ var c = 'test';
+ print('Hello!'); // BREAKPOINT
+}''');
+ final breakpointLine = lineWith(testFile, '// BREAKPOINT');
+
+ final stop = await client.hitBreakpoint(testFile, breakpointLine);
+ await client.expectTopFrameEvalResult(stop.threadId!, 'a', '1');
+ await client.expectTopFrameEvalResult(stop.threadId!, 'a * b', '2');
+ await client.expectTopFrameEvalResult(stop.threadId!, 'c', '"test"');
+ });
+
+ test('evaluates expressions with complex results', () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ print('Hello!'); // BREAKPOINT
+}''');
+ final breakpointLine = lineWith(testFile, '// BREAKPOINT');
+
+ final stop = await client.hitBreakpoint(testFile, breakpointLine);
+ final result = await client.expectTopFrameEvalResult(
+ stop.threadId!,
+ 'DateTime(2000, 1, 1)',
+ 'DateTime',
+ );
+
+ // Check we got a variablesReference that maps on to the fields.
+ expect(result.variablesReference, greaterThan(0));
+ await client.expectVariables(
+ result.variablesReference,
+ '''
+ isUtc: false
+ ''',
+ );
+ });
+
+ test(
+ 'evaluates complex expressions expressions with evaluateToStringInDebugViews=true',
+ () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ print('Hello!'); // BREAKPOINT
+}''');
+ final breakpointLine = lineWith(testFile, '// BREAKPOINT');
+
+ final stop = await client.hitBreakpoint(
+ testFile,
+ breakpointLine,
+ launch: () =>
+ client.launch(testFile.path, evaluateToStringInDebugViews: true),
+ );
+
+ await client.expectTopFrameEvalResult(
+ stop.threadId!,
+ 'DateTime(2000, 1, 1)',
+ 'DateTime (2000-01-01 00:00:00.000)',
+ );
+ });
+
+ test(
+ 'evaluates $threadExceptionExpression to the threads exception (simple type)',
+ () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ throw 'my error';
+}''');
+
+ final stop = await client.hitException(testFile);
+
+ final result = await client.expectTopFrameEvalResult(
+ stop.threadId!,
+ threadExceptionExpression,
+ '"my error"',
+ );
+ expect(result.variablesReference, equals(0));
+ });
+
+ test(
+ 'evaluates $threadExceptionExpression to the threads exception (complex type)',
+ () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ throw Exception('my error');
+}''');
+
+ final stop = await client.hitException(testFile);
+ final result = await client.expectTopFrameEvalResult(
+ stop.threadId!,
+ threadExceptionExpression,
+ '_Exception',
+ );
+ expect(result.variablesReference, greaterThan(0));
+ });
+
+ test(
+ 'evaluates $threadExceptionExpression.x.y to x.y on the threads exception',
+ () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ throw Exception('12345');
+}
+ ''');
+
+ final stop = await client.hitException(testFile);
+ await client.expectTopFrameEvalResult(
+ stop.threadId!,
+ '$threadExceptionExpression.message.length',
+ '5',
+ );
+ });
+
+ test('can evaluate expressions in non-top frames', () async {
+ final client = dap.client;
+ final testFile = await dap.createTestFile(r'''
+void main(List<String> args) {
+ var a = 999;
+ foo();
+}
+
+void foo() {
+ var a = 111; // BREAKPOINT
+}''');
+ final breakpointLine = lineWith(testFile, '// BREAKPOINT');
+
+ final stop = await client.hitBreakpoint(testFile, breakpointLine);
+ final stack = await client.getValidStack(stop.threadId!,
+ startFrame: 0, numFrames: 2);
+ final secondFrameId = stack.stackFrames[1].id;
+
+ await client.expectEvalResult(secondFrameId, 'a', '999');
+ });
+
+ // These tests can be slow due to starting up the external server process.
+ }, timeout: Timeout.none);
+ });
+}
diff --git a/pkg/dds/test/dap/integration/test_client.dart b/pkg/dds/test/dap/integration/test_client.dart
index f6c2a78..f1fade0 100644
--- a/pkg/dds/test/dap/integration/test_client.dart
+++ b/pkg/dds/test/dap/integration/test_client.dart
@@ -79,6 +79,23 @@
Future<Response> disconnect() => sendRequest(DisconnectArguments());
+ /// Sends an evaluate request for the given [expression], optionally for a
+ /// specific [frameId].
+ ///
+ /// Returns a Future that completes when the server returns a corresponding
+ /// response.
+ Future<Response> evaluate(
+ String expression, {
+ int? frameId,
+ String? context,
+ }) {
+ return sendRequest(EvaluateArguments(
+ expression: expression,
+ frameId: frameId,
+ context: context,
+ ));
+ }
+
/// Returns a Future that completes with the next [event] event.
Future<Event> event(String event) => _logIfSlow(
'Event "$event"',
@@ -549,4 +566,35 @@
return variables;
}
+
+ /// Evalutes [expression] in the top frame of thread [threadId] and expects a
+ /// specific [expectedResult].
+ Future<EvaluateResponseBody> expectTopFrameEvalResult(
+ int threadId,
+ String expression,
+ String expectedResult,
+ ) async {
+ final stack = await getValidStack(threadId, startFrame: 0, numFrames: 1);
+ final topFrameId = stack.stackFrames.first.id;
+
+ return expectEvalResult(topFrameId, expression, expectedResult);
+ }
+
+ /// Evalutes [expression] in frame [frameId] and expects a specific
+ /// [expectedResult].
+ Future<EvaluateResponseBody> expectEvalResult(
+ int frameId,
+ String expression,
+ String expectedResult,
+ ) async {
+ final response = await evaluate(expression, frameId: frameId);
+ expect(response.success, isTrue);
+ expect(response.command, equals('evaluate'));
+ final body =
+ EvaluateResponseBody.fromJson(response.body as Map<String, Object?>);
+
+ expect(body.result, equals(expectedResult));
+
+ return body;
+ }
}