[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;
+  }
 }