Allow tab-completion of commands, expression types and named sets
This CL extends the heapsnapshot analysis CLI with tab-completion support
for commands, options, filenames, expression types and named sets.
This makes it much more comfortable to use the tool.
TEST=runtime/tools/heapsnapshot/test/completion_test
Change-Id: Iea48b4bd12651a60add6206a92ce06823cbd754a
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/262243
Reviewed-by: Tess Strickland <sstrickl@google.com>
Commit-Queue: Martin Kustermann <kustermann@google.com>
diff --git a/runtime/tests/vm/dart/heapsnapshot_cli_test.dart b/runtime/tests/vm/dart/heapsnapshot_cli_test.dart
index 2012834..c2d51b9 100644
--- a/runtime/tests/vm/dart/heapsnapshot_cli_test.dart
+++ b/runtime/tests/vm/dart/heapsnapshot_cli_test.dart
@@ -4,6 +4,7 @@
import '../../../tools/heapsnapshot/test/cli_test.dart' as cli_test;
import '../../../tools/heapsnapshot/test/expression_test.dart' as expr_test;
+import '../../../tools/heapsnapshot/test/completion_test.dart' as comp_test;
import 'use_flag_test_helper.dart';
@@ -17,6 +18,7 @@
return;
}
- cli_test.main(args);
- expr_test.main(args);
+ cli_test.main();
+ expr_test.main();
+ comp_test.main();
}
diff --git a/runtime/tools/heapsnapshot/bin/explore.dart b/runtime/tools/heapsnapshot/bin/explore.dart
index 029060f..b597b45 100644
--- a/runtime/tools/heapsnapshot/bin/explore.dart
+++ b/runtime/tools/heapsnapshot/bin/explore.dart
@@ -2,8 +2,8 @@
// 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:dart_console/dart_console.dart';
import 'package:heapsnapshot/src/cli.dart';
+import 'package:heapsnapshot/src/console.dart';
class ConsoleErrorPrinter extends Output {
final Console console;
@@ -19,8 +19,39 @@
}
}
+class CompletionKeyHandler extends KeyHandler {
+ final CliState cliState;
+ CompletionKeyHandler(this.cliState);
+
+ bool handleKey(LineEditBuffer buffer, Key lastPressed) {
+ if (lastPressed.isControl &&
+ lastPressed.controlChar == ControlCharacter.tab &&
+ buffer.completionText.isNotEmpty) {
+ buffer.insert(buffer.completionText);
+ buffer.completionText = '';
+ return true;
+ }
+
+ buffer.completionText = '';
+
+ if (!lastPressed.isControl) {
+ final incomplete = buffer.text.substring(0, buffer.index);
+ final complete = cliCommandRunner.completeCommand(cliState, incomplete);
+ if (complete != null) {
+ if (!complete.startsWith(incomplete)) {
+ throw 'CompletionError: Suggestion "$complete" does not start with "$incomplete".';
+ }
+ if (complete.length > incomplete.length) {
+ buffer.completionText = complete.substring(incomplete.length);
+ }
+ }
+ }
+ return false;
+ }
+}
+
void main() async {
- final console = Console.scrolling();
+ final console = SmartConsole();
console.write('The ');
console.setForegroundColor(ConsoleColor.brightYellow);
@@ -34,21 +65,18 @@
final errors = ConsoleErrorPrinter(console);
final cliState = CliState(errors);
+ console.completionHandler = CompletionKeyHandler(cliState);
+
while (true) {
- void writePrompt() {
- console.setForegroundColor(ConsoleColor.brightBlue);
- console.write('(hsa) ');
- console.resetColorAttributes();
- console.setForegroundColor(ConsoleColor.brightGreen);
+ final response = console.smartReadLine();
+ console.resetColorAttributes();
+ if (response.shouldExit) return;
+ if (response.wasCancelled) {
+ console.write(console.newLine);
+ continue;
}
- writePrompt();
- final response = console.readLine(cancelOnEOF: true);
- console.resetColorAttributes();
- if (response == null) return;
- if (response.isEmpty) continue;
-
- final args = response
+ final args = response.text
.split(' ')
.map((p) => p.trim())
.where((p) => !p.isEmpty)
diff --git a/runtime/tools/heapsnapshot/lib/src/cli.dart b/runtime/tools/heapsnapshot/lib/src/cli.dart
index e723f36..0e9450b 100644
--- a/runtime/tools/heapsnapshot/lib/src/cli.dart
+++ b/runtime/tools/heapsnapshot/lib/src/cli.dart
@@ -10,6 +10,7 @@
import 'package:vm_service/vm_service.dart';
import 'analysis.dart';
+import 'completion.dart';
import 'expression.dart';
import 'format.dart';
import 'load.dart';
@@ -51,6 +52,39 @@
}
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 {
@@ -113,6 +147,11 @@
return;
}
}
+
+ String? completeCommand(CliState state, String text) {
+ return tryCompleteFileSystemEntity(
+ text, (filename) => filename.endsWith('.heapsnapshot'));
+ }
}
class StatsCommand extends SnapshotCommand {
@@ -145,6 +184,10 @@
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 {
@@ -176,6 +219,10 @@
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 {
@@ -255,6 +302,10 @@
state.output.print('');
}
}
+
+ String? completeCommand(CliState state, String text) {
+ return _completeOptions(text) ?? _completeExpression(state, text);
+ }
}
class ExamineCommand extends SnapshotCommand {
@@ -294,6 +345,10 @@
if (++i >= limit) break;
}
}
+
+ String? completeCommand(CliState state, String text) {
+ return _completeOptions(text) ?? _completeExpression(state, text);
+ }
}
class EvaluateCommand extends SnapshotCommand {
@@ -320,6 +375,10 @@
}
state.output.print(' $name {#${oids.length}}');
}
+
+ String? completeCommand(CliState state, String text) {
+ return _completeExpression(state, text);
+ }
}
class DescFilterCommand extends SnapshotCommand {
@@ -383,6 +442,31 @@
await defaultCommand.execute(state, args);
}
+
+ 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 {
@@ -416,3 +500,11 @@
ExamineCommand(),
DescFilterCommand(),
], EvaluateCommand());
+
+class CompletionCollector extends Output {
+ String? suggestedCompletion;
+
+ void suggestCompletion(String text) {
+ suggestedCompletion = text;
+ }
+}
diff --git a/runtime/tools/heapsnapshot/lib/src/completion.dart b/runtime/tools/heapsnapshot/lib/src/completion.dart
new file mode 100644
index 0000000..d1d0a7b
--- /dev/null
+++ b/runtime/tools/heapsnapshot/lib/src/completion.dart
@@ -0,0 +1,75 @@
+// 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 'package:path/path.dart' as path;
+
+class PostfixCompleter {
+ final String text;
+
+ PostfixCompleter(this.text);
+
+ String? tryComplete(String partial, List<String> candidates) {
+ if (!text.endsWith(partial)) throw 'caller error';
+ final completion = _selectCandidate(partial, candidates);
+ if (completion != null) {
+ return text.substring(0, text.length - partial.length) + completion;
+ }
+ return null;
+ }
+}
+
+String? _selectCandidate(String prefix, List<String> candidates) {
+ candidates = candidates
+ .where((c) => prefix.length < c.length && c.startsWith(prefix))
+ .toList();
+ if (candidates.isEmpty) return null;
+ candidates.sort((a, b) => b.length - a.length);
+ return candidates.first;
+}
+
+final homePath = Platform.environment['HOME']!;
+
+String? tryCompleteFileSystemEntity(
+ String incompleteFilePattern, bool Function(String) consider) {
+ if (incompleteFilePattern.isEmpty) return null;
+
+ final filename = incompleteFilePattern.endsWith(path.separator)
+ ? ''
+ : path.basename(incompleteFilePattern);
+ final dirname = incompleteFilePattern.substring(
+ 0, incompleteFilePattern.length - filename.length);
+
+ final dir = dirname != ''
+ ? Directory(dirname.startsWith('~/')
+ ? (homePath + dirname.substring(1))
+ : dirname)
+ : Directory.current;
+
+ if (dir.existsSync()) {
+ final entries = dir
+ .listSync()
+ .where((fse) => fse is File && consider(fse.path) || fse is Directory)
+ .map((fse) => path.basename(fse.path))
+ .toList();
+ final pc = PostfixCompleter(incompleteFilePattern);
+ return pc.tryComplete(filename, entries);
+ }
+
+ return null;
+}
+
+final _spaceCodeUnit = ' '.codeUnitAt(0);
+
+String getFirstWordWithSpaces(String text) {
+ int i = 0;
+ while (i < text.length && text.codeUnitAt(i) != _spaceCodeUnit) i++;
+ while (i < text.length && text.codeUnitAt(i) == _spaceCodeUnit) i++;
+ return text.substring(0, i);
+}
+
+String getLastWord(String text) {
+ return text.substring(text.lastIndexOf(' ') + 1);
+}
diff --git a/runtime/tools/heapsnapshot/lib/src/console.dart b/runtime/tools/heapsnapshot/lib/src/console.dart
new file mode 100644
index 0000000..2bcdc86
--- /dev/null
+++ b/runtime/tools/heapsnapshot/lib/src/console.dart
@@ -0,0 +1,292 @@
+// 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:math' show min;
+
+export 'package:dart_console/dart_console.dart';
+import 'package:dart_console/dart_console.dart';
+
+class SmartConsole extends Console {
+ final ScrollbackBuffer history;
+ late final List<KeyHandler> handlers;
+ KeyHandler? completionHandler;
+
+ SmartConsole({this.completionHandler})
+ : history = ScrollbackBuffer(recordBlanks: false) {
+ handlers = [
+ BashNavigationKeyHandler(),
+ BashHistoryKeyHandler(history),
+ BashEditKeyHandler(),
+ ];
+ }
+
+ ReadLineResult smartReadLine() {
+ final buffer = LineEditBuffer();
+
+ int promptRow = cursorPosition!.row;
+
+ cursorPosition = Coordinate(promptRow, 0);
+ drawPrompt(buffer);
+
+ while (true) {
+ final key = readKey();
+
+ bool wasHandled = false;
+ for (final handler in handlers) {
+ if (handler.handleKey(buffer, key)) {
+ wasHandled = true;
+ break;
+ }
+ }
+ if (completionHandler != null &&
+ completionHandler!.handleKey(buffer, key)) {
+ wasHandled = true;
+ }
+
+ if (!wasHandled && key.isControl) {
+ switch (key.controlChar) {
+ // Accept
+ case ControlCharacter.enter:
+ history.add(buffer.text);
+ writeLine();
+ return ReadLineResult(buffer.text, false, false);
+
+ // Cancel this line.
+ case ControlCharacter.ctrlC:
+ return ReadLineResult('', true, false);
+
+ // EOF
+ case ControlCharacter.ctrlD:
+ if (!buffer.text.isEmpty) break;
+ return ReadLineResult('', true, true);
+
+ // Ignore.
+ default:
+ break;
+ }
+ }
+
+ cursorPosition = Coordinate(promptRow, 0);
+ drawPrompt(buffer);
+ }
+ }
+
+ void drawPrompt(LineEditBuffer buffer) {
+ const prefix = '(hsa) ';
+
+ final row = cursorPosition!.row;
+
+ setForegroundColor(ConsoleColor.brightBlue);
+ write(prefix);
+ resetColorAttributes();
+ setForegroundColor(ConsoleColor.brightGreen);
+
+ eraseCursorToEnd();
+ if (buffer.completionText.isNotEmpty) {
+ write(buffer.text.substring(0, buffer.index));
+ setForegroundColor(ConsoleColor.brightWhite);
+ write(buffer.completionText);
+ setForegroundColor(ConsoleColor.brightGreen);
+ write(buffer.text.substring(buffer.index));
+ } else {
+ write(buffer.text);
+ }
+
+ cursorPosition = Coordinate(row, prefix.length + buffer.index);
+ }
+}
+
+class ReadLineResult {
+ final String text;
+ final bool wasCancelled;
+ final bool shouldExit;
+
+ ReadLineResult(this.text, this.wasCancelled, this.shouldExit);
+}
+
+/// Handler of a new key stroke when editing a line.
+abstract class KeyHandler {
+ bool handleKey(LineEditBuffer buffer, Key key);
+}
+
+/// Handles cursor navigation.
+class BashNavigationKeyHandler extends KeyHandler {
+ bool handleKey(LineEditBuffer buffer, Key key) {
+ if (!key.isControl) return false;
+
+ switch (key.controlChar) {
+ case ControlCharacter.arrowLeft:
+ case ControlCharacter.ctrlB:
+ buffer.moveLeft();
+ return true;
+ case ControlCharacter.arrowRight:
+ case ControlCharacter.ctrlF:
+ buffer.moveRight();
+ return true;
+ case ControlCharacter.wordLeft:
+ buffer.moveWordLeft();
+ return true;
+ case ControlCharacter.wordRight:
+ buffer.moveWordRight();
+ return true;
+ case ControlCharacter.home:
+ case ControlCharacter.ctrlA:
+ buffer.moveStart();
+ return true;
+ case ControlCharacter.end:
+ case ControlCharacter.ctrlE:
+ buffer.moveEnd();
+ return true;
+ default:
+ return false;
+ }
+ }
+}
+
+/// Handles history navigation.
+class BashHistoryKeyHandler extends KeyHandler {
+ ScrollbackBuffer history;
+
+ BashHistoryKeyHandler(this.history);
+
+ bool handleKey(LineEditBuffer buffer, Key key) {
+ if (!key.isControl) return false;
+
+ switch (key.controlChar) {
+ case ControlCharacter.ctrlP:
+ case ControlCharacter.arrowUp:
+ buffer.replaceWith(history.up(buffer.text));
+ return true;
+ case ControlCharacter.ctrlN:
+ case ControlCharacter.arrowDown:
+ final temp = history.down();
+ if (temp != null) {
+ buffer.replaceWith(temp);
+ }
+ return true;
+ default:
+ return false;
+ }
+ }
+}
+
+/// Handles text edits.
+class BashEditKeyHandler extends KeyHandler {
+ bool handleKey(LineEditBuffer buffer, Key key) {
+ if (!key.isControl) {
+ buffer.insert(key.char);
+ return true;
+ }
+
+ // TODO: Add support for <alt-d> (delete word).
+ switch (key.controlChar) {
+ case ControlCharacter.backspace:
+ case ControlCharacter.ctrlH:
+ buffer.backspace();
+ return true;
+ case ControlCharacter.ctrlU:
+ buffer.truncateLeft();
+ return true;
+ case ControlCharacter.ctrlK:
+ buffer.truncateRight();
+ return true;
+ case ControlCharacter.delete:
+ case ControlCharacter.ctrlD:
+ final wasDeleted = buffer.delete();
+ return wasDeleted;
+ default:
+ return false;
+ }
+ }
+}
+
+/// Represents the state of [Console.readLine] while editing the line.
+class LineEditBuffer {
+ /// The _text that was so far entered.
+ String _text = '';
+
+ /// The _index into [_text] where the editing cursor is currently being drawn.
+ int _index = 0;
+
+ /// The _text to display as inline completion.
+ String completionText = '';
+
+ LineEditBuffer();
+
+ String get text => _text;
+
+ int get index => _index;
+
+ void moveWordLeft() {
+ if (_index > 0) {
+ final textLeftOfCursor = _text.substring(0, _index - 1);
+ final lastSpace = textLeftOfCursor.lastIndexOf(' ');
+ _index = lastSpace != -1 ? lastSpace + 1 : 0;
+ }
+ }
+
+ void moveWordRight() {
+ if (_index < _text.length) {
+ final textRightOfCursor = _text.substring(_index + 1);
+ final nextSpace = textRightOfCursor.indexOf(' ');
+ _index = nextSpace != -1
+ ? min(_index + nextSpace + 2, _text.length)
+ : _text.length;
+ }
+ }
+
+ void moveLeft() {
+ if (_index > 0) _index--;
+ }
+
+ void moveRight() {
+ if (_index < _text.length) _index++;
+ }
+
+ void moveStart() {
+ _index = 0;
+ }
+
+ void moveEnd() {
+ _index = _text.length;
+ }
+
+ void replaceWith(String newtext) {
+ _text = newtext;
+ _index = _text.length;
+ }
+
+ void truncateRight() {
+ _text = _text.substring(0, _index);
+ }
+
+ void truncateLeft() {
+ _text = _text.substring(_index, _text.length);
+ _index = 0;
+ }
+
+ void backspace() {
+ if (_index > 0) {
+ _text = _text.substring(0, _index - 1) + _text.substring(_index);
+ _index--;
+ }
+ }
+
+ bool delete() {
+ if (_index < _text.length) {
+ _text = _text.substring(0, _index) + _text.substring(_index + 1);
+ return true;
+ }
+ return false;
+ }
+
+ void insert(String chars) {
+ if (_index == _text.length) {
+ _text += chars;
+ } else {
+ _text = _text.substring(0, _index) + chars + _text.substring(_index);
+ }
+ _index += chars.length;
+ }
+}
diff --git a/runtime/tools/heapsnapshot/lib/src/expression.dart b/runtime/tools/heapsnapshot/lib/src/expression.dart
index ec8c060..d001ed2 100644
--- a/runtime/tools/heapsnapshot/lib/src/expression.dart
+++ b/runtime/tools/heapsnapshot/lib/src/expression.dart
@@ -7,6 +7,7 @@
import 'package:vm_service/vm_service.dart';
import 'analysis.dart';
+import 'completion.dart';
abstract class SetExpression {
Set<int>? evaluate(NamedSets namedSets, Analysis analysis, Output output);
@@ -453,6 +454,17 @@
}
if (!namedSets.contains(current)) {
output.printError('There is no set with name "$current". See `info`.');
+
+ // We're at the end - it may be beneficial to suggest completion.
+ if (tokens.isAtEnd && tokens._text.endsWith(current)) {
+ final pc = PostfixCompleter(tokens._text);
+ final candidate = pc.tryComplete(current, namedSets.toList()) ??
+ pc.tryComplete(current, parsingFunctions.keys.toList());
+ if (candidate != null) {
+ output.suggestCompletion(candidate);
+ }
+ }
+
return null;
}
return NamedExpression(current);
@@ -632,6 +644,7 @@
abstract class Output {
void print(String message) {}
void printError(String message) {}
+ void suggestCompletion(String text) {}
}
const dslDescription = '''
diff --git a/runtime/tools/heapsnapshot/test/completion_test.dart b/runtime/tools/heapsnapshot/test/completion_test.dart
new file mode 100644
index 0000000..d2d07d9
--- /dev/null
+++ b/runtime/tools/heapsnapshot/test/completion_test.dart
@@ -0,0 +1,91 @@
+// 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 'package:heapsnapshot/heapsnapshot.dart';
+import 'package:heapsnapshot/src/cli.dart';
+import 'package:path/path.dart' as path;
+
+import 'package:test/test.dart';
+
+class FakeAnalysis implements Analysis {
+ @override
+ Set<int> get roots => <int>{1};
+
+ @override
+ dynamic noSuchMethod(Invocation i) {}
+}
+
+main() {
+ late CliState cliState;
+
+ String? complete(String text) =>
+ cliCommandRunner.completeCommand(cliState, text);
+
+ group('cli-completion no snapshot loaded', () {
+ setUp(() {
+ cliState = CliState(CompletionCollector());
+ });
+
+ // <...incomplete-load-command...>
+ test('complete load command', () {
+ expect(complete('l'), 'load');
+ });
+
+ // <...incomplete-stats-command...> fails
+ test('complete stats command fails', () {
+ // Since there was no snapshot loaded, commands operating on loaded
+ // snapshot should not auto-complete yet.
+ expect(complete('s'), null);
+ });
+
+ // load <...incomplete-file...>
+ test('complete incomplete file', () {
+ final snapshotDir = Directory.systemTemp.createTempSync('snapshot');
+ try {
+ final file = path.join(snapshotDir.path, 'foobar.heapsnapshot');
+ File(file).createSync();
+
+ // Ensure auto-complete works for files.
+ expect(complete('load ${path.join(snapshotDir.path, 'fo')}'),
+ 'load $file');
+ } finally {
+ snapshotDir.deleteSync(recursive: true);
+ }
+ });
+ });
+
+ group('cli-completion snapshot loaded', () {
+ setUp(() {
+ cliState = CliState(CompletionCollector());
+ cliState.initialize(FakeAnalysis());
+ });
+
+ // <...incomplete-command...>
+ test('complete command', () {
+ expect(complete('s'), 'stats');
+ });
+
+ // <command> <...incomplete-option...>
+ test('complete command short option', () {
+ expect(complete('stats -'), 'stats -c');
+ });
+ test('complete command long option', () {
+ expect(complete('stats --m'), 'stats --max');
+ });
+
+ // <command> <...incomplete-args...>
+ test('complete command arg', () {
+ cliState.namedSets.nameSet({1}, 'foobar');
+ expect(complete('stats f'), 'stats foobar');
+ });
+
+ // <expr>
+ test('complete default eval command', () {
+ cliState.namedSets.nameSet({1}, 'foobar');
+ expect(complete('foo'), 'foobar');
+ });
+ });
+}