blob: 0bedd505cafce9ecbc4070c7bb0d212cd339b500 [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:io';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'package:vm_service/vm_service.dart' as vm;
import 'adapters/dart.dart';
import 'isolate_manager.dart';
import 'protocol_generated.dart' as dap;
/// 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,
bool includeQuotesAroundString = true,
}) async {
final isTruncated = ref.valueAsStringIsTruncated ?? false;
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 &&
(ref.valueAsString == null || !allowTruncatedValue)
? await _callToString(
thread,
ref,
// Quotes are handled below, so they can be wrapped around the
// elipsis.
includeQuotesAroundString: false,
)
: null;
stringValue ??= '${ref.valueAsString}…';
return includeQuotesAroundString ? '"$stringValue"' : stringValue;
} else if (ref.kind == vm.InstanceKind.kString) {
// Untruncated strings.
return includeQuotesAroundString
? '"${ref.valueAsString}"'
: ref.valueAsString.toString();
} else if (ref.valueAsString != null) {
return isTruncated
? '${ref.valueAsString}…'
: ref.valueAsString.toString();
} else if (ref.kind == 'PlainInstance') {
var stringValue = ref.classRef?.name ?? '<unknown instance>';
if (allowCallingToString) {
final toStringValue = await _callToString(
thread,
ref,
includeQuotesAroundString: false,
);
// 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 (ref.kind == 'List') {
return 'List (${ref.length} ${ref.length == 1 ? "item" : "items"})';
} else if (ref.kind == 'Map') {
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.Instace] to a list of [dap.Variable]s, one for each
/// field/member/element/association.
///
/// If [startItem] and/or [numItems] are supplied, only a slice of the
/// items will be returned to allow the client to page.
Future<List<dap.Variable>> convertVmInstanceToVariablesList(
ThreadInfo thread,
vm.Instance instance, {
required String? evaluateName,
required bool allowCallingToString,
int? startItem = 0,
int? numItems,
}) 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,
)
];
} 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>()
.sublist(start, numItems != null ? start + numItems : null)
.mapIndexed(
(index, response) => convertVmResponseToVariable(
thread,
response,
name: '[${start + index}]',
evaluateName: _adapter.combineEvaluateName(
evaluateName, '[${start + index}]'),
allowCallingToString:
allowCallingToString && index <= maxToStringsPerEvaluation,
),
));
} 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
.sublist(start, numItems != null ? start + numItems : null)
.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);
final valueDisplay = await convertVmResponseToDisplayString(
thread, value,
allowCallingToString: callToString);
// 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(mapEntry),
);
}));
} else if (fields != null) {
// Otherwise, show the fields from the instance.
final variables = await Future.wait(fields.mapIndexed(
(index, field) async {
final name = field.decl?.name;
return convertVmResponseToVariable(thread, field.value,
name: name ?? '<unnamed field>',
evaluateName: name != null
? _adapter.combineEvaluateName(evaluateName, '.$name')
: null,
allowCallingToString:
allowCallingToString && index <= maxToStringsPerEvaluation);
},
));
// 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,
);
// Convert results to variables.
return convertVmResponseToVariable(
thread,
response,
name: getterName,
evaluateName:
_adapter.combineEvaluateName(evaluateName, '.$getterName'),
allowCallingToString:
allowCallingToString && index <= maxToStringsPerEvaluation,
);
} 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 [];
}
}
/// 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,
bool includeQuotesAroundString = true,
}) async {
if (response is vm.InstanceRef) {
return convertVmInstanceRefToDisplayString(
thread,
response,
allowCallingToString: allowCallingToString,
includeQuotesAroundString: includeQuotesAroundString,
);
} else if (response is vm.Sentinel) {
return '<sentinel>';
} else {
return '<unknown: ${response.type}>';
}
}
/// 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,
}) 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(response);
return dap.Variable(
name: name ?? response.kind.toString(),
evaluateName: evaluateName,
value: await convertVmResponseToDisplayString(
thread,
response,
allowCallingToString: allowCallingToString,
),
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,
);
}
}
/// 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;
}
var 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, {
bool includeQuotesAroundString = true,
}) 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.
includeQuotesAroundString: includeQuotesAroundString,
);
}
/// 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!));
}
classRef = classResponse.superClass;
}
return getterNames;
}
}