| // Copyright (c) 2020, 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:dwds/src/config/tool_configuration.dart'; |
| import 'package:dwds/src/debugging/dart_scope.dart'; |
| import 'package:dwds/src/debugging/debugger.dart'; |
| import 'package:dwds/src/debugging/location.dart'; |
| import 'package:dwds/src/debugging/modules.dart'; |
| import 'package:dwds/src/services/expression_compiler.dart'; |
| import 'package:dwds/src/services/javascript_builder.dart'; |
| import 'package:dwds/src/utilities/conversions.dart'; |
| import 'package:dwds/src/utilities/domain.dart'; |
| import 'package:dwds/src/utilities/objects.dart' as chrome; |
| import 'package:logging/logging.dart'; |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; |
| |
| class EvaluationErrorKind { |
| EvaluationErrorKind._(); |
| |
| static const compilation = 'CompilationError'; |
| static const type = 'TypeError'; |
| static const reference = 'ReferenceError'; |
| static const internal = 'InternalError'; |
| static const asyncFrame = 'AsyncFrameError'; |
| static const invalidInput = 'InvalidInputError'; |
| static const loadModule = 'LoadModuleError'; |
| } |
| |
| /// ExpressionEvaluator provides functionality to evaluate dart expressions |
| /// from text user input in the debugger, using chrome remote debugger to |
| /// collect context for evaluation (scope, types, modules), and using |
| /// ExpressionCompilerInterface to compile dart expressions to JavaScript. |
| class ExpressionEvaluator { |
| final String _entrypoint; |
| final AppInspectorInterface _inspector; |
| final Debugger _debugger; |
| final Locations _locations; |
| final Modules _modules; |
| final ExpressionCompiler _compiler; |
| final _logger = Logger('ExpressionEvaluator'); |
| bool _closed = false; |
| |
| /// Strip synthetic library name from compiler error messages. |
| static final _syntheticNameFilterRegex = RegExp( |
| 'org-dartlang-debug:synthetic_debug_expression:.*:.*Error: ', |
| ); |
| |
| /// Find module path from the XHR call network error message received from chrome. |
| /// |
| /// Example: |
| /// NetworkError: Failed to load `http://<hostname>.com/path/to/module.js?<cache_busting_token>` |
| static final _loadModuleErrorRegex = RegExp( |
| r".*Failed to load '.*\.com/(.*\.js).*", |
| ); |
| |
| ExpressionEvaluator( |
| this._entrypoint, |
| this._inspector, |
| this._debugger, |
| this._locations, |
| this._modules, |
| this._compiler, |
| ); |
| |
| /// Create and error with [severity] and [message] |
| /// |
| /// [severity] is one of kinds in [EvaluationErrorKind] |
| RemoteObject createError(String severity, String message) { |
| return RemoteObject(<String, String>{'type': severity, 'value': message}); |
| } |
| |
| void close() { |
| _closed = true; |
| } |
| |
| /// Evaluate dart expression inside a given library. |
| /// |
| /// Uses ExpressionCompiler interface to compile the expression to |
| /// JavaScript and sends evaluate requests to chrome to calculate |
| /// the final result. |
| /// |
| /// Returns remote object containing the result of evaluation or error. |
| /// |
| /// [isolateId] current isolate ID. |
| /// [libraryUri] dart library to evaluate the expression in. |
| /// [expression] dart expression to evaluate. |
| Future<RemoteObject> evaluateExpression( |
| String isolateId, |
| String? libraryUri, |
| String expression, |
| Map<String, String>? scope, |
| ) async { |
| if (_closed) { |
| return createError( |
| EvaluationErrorKind.internal, |
| 'expression evaluator closed.', |
| ); |
| } |
| |
| scope ??= {}; |
| |
| if (expression.isEmpty) { |
| return createError(EvaluationErrorKind.invalidInput, expression); |
| } |
| |
| if (libraryUri == null) { |
| return createError(EvaluationErrorKind.invalidInput, 'no library uri'); |
| } |
| |
| final module = await _modules.moduleForLibrary(libraryUri); |
| if (module == null) { |
| return createError( |
| EvaluationErrorKind.internal, |
| 'no module for $libraryUri', |
| ); |
| } |
| |
| // Wrap the expression in a lambda so we can call it as a function. |
| expression = _createDartLambda(expression, scope.keys); |
| _logger.finest('Evaluating "$expression" at $module'); |
| |
| // Compile expression using an expression compiler, such as |
| // frontend server or expression compiler worker. |
| final compilationResult = await _compiler.compileExpressionToJs( |
| isolateId, |
| libraryUri.toString(), |
| 0, |
| 0, |
| {}, |
| {}, |
| module, |
| expression, |
| ); |
| |
| final isError = compilationResult.isError; |
| final jsResult = compilationResult.result; |
| if (isError) { |
| return _formatCompilationError(jsResult); |
| } |
| |
| // Strip try/catch incorrectly added by the expression compiler. |
| final jsCode = _maybeStripTryCatch(jsResult); |
| |
| // Send JS expression to chrome to evaluate. |
| var result = await _callJsFunction(jsCode, scope); |
| result = await _formatEvaluationError(result); |
| |
| _logger.finest('Evaluated "$expression" to "${result.json}"'); |
| return result; |
| } |
| |
| /// Evaluate dart expression inside a given frame (function). |
| /// |
| /// Gets necessary context (types, scope, module names) data from chrome, |
| /// uses ExpressionCompiler interface to compile the expression to |
| /// JavaScript, and sends evaluate requests to chrome to calculate the |
| /// final result. |
| /// |
| /// Returns remote object containing the result of evaluation or error. |
| /// |
| /// [isolateId] current isolate ID. |
| /// [frameIndex] JavaScript frame to evaluate the expression in. |
| /// [expression] dart expression to evaluate. |
| /// [scope] additional scope to use in the expression as a map from |
| /// variable names to remote object IDs. |
| /// |
| ///////////////////////////////// |
| /// **Example - without scope** |
| /// |
| /// To evaluate a dart expression `e`, we perform the following: |
| /// |
| /// 1. compile dart expression `e` to JavaScript expression `jsExpr` |
| /// using the expression compiler (i.e. frontend server or expression |
| /// compiler worker). |
| /// |
| /// 2. create JavaScript wrapper expression, `jsWrapperExpr`, defined as |
| /// |
| /// ```JavaScript |
| /// try { |
| /// jsExpr; |
| /// } catch (error) { |
| /// error.name + ": " + error.message; |
| /// } |
| /// ``` |
| /// |
| /// 3. evaluate `JsExpr` using `Debugger.evaluateOnCallFrame` chrome API. |
| /// |
| /// ////////////////////////// |
| /// **Example - with scope** |
| /// |
| /// To evaluate a dart expression |
| /// ```dart |
| /// this.t + a + x + y |
| /// ``` |
| /// in a dart scope that defines `a` and `this`, and additional scope |
| /// `x, y`, we perform the following: |
| /// |
| /// 1. compile dart function |
| /// |
| /// ```dart |
| /// (x, y, a) { return this.t + a + x + y; } |
| /// ``` |
| /// |
| /// to JavaScript function |
| /// |
| /// ```jsFunc``` |
| /// |
| /// using the expression compiler (i.e. frontend server or expression |
| /// compiler worker). |
| /// |
| /// 2. create JavaScript wrapper function, `jsWrapperFunc`, defined as |
| /// |
| /// ```JavaScript |
| /// function (x, y, a, __t$this) { |
| /// try { |
| /// return function (x, y, a) { |
| /// return jsFunc(x, y, a); |
| /// }.bind(__t$this)(x, y, a); |
| /// } catch (error) { |
| /// return error.name + ": " + error.message; |
| /// } |
| /// } |
| /// ``` |
| /// |
| /// 3. collect scope variable object IDs for total scope |
| /// (original frame scope from WipCallFrame + additional scope passed |
| /// by the user). |
| /// |
| /// 4. call `jsWrapperFunc` using `Runtime.callFunctionOn` chrome API |
| /// with scope variable object IDs passed as arguments. |
| Future<RemoteObject> evaluateExpressionInFrame( |
| String isolateId, |
| int frameIndex, |
| String expression, |
| Map<String, String>? scope, |
| ) async { |
| scope ??= {}; |
| |
| if (expression.isEmpty) { |
| return createError(EvaluationErrorKind.invalidInput, expression); |
| } |
| |
| // Get JS scope and current JS location. |
| final jsFrame = _debugger.jsFrameForIndex(frameIndex); |
| if (jsFrame == null) { |
| return createError( |
| EvaluationErrorKind.asyncFrame, |
| 'Expression evaluation in async frames ' |
| 'is not supported. No frame with index $frameIndex.', |
| ); |
| } |
| |
| final functionName = jsFrame.functionName; |
| final jsLine = jsFrame.location.lineNumber; |
| final jsScriptId = jsFrame.location.scriptId; |
| final jsColumn = jsFrame.location.columnNumber; |
| final frameScope = await _collectLocalFrameScope(jsFrame); |
| |
| // Find corresponding dart location and scope. |
| final url = _debugger.urlForScriptId(jsScriptId); |
| if (url == null) { |
| return createError( |
| EvaluationErrorKind.internal, |
| 'Cannot find url for JS script: $jsScriptId', |
| ); |
| } |
| final locationMap = await _locations.locationForJs(url, jsLine, jsColumn); |
| if (locationMap == null) { |
| return createError( |
| EvaluationErrorKind.internal, |
| 'Cannot find Dart location for JS location: ' |
| 'url: $url, ' |
| 'function: $functionName, ' |
| 'line: $jsLine, ' |
| 'column: $jsColumn', |
| ); |
| } |
| |
| final dartLocation = locationMap.dartLocation; |
| final dartSourcePath = dartLocation.uri.serverPath; |
| final libraryUri = await _modules.libraryForSource(dartSourcePath); |
| if (libraryUri == null) { |
| return createError( |
| EvaluationErrorKind.internal, |
| 'no libraryUri for $dartSourcePath', |
| ); |
| } |
| |
| final module = await _modules.moduleForLibrary(libraryUri.toString()); |
| if (module == null) { |
| return createError( |
| EvaluationErrorKind.internal, |
| 'no module for $libraryUri ($dartSourcePath)', |
| ); |
| } |
| |
| _logger.finest( |
| 'Evaluating "$expression" at $module, ' |
| '$libraryUri:${dartLocation.line}:${dartLocation.column} ' |
| 'with scope: $scope', |
| ); |
| |
| if (scope.isNotEmpty) { |
| final totalScope = Map<String, String>.from(scope)..addAll(frameScope); |
| expression = _createDartLambda(expression, totalScope.keys); |
| } |
| |
| _logger.finest('Compiling "$expression"'); |
| |
| // Compile expression using an expression compiler, such as |
| // frontend server or expression compiler worker. |
| // |
| // TODO(annagrin): map JS locals to dart locals in the expression |
| // and JS scope before passing them to the dart expression compiler. |
| // Issue: https://github.com/dart-lang/sdk/issues/40273 |
| final compilationResult = await _compiler.compileExpressionToJs( |
| isolateId, |
| libraryUri.toString(), |
| dartLocation.line, |
| dartLocation.column, |
| {}, |
| frameScope.map((key, value) => MapEntry(key, key)), |
| module, |
| expression, |
| ); |
| |
| final isError = compilationResult.isError; |
| final jsResult = compilationResult.result; |
| if (isError) { |
| return _formatCompilationError(jsResult); |
| } |
| |
| // Strip try/catch incorrectly added by the expression compiler. |
| final jsCode = _maybeStripTryCatch(jsResult); |
| |
| // Send JS expression to chrome to evaluate. |
| var result = |
| scope.isEmpty |
| ? await _evaluateJsExpressionInFrame(frameIndex, jsCode) |
| : await _callJsFunctionInFrame( |
| frameIndex, |
| jsCode, |
| scope, |
| frameScope, |
| ); |
| |
| result = await _formatEvaluationError(result); |
| _logger.finest('Evaluated "$expression" to "${result.json}"'); |
| return result; |
| } |
| |
| /// Call JavaScript [function] with [scope] on frame [frameIndex]. |
| /// |
| /// Wrap the [function] in a lambda that takes scope variables as parameters. |
| /// Send JS expression to chrome to evaluate in frame with [frameIndex] |
| /// with the provided [scope]. |
| /// |
| /// [frameIndex] is the index of the frame to call the function in. |
| /// [function] is the JS function to evaluate. |
| /// [scope] is the additional scope as a map from scope variables to |
| /// remote object IDs. |
| /// [frameScope] is the original scope as a map from scope variables |
| /// to remote object IDs. |
| Future<RemoteObject> _callJsFunctionInFrame( |
| int frameIndex, |
| String function, |
| Map<String, String> scope, |
| Map<String, String> frameScope, |
| ) async { |
| final totalScope = Map<String, String>.from(scope)..addAll(frameScope); |
| final thisObject = await _debugger.evaluateJsOnCallFrameIndex( |
| frameIndex, |
| 'this', |
| ); |
| |
| final thisObjectId = thisObject.objectId; |
| if (thisObjectId != null) { |
| totalScope['this'] = thisObjectId; |
| } |
| |
| return _callJsFunction(function, totalScope); |
| } |
| |
| /// Call the [function] with [scope] as arguments. |
| /// |
| /// Wrap the [function] in a lambda that takes scope variables as parameters. |
| /// Send JS expression to chrome to evaluate with the provided [scope]. |
| /// |
| /// [function] is the JS function to evaluate. |
| /// [scope] is a map from scope variables to remote object IDs. |
| Future<RemoteObject> _callJsFunction( |
| String function, |
| Map<String, String> scope, |
| ) { |
| final jsCode = _createEvalFunction(function, scope.keys); |
| |
| _logger.finest('Evaluating JS: "$jsCode" with scope: $scope'); |
| return _inspector.callFunction(jsCode, scope.values); |
| } |
| |
| /// Evaluate JavaScript [expression] on frame [frameIndex]. |
| /// |
| /// Wrap the [expression] in a try/catch expression to catch errors. |
| /// Send JS expression to chrome to evaluate on frame [frameIndex]. |
| /// |
| /// [frameIndex] is the index of the frame to call the function in. |
| /// [expression] is the JS function to evaluate. |
| Future<RemoteObject> _evaluateJsExpressionInFrame( |
| int frameIndex, |
| String expression, |
| ) { |
| final jsCode = _createEvalExpression(expression); |
| |
| _logger.finest('Evaluating JS: "$jsCode"'); |
| return _debugger.evaluateJsOnCallFrameIndex(frameIndex, jsCode); |
| } |
| |
| static String? _getObjectId(RemoteObject? object) => |
| object?.objectId ?? dartIdFor(object?.value); |
| |
| RemoteObject _formatCompilationError(String error) { |
| // Frontend currently gives a text message including library name |
| // and function name on compilation error. Strip this information |
| // since it shows synthetic names that are only used for temporary |
| // debug library during expression evaluation. |
| // |
| // TODO(annagrin): modify frontend to avoid stripping dummy names |
| // [issue 40449](https://github.com/dart-lang/sdk/issues/40449) |
| if (error.startsWith('[')) { |
| error = error.substring(1); |
| } |
| if (error.endsWith(']')) { |
| error = error.substring(0, error.lastIndexOf(']')); |
| } |
| if (error.contains('InternalError: ')) { |
| error = error.replaceAll('InternalError: ', ''); |
| return createError(EvaluationErrorKind.internal, error); |
| } |
| error = error.replaceAll(_syntheticNameFilterRegex, ''); |
| return createError(EvaluationErrorKind.compilation, error); |
| } |
| |
| Future<RemoteObject> _formatEvaluationError(RemoteObject result) async { |
| if (result.type == 'string') { |
| var error = '${result.value}'; |
| if (error.startsWith('ReferenceError: ')) { |
| error = error.replaceFirst('ReferenceError: ', ''); |
| return createError(EvaluationErrorKind.reference, error); |
| } else if (error.startsWith('TypeError: ')) { |
| error = error.replaceFirst('TypeError: ', ''); |
| return createError(EvaluationErrorKind.type, error); |
| } else if (error.startsWith('NetworkError: ')) { |
| var modulePath = _loadModuleErrorRegex.firstMatch(error)?.group(1); |
| final module = |
| modulePath != null |
| ? await globalToolConfiguration.loadStrategy |
| .moduleForServerPath(_entrypoint, modulePath) |
| : 'unknown'; |
| modulePath ??= 'unknown'; |
| error = |
| 'Module is not loaded : $module (path: $modulePath). ' |
| 'Accessing libraries that have not yet been used in the ' |
| 'application is not supported during expression evaluation.'; |
| return createError(EvaluationErrorKind.loadModule, error); |
| } |
| } |
| return result; |
| } |
| |
| /// Return local scope as a map from variable names to remote object IDs. |
| /// |
| /// [frame] is the current frame index. |
| Future<Map<String, String>> _collectLocalFrameScope( |
| WipCallFrame frame, |
| ) async { |
| final scope = <String, String>{}; |
| |
| void collectVariables(Iterable<chrome.Property> variables) { |
| for (final p in variables) { |
| final name = p.name; |
| final value = p.value; |
| // TODO: null values represent variables optimized by v8. |
| // Show that to the user. |
| if (name != null && value != null && !_isUndefined(value)) { |
| final objectId = _getObjectId(p.value); |
| if (objectId != null) { |
| scope[name] = objectId; |
| } |
| } |
| } |
| } |
| |
| // skip library and main scope |
| final scopeChain = filterScopes(frame).reversed; |
| for (final scope in scopeChain) { |
| final objectId = scope.object.objectId; |
| if (objectId != null) { |
| final scopeProperties = await _inspector.getProperties(objectId); |
| collectVariables(scopeProperties); |
| } |
| } |
| |
| return scope; |
| } |
| |
| bool _isUndefined(RemoteObject value) => value.type == 'undefined'; |
| |
| static String _createDartLambda(String expression, Iterable<String> params) => |
| '(${params.join(', ')}) { return $expression; }'; |
| |
| /// Strip try/catch incorrectly added by the expression compiler. |
| /// TODO: remove adding try/catch block in expression compiler. |
| /// https://github.com/dart-lang/webdev/issues/1341, then remove |
| /// this stripping code. |
| static String _maybeStripTryCatch(String jsCode) { |
| // Match the wrapping generated by the expression compiler exactly |
| // so the matching does not succeed naturally after the wrapping is |
| // removed: |
| // |
| // Expression compiler's wrapping: |
| // |
| // '\ntry {' |
| // '\n ($jsExpression(' |
| // '\n $args' |
| // '\n ))' |
| // '\n} catch (error) {' |
| // '\n error.name + ": " + error.message;' |
| // '\n}'; |
| // |
| final lines = jsCode.split('\n'); |
| if (lines.length > 5) { |
| final tryLines = lines.getRange(0, 2).toList(); |
| final bodyLines = lines.getRange(2, lines.length - 3); |
| final catchLines = |
| lines.getRange(lines.length - 3, lines.length).toList(); |
| if (tryLines[0].isEmpty && |
| tryLines[1] == 'try {' && |
| catchLines[0] == '} catch (error) {' && |
| catchLines[1] == ' error.name + ": " + error.message;' && |
| catchLines[2] == '}') { |
| return bodyLines.join('\n'); |
| } |
| } |
| return jsCode; |
| } |
| |
| /// Create JS expression to pass to `Debugger.evaluateOnCallFrame`. |
| static String _createEvalExpression(String expression) { |
| final body = expression.split('\n').where((e) => e.isNotEmpty); |
| |
| return JsBuilder.createEvalExpression(body); |
| } |
| |
| /// Create JS function to invoke in `Runtime.callFunctionOn`. |
| static String _createEvalFunction(String function, Iterable<String> params) { |
| final body = function.split('\n').where((e) => e.isNotEmpty); |
| |
| return params.contains('this') |
| ? JsBuilder.createEvalBoundFunction(body, params) |
| : JsBuilder.createEvalStaticFunction(body, params); |
| } |
| } |