| // Copyright (c) 2022, 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:io'; |
| import 'dart:async'; |
| |
| import 'package:args/args.dart'; |
| |
| import 'package:heap_snapshot/analysis.dart'; |
| import 'package:heap_snapshot/format.dart'; |
| import 'package:mmap/mmap.dart'; |
| import 'package:vm_service/vm_service.dart'; |
| |
| import 'completion.dart'; |
| import 'expression.dart'; |
| import 'load.dart'; |
| export 'expression.dart' show Output; |
| |
| abstract class Command { |
| String get name; |
| String get description; |
| String get usage; |
| List<String> get nameAliases => const []; |
| |
| final ArgParser argParser = ArgParser(); |
| |
| Future execute(CliState state, List<String> allArgs) async { |
| try { |
| int startOfRest = 0; |
| while (startOfRest < allArgs.length && |
| allArgs[startOfRest].startsWith('-')) { |
| startOfRest++; |
| } |
| |
| final options = argParser.parse(allArgs.take(startOfRest).toList()); |
| final args = allArgs.skip(startOfRest).toList(); |
| await executeInternal(state, options, args); |
| } catch (e, s) { |
| state.output.print('An error occurred: $e\n$s'); |
| printUsage(state); |
| } |
| } |
| |
| Future executeInternal(CliState state, ArgResults options, List<String> args); |
| |
| void printUsage(CliState state) { |
| if (nameAliases.isEmpty) { |
| state.output.print('Usage for $name:'); |
| } else { |
| state.output |
| .print('Usage for $name (aliases: ${nameAliases.join(' ')}):'); |
| } |
| state.output.print(' $usage'); |
| } |
| |
| String? completeCommand(CliState state, String text) { |
| return null; |
| } |
| |
| String? _completeExpression(CliState state, text) { |
| if (!state.isInitialized) return null; |
| |
| final output = CompletionCollector(); |
| parseExpression(text, output, state.namedSets.names.toSet()); |
| return output.suggestedCompletion; |
| } |
| |
| String? _completeOptions(String text) { |
| final pc = PostfixCompleter(text); |
| |
| final lastWord = getLastWord(text); |
| if (lastWord.isEmpty || !lastWord.startsWith('-')) return null; |
| |
| if (!lastWord.startsWith('--')) { |
| // For only one `-` we prefer to complete with abbreviated options. |
| final options = argParser.options.values |
| .where((o) => o.abbr != null) |
| .map((o) => '-' + o.abbr!) |
| .toList(); |
| return pc.tryComplete(lastWord, options); |
| } |
| final options = argParser.options.values |
| .expand((o) => [o.name, ...o.aliases]) |
| .map((o) => '--$o') |
| .toList(); |
| return pc.tryComplete(lastWord, options); |
| } |
| } |
| |
| abstract class SnapshotCommand extends Command { |
| SnapshotCommand(); |
| |
| Future executeInternal( |
| CliState state, ArgResults options, List<String> args) async { |
| if (!state.isInitialized) { |
| state.output.print('No `*.heapsnapshot` loaded. See `help load`.'); |
| return; |
| } |
| await executeSnapshotCommand(state, options, args); |
| } |
| |
| Future executeSnapshotCommand( |
| CliState state, ArgResults options, List<String> args); |
| } |
| |
| class LoadCommand extends Command { |
| final name = 'load'; |
| final description = 'Loads a *.heapsnapshot produced by the Dart VM.'; |
| final usage = 'load <file.heapsnapshot>'; |
| |
| LoadCommand(); |
| |
| Future executeInternal( |
| CliState state, ArgResults options, List<String> args) async { |
| if (args.length != 1) { |
| printUsage(state); |
| return; |
| } |
| final url = args.single; |
| if (url.startsWith('http') || url.startsWith('ws')) { |
| try { |
| final chunks = await loadFromUri(Uri.parse(url)); |
| state.initialize(Analysis(HeapSnapshotGraph.fromChunks(chunks))); |
| state.output.print('Loaded heapsnapshot from "$url".'); |
| } catch (e) { |
| state.output.print('Could not load heapsnapshot from "$url".'); |
| } |
| return; |
| } |
| |
| final filename = url.startsWith('~/') |
| ? (Platform.environment['HOME']! + url.substring(1)) |
| : url; |
| if (!File(filename).existsSync()) { |
| state.output.print('File "$filename" doesn\'t exist.'); |
| return; |
| } |
| try { |
| final bytes = mmapOrReadFileSync(filename); |
| state.initialize( |
| Analysis(HeapSnapshotGraph.fromChunks([bytes.buffer.asByteData()]))); |
| state.output.print('Loaded heapsnapshot from "$filename".'); |
| } catch (e) { |
| state.output.print('Could not load heapsnapshot from "$filename".'); |
| return; |
| } |
| } |
| |
| String? completeCommand(CliState state, String text) { |
| return tryCompleteFileSystemEntity( |
| text, (filename) => filename.endsWith('.heapsnapshot')); |
| } |
| } |
| |
| class StatsCommand extends SnapshotCommand { |
| final name = 'stats'; |
| final description = 'Calculates statistics about a set of objects.'; |
| final usage = 'stats [-n/--max=NUM] [-c/--sort-by-count] <expr> '; |
| final nameAliases = ['stat']; |
| |
| StatsCommand() { |
| argParser.addFlag('sort-by-count', |
| abbr: 'c', |
| help: 'Sorts by count (instead of size).', |
| defaultsTo: false); |
| argParser.addOption('max', |
| abbr: 'n', |
| help: 'Limits the number of lines to be printed.', |
| defaultsTo: '20'); |
| } |
| |
| Future executeSnapshotCommand( |
| CliState state, ArgResults options, List<String> args) async { |
| final oids = parseAndEvaluate( |
| state.namedSets, state.analysis, args.join(' '), state.output); |
| if (oids == null) return; |
| |
| final sortByCount = options['sort-by-count'] as bool; |
| final lines = int.parse(options['max']!); |
| |
| final stats = |
| state.analysis.generateObjectStats(oids, sortBySize: !sortByCount); |
| state.output.print(formatHeapStats(stats, maxLines: lines)); |
| } |
| |
| String? completeCommand(CliState state, String text) { |
| return _completeOptions(text) ?? _completeExpression(state, text); |
| } |
| } |
| |
| class DataStatsCommand extends SnapshotCommand { |
| final name = 'dstats'; |
| final description = |
| 'Calculates statistics about the data portion of objects.'; |
| final usage = 'dstats [-n/--max=NUM] [-c/--sort-by-count] <expr> '; |
| final nameAliases = ['dstat']; |
| |
| DataStatsCommand() { |
| argParser.addFlag('sort-by-count', |
| abbr: 'c', help: 'Sort by count', defaultsTo: false); |
| argParser.addOption('max', |
| abbr: 'n', |
| help: 'Limits the number of max to be printed.', |
| defaultsTo: '20'); |
| } |
| |
| Future executeSnapshotCommand( |
| CliState state, ArgResults options, List<String> args) async { |
| final oids = parseAndEvaluate( |
| state.namedSets, state.analysis, args.join(' '), state.output); |
| if (oids == null) return; |
| |
| final sortByCount = options['sort-by-count'] as bool; |
| final lines = int.parse(options['max']!); |
| |
| final stats = |
| state.analysis.generateDataStats(oids, sortBySize: !sortByCount); |
| state.output.print(formatDataStats(stats, maxLines: lines)); |
| } |
| |
| String? completeCommand(CliState state, String text) { |
| return _completeOptions(text) ?? _completeExpression(state, text); |
| } |
| } |
| |
| class InfoCommand extends SnapshotCommand { |
| final name = 'info'; |
| final description = 'Prints the known named sets.'; |
| final usage = 'info'; |
| |
| InfoCommand(); |
| |
| Future executeSnapshotCommand( |
| CliState state, ArgResults options, List<String> args) async { |
| if (args.length != 0) { |
| printUsage(state); |
| return; |
| } |
| |
| state.output.print('Known named sets:'); |
| final table = Table(); |
| state.namedSets.forEach((String name, IntSet oids) { |
| table.addRow([name, '{#${oids.length}}']); |
| }); |
| state.output.print(indent(' ', table.asString)); |
| } |
| } |
| |
| class ClearCommand extends SnapshotCommand { |
| final name = 'clear'; |
| final description = 'Clears a specific named set (or all).'; |
| final usage = 'clear <name>*'; |
| |
| ClearCommand(); |
| |
| Future executeSnapshotCommand( |
| CliState state, ArgResults options, List<String> args) async { |
| if (args.isEmpty) { |
| state.namedSets.clearWhere((key) => key != 'roots'); |
| return; |
| } |
| |
| for (final arg in args) { |
| state.namedSets.clear(arg); |
| return; |
| } |
| } |
| } |
| |
| class RetainingPathCommand extends SnapshotCommand { |
| final name = 'retainers'; |
| final description = 'Prints information about retaining paths.'; |
| final usage = 'retainers [-d/--depth=<num>] [-n/--max=NUM] <expr>'; |
| final nameAliases = ['retain']; |
| |
| RetainingPathCommand() { |
| argParser.addOption('depth', |
| abbr: 'd', help: 'Maximum depth of retaining paths.', defaultsTo: '10'); |
| argParser.addOption('max', |
| abbr: 'n', |
| help: 'Limits the number of entries printed.', |
| defaultsTo: '3'); |
| } |
| |
| Future executeSnapshotCommand( |
| CliState state, ArgResults options, List<String> args) async { |
| final oids = parseAndEvaluate( |
| state.namedSets, state.analysis, args.join(' '), state.output); |
| if (oids == null) return; |
| |
| final depth = int.parse(options['depth']!); |
| final maxEntries = int.parse(options['max']!); |
| |
| final paths = state.analysis.retainingPathsOf(oids, depth); |
| for (int i = 0; i < paths.length; ++i) { |
| if (maxEntries != -1 && i >= maxEntries) break; |
| final path = paths[i]; |
| state.output.print('There are ${path.count} retaining paths of'); |
| state.output.print(formatRetainingPath(state.analysis.graph, paths[i])); |
| state.output.print(''); |
| } |
| } |
| |
| String? completeCommand(CliState state, String text) { |
| return _completeOptions(text) ?? _completeExpression(state, text); |
| } |
| } |
| |
| class ExamineCommand extends SnapshotCommand { |
| final name = 'examine'; |
| final description = 'Examins a set of objects.'; |
| final usage = 'examine [-n/--max=NUM] <expr>?'; |
| final nameAliases = ['x']; |
| |
| ExamineCommand() { |
| argParser.addOption('max', |
| abbr: 'n', |
| help: 'Limits the number of entries to be examined..', |
| defaultsTo: '5'); |
| } |
| |
| Future executeSnapshotCommand( |
| CliState state, ArgResults options, List<String> args) async { |
| final limit = int.parse(options['max']!); |
| |
| final oids = parseAndEvaluate( |
| state.namedSets, state.analysis, args.join(' '), state.output); |
| if (oids == null) return; |
| if (oids.isEmpty) return; |
| |
| final it = oids.iterator; |
| int i = 0; |
| while (it.moveNext()) { |
| final oid = it.current; |
| final info = state.analysis.examine(oid); |
| state.output.print('${info.className}@$oid (${info.libraryUri}) {'); |
| final table = Table(); |
| info.fieldValues.forEach((name, value) { |
| table.addRow([name, value]); |
| }); |
| state.output.print(indent(' ', table.asString)); |
| state.output.print('}'); |
| if (++i >= limit) break; |
| } |
| } |
| |
| String? completeCommand(CliState state, String text) { |
| return _completeOptions(text) ?? _completeExpression(state, text); |
| } |
| } |
| |
| class EvaluateCommand extends SnapshotCommand { |
| final name = 'eval'; |
| final description = 'Evaluates a set expression.'; |
| final usage = 'eval <expr>\n\n$dslDescription'; |
| |
| EvaluateCommand(); |
| |
| Future executeSnapshotCommand( |
| CliState state, ArgResults options, List<String> args) async { |
| final sexpr = parseExpression( |
| args.join(' '), state.output, state.namedSets.names.toSet()); |
| if (sexpr == null) return null; |
| |
| final oids = sexpr.evaluate(state.namedSets, state.analysis, state.output); |
| if (oids == null) return null; |
| |
| late final String name; |
| if (sexpr is SetNameExpression) { |
| name = sexpr.name; |
| } else { |
| name = state.namedSets.nameSet(oids); |
| } |
| state.output.print(' $name {#${oids.length}}'); |
| } |
| |
| String? completeCommand(CliState state, String text) { |
| return _completeExpression(state, text); |
| } |
| } |
| |
| class DescFilterCommand extends SnapshotCommand { |
| final name = 'describe-filter'; |
| final description = 'Describes what a filter expression will match.'; |
| final usage = 'describe-filter $dslFilter'; |
| final nameAliases = ['desc-filter', 'desc']; |
| |
| DescFilterCommand(); |
| |
| Future executeSnapshotCommand( |
| CliState state, ArgResults options, List<String> args) async { |
| final tfilter = state.analysis.parseTraverseFilter(args); |
| if (tfilter == null) return null; |
| |
| state.output.print(tfilter.asString(state.analysis.graph)); |
| } |
| } |
| |
| class HelpCommand extends Command { |
| final name = 'help'; |
| final description = 'Shows general help or help for a specific command.'; |
| final usage = 'help <command>?'; |
| final nameAliases = ['h']; |
| |
| HelpCommand(); |
| |
| Future executeInternal( |
| CliState state, ArgResults options, List<String> args) async { |
| if (args.length >= 1) { |
| final helpCommandName = args[0]; |
| final helpCommand = cliCommandRunner.name2command[helpCommandName]; |
| if (helpCommand != null) { |
| helpCommand.printUsage(state); |
| return; |
| } |
| } |
| final table = Table(); |
| cliCommandRunner.name2command.forEach((name, command) { |
| if (name == command.name) { |
| table.addRow([command.name, command.description]); |
| } |
| }); |
| state.output.print('Available commands:'); |
| state.output.print(indent(' ', table.asString)); |
| } |
| |
| String? completeCommand(CliState state, String text) { |
| if (text.indexOf(' ') == -1) { |
| final pc = PostfixCompleter(text); |
| final possibleCommands = cliCommandRunner.name2command.keys.toList(); |
| return pc.tryComplete(text, possibleCommands); |
| } |
| return null; |
| } |
| } |
| |
| class ExitCommand extends Command { |
| final name = 'exit'; |
| final description = 'Exits the program.'; |
| final usage = 'exit'; |
| final nameAliases = ['quit', 'q']; |
| |
| ExitCommand(); |
| |
| Future executeInternal( |
| CliState state, ArgResults options, List<String> args) { |
| throw 'unreachable'; |
| } |
| } |
| |
| class CommandRunner { |
| final Command defaultCommand; |
| final Map<String, Command> name2command = {}; |
| |
| CommandRunner(List<Command> commands, this.defaultCommand) { |
| for (final command in commands) { |
| name2command[command.name] = command; |
| for (final alias in command.nameAliases) { |
| name2command[alias] = command; |
| } |
| } |
| } |
| |
| /// Returns `true` if the CLI tool should exit and `false` otherwise. |
| Future<bool> run(CliState state, List<String> args) async { |
| if (args.isEmpty) return false; |
| |
| final commandName = args.first; |
| final command = name2command[commandName]; |
| if (command != null) { |
| if (command is ExitCommand) { |
| return true; |
| } |
| await command.execute(state, args.skip(1).toList()); |
| return false; |
| } |
| await defaultCommand.execute(state, args); |
| return false; |
| } |
| |
| String? completeCommand(CliState state, String text) { |
| // We only complete commands, no arguments (yet). |
| if (text.isEmpty) return null; |
| |
| final left = getFirstWordWithSpaces(text); |
| if (left.endsWith(' ')) { |
| final command = name2command[left.trim()]; |
| if (command != null) { |
| final right = text.substring(left.length); |
| final result = command.completeCommand(state, right); |
| return (result != null) ? (left + result) : null; |
| } |
| } else { |
| final pc = PostfixCompleter(text); |
| final possibleCommands = name2command.keys |
| .where((name) => |
| state.isInitialized || name2command[name] is! SnapshotCommand) |
| .toList(); |
| final completion = pc.tryComplete(text, possibleCommands); |
| if (completion != null) return completion; |
| } |
| |
| return defaultCommand.completeCommand(state, text); |
| } |
| } |
| |
| class CliState { |
| NamedSets? _namedSets; |
| Analysis? _analysis; |
| Output output; |
| |
| CliState(this.output); |
| |
| void initialize(Analysis analysis) { |
| _analysis = analysis; |
| |
| _namedSets = NamedSets(); |
| _namedSets!.nameSet(analysis.roots, 'roots'); |
| } |
| |
| bool get isInitialized => _analysis != null; |
| |
| Analysis get analysis => _analysis!; |
| NamedSets get namedSets => _namedSets!; |
| } |
| |
| final cliCommandRunner = CommandRunner([ |
| LoadCommand(), |
| StatsCommand(), |
| DataStatsCommand(), |
| InfoCommand(), |
| ClearCommand(), |
| RetainingPathCommand(), |
| EvaluateCommand(), |
| ExamineCommand(), |
| DescFilterCommand(), |
| HelpCommand(), |
| ExitCommand(), |
| ], EvaluateCommand()); |
| |
| class CompletionCollector extends Output { |
| String? suggestedCompletion; |
| |
| void suggestCompletion(String text) { |
| suggestedCompletion = text; |
| } |
| } |