| // 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 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:collection/collection.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:vm_service/vm_service.dart' as vm; |
| |
| import '../../dap.dart'; |
| import 'isolate_manager.dart'; |
| import 'protocol_generated.dart' as dap; |
| import 'variables.dart'; |
| |
| /// A helper that handlers converting to/from DAP and VM Service types and to |
| /// user-friendly display strings. |
| /// |
| /// This class may call back to the VM Service to fetch additional information |
| /// when converting classes - for example when converting a stack frame it may |
| /// fetch scripts from the VM Service in order to map token positions back to |
| /// line/columns as required by DAP. |
| class ProtocolConverter { |
| /// The parent debug adapter, used to access arguments and the VM Service for |
| /// the debug session. |
| final DartDebugAdapter _adapter; |
| |
| ProtocolConverter(this._adapter); |
| |
| /// Converts an absolute path to one relative to the cwd used to launch the |
| /// application. |
| /// |
| /// If [sourcePath] is outside of the cwd used for launching the application |
| /// then the full absolute path will be returned. |
| String convertToRelativePath(String sourcePath) { |
| final cwd = _adapter.args.cwd; |
| if (cwd == null) { |
| return sourcePath; |
| } |
| final rel = path.relative(sourcePath, from: cwd); |
| return !rel.startsWith('..') ? rel : sourcePath; |
| } |
| |
| /// Converts a [vm.InstanceRef] into a user-friendly display string. |
| /// |
| /// This may be shown in the collapsed view of a complex type. |
| /// |
| /// If [allowCallingToString] is true, the toString() method may be called on |
| /// the object for a display string. |
| /// |
| /// Strings are usually wrapped in quotes to indicate their type. This can be |
| /// controlled with [includeQuotesAroundString] (for example to suppress them |
| /// if the context indicates the user is copying the value to the clipboard). |
| Future<String> convertVmInstanceRefToDisplayString( |
| ThreadInfo thread, |
| vm.InstanceRef ref, { |
| required bool allowCallingToString, |
| bool allowTruncatedValue = true, |
| VariableFormat? format, |
| }) async { |
| final isTruncated = ref.valueAsStringIsTruncated ?? false; |
| final valueAsString = ref.valueAsString; |
| final formatter = format ?? const VariableFormat(); |
| if (ref.kind == vm.InstanceKind.kString && isTruncated) { |
| // Call toString() if allowed (and we don't already have a value), |
| // otherwise (or if it returns null) fall back to the truncated value |
| // with "…" suffix. |
| var stringValue = allowCallingToString && |
| (valueAsString == null || !allowTruncatedValue) |
| ? await _callToString(thread, ref, |
| // Quotes are handled below, so they can be wrapped around the |
| // ellipsis. |
| format: VariableFormat.noQuotes()) |
| : null; |
| stringValue ??= '$valueAsString…'; |
| |
| return formatter.formatString(stringValue); |
| } else if (ref.kind == vm.InstanceKind.kString) { |
| // Untruncated strings. |
| return formatter.formatString(valueAsString ?? ""); |
| } else if (valueAsString != null) { |
| if (isTruncated) { |
| return '$valueAsString…'; |
| } else if (ref.kind == vm.InstanceKind.kInt) { |
| return formatter.formatInt(int.tryParse(valueAsString)); |
| } else { |
| return valueAsString.toString(); |
| } |
| } else if (ref.kind == 'PlainInstance') { |
| var stringValue = ref.classRef?.name ?? '<unknown instance>'; |
| if (allowCallingToString) { |
| final toStringValue = await _callToString(thread, ref, |
| // Suppress quotes because this is going inside a longer string. |
| format: VariableFormat.noQuotes()); |
| // Include the toString() result only if it's not the default (which |
| // duplicates the type name we're already showing). |
| if (toStringValue != "Instance of '${ref.classRef?.name}'") { |
| stringValue += ' ($toStringValue)'; |
| } |
| } |
| return stringValue; |
| } else if (_isList(ref)) { |
| return '${ref.kind} (${ref.length} ${ref.length == 1 ? "item" : "items"})'; |
| } else if (_isMap(ref)) { |
| return 'Map (${ref.length} ${ref.length == 1 ? "item" : "items"})'; |
| } else if (ref.kind == 'Type') { |
| return 'Type (${ref.name})'; |
| } else { |
| return ref.kind ?? '<unknown result>'; |
| } |
| } |
| |
| /// Converts a [vm.Instance] to a list of [dap.Variable]s, one for each |
| /// field/member/element/association. |
| /// |
| /// If [startItem] and/or [numItems] are supplied, it is assumed that the |
| /// elements/associations/bytes in [instance] have been restricted to that set |
| /// when fetched from the VM. |
| Future<List<dap.Variable>> convertVmInstanceToVariablesList( |
| ThreadInfo thread, |
| vm.Instance instance, { |
| required String? evaluateName, |
| required bool allowCallingToString, |
| int? startItem = 0, |
| int? numItems, |
| VariableFormat? format, |
| }) async { |
| final elements = instance.elements; |
| final associations = instance.associations; |
| final fields = instance.fields; |
| |
| if (isSimpleKind(instance.kind)) { |
| // For simple kinds, just return a single variable with their value. |
| return [ |
| await convertVmResponseToVariable( |
| thread, |
| instance, |
| name: null, |
| evaluateName: evaluateName, |
| allowCallingToString: allowCallingToString, |
| format: format, |
| ) |
| ]; |
| } else if (elements != null) { |
| // For lists, map each item (in the requested subset) to a variable. |
| final start = startItem ?? 0; |
| return Future.wait(elements.cast<vm.Response>().mapIndexed( |
| (index, response) { |
| final name = '[${start + index}]'; |
| final itemEvaluateName = |
| _adapter.combineEvaluateName(evaluateName, name); |
| if (response is vm.InstanceRef) { |
| _adapter.storeEvaluateName(response, itemEvaluateName); |
| } |
| |
| return convertVmResponseToVariable( |
| thread, |
| response, |
| name: name, |
| evaluateName: itemEvaluateName, |
| allowCallingToString: |
| allowCallingToString && index <= maxToStringsPerEvaluation, |
| format: format, |
| ); |
| }, |
| )); |
| } else if (associations != null) { |
| // For maps, create a variable for each entry (in the requested subset). |
| // Use the keys and values to create a display string in the form |
| // "Key -> Value". |
| // Both the key and value will be expandable (handled by variablesRequest |
| // detecting the MapAssociation type). |
| final start = startItem ?? 0; |
| return Future.wait(associations.mapIndexed((index, mapEntry) async { |
| final key = mapEntry.key; |
| final value = mapEntry.value; |
| final callToString = |
| allowCallingToString && index <= maxToStringsPerEvaluation; |
| |
| final keyDisplay = await convertVmResponseToDisplayString( |
| thread, |
| key, |
| allowCallingToString: callToString, |
| format: format, |
| ); |
| final valueDisplay = await convertVmResponseToDisplayString( |
| thread, |
| value, |
| allowCallingToString: callToString, |
| format: format, |
| ); |
| |
| // We only provide an evaluateName for the value, and only if the |
| // key is a simple value. |
| if (key is vm.InstanceRef && |
| value is vm.InstanceRef && |
| evaluateName != null && |
| isSimpleKind(key.kind)) { |
| _adapter.storeEvaluateName(value, '$evaluateName[$keyDisplay]'); |
| } |
| |
| return dap.Variable( |
| name: '${start + index}', |
| value: '$keyDisplay -> $valueDisplay', |
| variablesReference: thread.storeData(VariableData(mapEntry, format)), |
| ); |
| })); |
| } else if (_isList(instance) && |
| instance.length != null && |
| instance.bytes != null) { |
| final elements = _decodeList(instance); |
| |
| final start = startItem ?? 0; |
| return elements.mapIndexed( |
| (index, element) { |
| final name = '[${start + index}]'; |
| return dap.Variable( |
| name: name, |
| evaluateName: _adapter.combineEvaluateName(evaluateName, name), |
| value: element.toString(), |
| variablesReference: 0, |
| ); |
| }, |
| ).toList(); |
| } else if (fields != null) { |
| // Otherwise, show the fields from the instance. |
| final variables = await Future.wait(fields.mapIndexed( |
| (index, field) async { |
| var name = field.decl?.name; |
| if (name == null && field.name is int) { |
| // Indexed record fields are given only their index as the name, but |
| // users will expect to see them as they are accessed like `$1`. |
| name = '\$${field.name}'; |
| } else { |
| name ??= field.name; |
| } |
| final fieldEvaluateName = name != null |
| ? _adapter.combineEvaluateName(evaluateName, '.$name') |
| : null; |
| if (fieldEvaluateName != null) { |
| _adapter.storeEvaluateName(field.value, fieldEvaluateName); |
| } |
| return convertVmResponseToVariable( |
| thread, |
| field.value, |
| name: name ?? '<unnamed field>', |
| evaluateName: fieldEvaluateName, |
| allowCallingToString: |
| allowCallingToString && index <= maxToStringsPerEvaluation, |
| format: format, |
| ); |
| }, |
| )); |
| |
| // Also evaluate the getters if evaluateGettersInDebugViews=true enabled. |
| final service = _adapter.vmService; |
| if (service != null && |
| (_adapter.args.evaluateGettersInDebugViews ?? false)) { |
| // Collect getter names for this instances class and its supers. |
| final getterNames = |
| await _getterNamesForClassHierarchy(thread, instance.classRef); |
| |
| /// Helper to evaluate each getter and convert the response to a |
| /// variable. |
| Future<dap.Variable> evaluate(int index, String getterName) async { |
| try { |
| final response = await service.evaluate( |
| thread.isolate.id!, |
| instance.id!, |
| getterName, |
| ); |
| final fieldEvaluateName = |
| _adapter.combineEvaluateName(evaluateName, '.$getterName'); |
| if (response is vm.InstanceRef) { |
| _adapter.storeEvaluateName(response, fieldEvaluateName); |
| } |
| // Convert results to variables. |
| return convertVmResponseToVariable( |
| thread, |
| response, |
| name: getterName, |
| evaluateName: |
| _adapter.combineEvaluateName(evaluateName, '.$getterName'), |
| allowCallingToString: |
| allowCallingToString && index <= maxToStringsPerEvaluation, |
| format: format, |
| ); |
| } catch (e) { |
| return dap.Variable( |
| name: getterName, |
| value: _adapter.extractEvaluationErrorMessage('$e'), |
| variablesReference: 0, |
| ); |
| } |
| } |
| |
| variables.addAll(await Future.wait(getterNames.mapIndexed(evaluate))); |
| } |
| |
| // Sort the fields/getters by name. |
| variables.sortBy((v) => v.name); |
| |
| return variables; |
| } else { |
| // For any other type that we don't produce variables for, return an empty |
| // list. |
| return []; |
| } |
| } |
| |
| /// Decodes the bytes of a list from the base64 encoded string |
| /// [instance.bytes]. |
| List<Object?> _decodeList(vm.Instance instance) { |
| final bytes = base64Decode(instance.bytes!); |
| switch (instance.kind) { |
| case 'Uint8ClampedList': |
| return bytes.buffer.asUint8ClampedList(); |
| case 'Uint8List': |
| return bytes.buffer.asUint8List(); |
| case 'Uint16List': |
| return bytes.buffer.asUint16List(); |
| case 'Uint32List': |
| return bytes.buffer.asUint32List(); |
| case 'Uint64List': |
| return bytes.buffer.asUint64List(); |
| case 'Int8List': |
| return bytes.buffer.asInt8List(); |
| case 'Int16List': |
| return bytes.buffer.asInt16List(); |
| case 'Int32List': |
| return bytes.buffer.asInt32List(); |
| case 'Int64List': |
| return bytes.buffer.asInt64List(); |
| case 'Float32List': |
| return bytes.buffer.asFloat32List(); |
| case 'Float64List': |
| return bytes.buffer.asFloat64List(); |
| case 'Int32x4List': |
| return bytes.buffer.asInt32x4List(); |
| case 'Float32x4List': |
| return bytes.buffer.asFloat32x4List(); |
| case 'Float64x2List': |
| return bytes.buffer.asFloat64x2List(); |
| default: |
| // A list type we don't know how to decode. |
| return []; |
| } |
| } |
| |
| /// Converts a [vm.Response] into a user-friendly display string. |
| /// |
| /// This may be shown in the collapsed view of a complex type. |
| /// |
| /// If [allowCallingToString] is true, the toString() method may be called on |
| /// the object for a display string. |
| Future<String> convertVmResponseToDisplayString( |
| ThreadInfo thread, |
| vm.Response response, { |
| required bool allowCallingToString, |
| VariableFormat? format, |
| }) async { |
| if (response is vm.InstanceRef) { |
| return convertVmInstanceRefToDisplayString( |
| thread, |
| response, |
| allowCallingToString: allowCallingToString, |
| format: format, |
| ); |
| } else if (response is vm.ErrorRef) { |
| final errorMessage = response.message; |
| return errorMessage != null |
| ? _adapter.extractUnhandledExceptionMessage(errorMessage) |
| : response.kind ?? '<unknown error>'; |
| } else if (response is vm.Sentinel) { |
| return '<sentinel>'; |
| } else { |
| return '<unknown: ${response.type}>'; |
| } |
| } |
| |
| Future<dap.Variable> convertFieldRefToVariable( |
| ThreadInfo thread, |
| vm.FieldRef fieldRef, { |
| required bool allowCallingToString, |
| required VariableFormat? format, |
| }) async { |
| final field = await thread.getObject(fieldRef); |
| if (field is vm.Field) { |
| return convertVmResponseToVariable( |
| thread, |
| field.staticValue, |
| name: fieldRef.name, |
| allowCallingToString: allowCallingToString, |
| evaluateName: fieldRef.name, |
| format: format, |
| ); |
| } else { |
| return Variable( |
| name: fieldRef.name ?? '<unnamed field>', |
| value: '<unavailable>', |
| variablesReference: 0, |
| ); |
| } |
| } |
| |
| /// Converts a [vm.Response] into to a [dap.Variable]. |
| /// |
| /// If provided, [name] is used as the variables name (for example the field |
| /// name holding this variable). |
| /// |
| /// If [allowCallingToString] is true, the toString() method may be called on |
| /// the object for a display string. |
| Future<dap.Variable> convertVmResponseToVariable( |
| ThreadInfo thread, |
| vm.Response response, { |
| required String? name, |
| required String? evaluateName, |
| required bool allowCallingToString, |
| VariableFormat? format, |
| }) async { |
| if (response is vm.InstanceRef) { |
| // 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(VariableData(response, format)); |
| |
| return dap.Variable( |
| name: name ?? response.kind.toString(), |
| evaluateName: evaluateName, |
| value: await convertVmResponseToDisplayString( |
| thread, |
| response, |
| allowCallingToString: allowCallingToString, |
| format: format, |
| ), |
| indexedVariables: _isList(response) ? response.length : null, |
| variablesReference: variablesReference, |
| ); |
| } else if (response is vm.Sentinel) { |
| return dap.Variable( |
| name: name ?? '<sentinel>', |
| value: response.valueAsString.toString(), |
| variablesReference: 0, |
| ); |
| } else if (response is vm.ErrorRef) { |
| final errorMessage = _adapter |
| .extractUnhandledExceptionMessage(response.message ?? '<error>'); |
| return dap.Variable( |
| name: name ?? '<error>', |
| value: '<$errorMessage>', |
| variablesReference: 0, |
| ); |
| } else { |
| return dap.Variable( |
| name: name ?? '<error>', |
| value: response.runtimeType.toString(), |
| variablesReference: 0, |
| ); |
| } |
| } |
| |
| /// Returns whether [ref] is a List kind. |
| /// |
| /// This includes standard Dart [List], as well as lists from |
| /// `dart:typed_data` such as `Uint8List`. |
| bool _isList(vm.InstanceRef ref) => ref.kind?.endsWith('List') ?? false; |
| |
| /// Returns whether [ref] is a Map kind. |
| bool _isMap(vm.InstanceRef ref) => ref.kind == 'Map'; |
| |
| /// Converts a VM Service stack frame to a DAP stack frame. |
| Future<dap.StackFrame> convertVmToDapStackFrame( |
| ThreadInfo thread, |
| vm.Frame frame, { |
| required bool isTopFrame, |
| int? firstAsyncMarkerIndex, |
| }) async { |
| final frameId = thread.storeData(frame); |
| |
| if (frame.kind == vm.FrameKind.kAsyncSuspensionMarker) { |
| return dap.StackFrame( |
| id: frameId, |
| name: '<asynchronous gap>', |
| presentationHint: 'label', |
| line: 0, |
| column: 0, |
| ); |
| } |
| |
| // The VM may supply frames with a prefix that we don't want to include in |
| // the frame for the user. |
| const unoptimizedPrefix = '[Unoptimized] '; |
| final codeName = frame.code?.name; |
| final frameName = codeName != null |
| ? (codeName.startsWith(unoptimizedPrefix) |
| ? codeName.substring(unoptimizedPrefix.length) |
| : codeName) |
| : '<unknown>'; |
| |
| // If there's no location, this isn't source a user can debug so use a |
| // subtle hint (which the editor may use to render the frame faded). |
| final location = frame.location; |
| if (location == null) { |
| return dap.StackFrame( |
| id: frameId, |
| name: frameName, |
| presentationHint: 'subtle', |
| line: 0, |
| column: 0, |
| ); |
| } |
| |
| final scriptRef = location.script; |
| final tokenPos = location.tokenPos; |
| final scriptRefUri = scriptRef?.uri; |
| final uri = scriptRefUri != null ? Uri.parse(scriptRefUri) : null; |
| final uriIsDart = uri?.isScheme('dart') ?? false; |
| final uriIsPackage = uri?.isScheme('package') ?? false; |
| final sourcePath = uri != null ? await thread.resolveUriToPath(uri) : null; |
| var canShowSource = sourcePath != null && File(sourcePath).existsSync(); |
| |
| // Download the source if from a "dart:" uri. |
| int? sourceReference; |
| if (!canShowSource && |
| uri != null && |
| (uri.isScheme('dart') || uri.isScheme('org-dartlang-app')) && |
| scriptRef != null) { |
| sourceReference = thread.storeData(scriptRef); |
| canShowSource = true; |
| } |
| |
| // First try to use line/col from location to avoid fetching scripts. |
| // LSP doesn't support nullable lines so we use 0 as where we can't map. |
| var line = location.line ?? 0; |
| var col = location.column ?? 0; |
| if (line == 0 || col == 0) { |
| if (scriptRef != null && tokenPos != null) { |
| try { |
| final script = await thread.getScript(scriptRef); |
| line = script.getLineNumberFromTokenPos(tokenPos) ?? 0; |
| col = script.getColumnNumberFromTokenPos(tokenPos) ?? 0; |
| } catch (e) { |
| _adapter.logger?.call('Failed to map frame location to line/col: $e'); |
| } |
| } |
| } |
| |
| // If a source would be considered not-debuggable (for example it's in the |
| // SDK and debugSdkLibraries=false) then we should also mark it as |
| // deemphasized so that the editor can jump up the stack to the first frame |
| // of debuggable code. |
| final isDebuggable = |
| uri != null && await _adapter.libraryIsDebuggable(thread, uri); |
| final presentationHint = isDebuggable ? null : 'deemphasize'; |
| final origin = uri != null && _adapter.isSdkLibrary(uri) |
| ? 'from the SDK' |
| : uri != null && await _adapter.isExternalPackageLibrary(thread, uri) |
| ? 'from external packages' |
| : null; |
| |
| final source = canShowSource |
| ? dap.Source( |
| name: uriIsPackage || uriIsDart |
| ? uri!.toString() |
| : sourcePath != null |
| ? convertToRelativePath(sourcePath) |
| : uri?.toString() ?? '<unknown source>', |
| path: sourcePath, |
| sourceReference: sourceReference, |
| origin: origin, |
| adapterData: location.script, |
| presentationHint: presentationHint, |
| ) |
| : null; |
| |
| // The VM only allows us to restart from frames that are not the top frame, |
| // but since we're also showing asyncCausalFrames any indexes past the first |
| // async boundary will not line up so we cap it there. |
| final canRestart = !isTopFrame && |
| (firstAsyncMarkerIndex == null || frame.index! < firstAsyncMarkerIndex); |
| |
| return dap.StackFrame( |
| id: frameId, |
| name: frameName, |
| source: source, |
| line: line, |
| column: col, |
| canRestart: canRestart, |
| ); |
| } |
| |
| /// 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. |
| /// |
| /// Strings are usually wrapped in quotes to indicate their type. This can be |
| /// controlled with [includeQuotesAroundString] (for example to suppress them |
| /// if the context indicates the user is copying the value to the clipboard). |
| Future<String?> _callToString( |
| ThreadInfo thread, |
| vm.InstanceRef ref, { |
| VariableFormat? format, |
| }) async { |
| final service = _adapter.vmService; |
| if (service == null) { |
| return null; |
| } |
| var result = await service.invoke( |
| thread.isolate.id!, |
| ref.id!, |
| 'toString', |
| [], |
| disableBreakpoints: true, |
| ); |
| |
| // If the response is a string and is truncated, use getObject() to get the |
| // full value. |
| if (result is vm.InstanceRef && |
| result.kind == 'String' && |
| (result.valueAsStringIsTruncated ?? false)) { |
| result = await service.getObject(thread.isolate.id!, result.id!); |
| } |
| |
| return convertVmResponseToDisplayString( |
| thread, |
| result, |
| allowCallingToString: false, // Don't allow recursing. |
| format: format, |
| ); |
| } |
| |
| /// Collect a list of all getter names for [classRef] and its super classes. |
| /// |
| /// This is used to show/evaluate getters in debug views like hovers and |
| /// variables/watch panes. |
| Future<Set<String>> _getterNamesForClassHierarchy( |
| ThreadInfo thread, |
| vm.ClassRef? classRef, |
| ) async { |
| final getterNames = <String>{}; |
| final service = _adapter.vmService; |
| while (service != null && classRef != null) { |
| final classResponse = |
| await service.getObject(thread.isolate.id!, classRef.id!); |
| if (classResponse is! vm.Class) { |
| break; |
| } |
| final functions = classResponse.functions; |
| if (functions != null) { |
| final instanceFields = functions.where((f) => |
| // TODO(dantup): Update this to use something better that bkonyi is |
| // adding to the protocol. |
| f.json?['_kind'] == 'GetterFunction' && |
| !(f.isStatic ?? false) && |
| !(f.isConst ?? false)); |
| getterNames.addAll(instanceFields |
| .map((f) => f.name!) |
| .where((name) => !name.startsWith('_'))); |
| } |
| |
| classRef = classResponse.superClass; |
| } |
| |
| return getterNames; |
| } |
| } |