blob: 7bba7831a2f1880c63dc87e6802af929f431a56a [file] [log] [blame]
// 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;
}
}