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>
This commit is contained in:
Martin Kustermann 2022-10-03 14:58:58 +00:00 committed by Commit Queue
parent f7808a1aeb
commit aa4339a9f3
7 changed files with 609 additions and 16 deletions

View file

@ -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 @@ main(List<String> args) {
return;
}
cli_test.main(args);
expr_test.main(args);
cli_test.main();
expr_test.main();
comp_test.main();
}

View file

@ -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 ConsoleErrorPrinter extends Output {
}
}
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 @@ void main() async {
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)

View file

@ -10,6 +10,7 @@ import 'package:args/args.dart';
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 @@ abstract class Command {
}
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 @@ class LoadCommand extends Command {
return;
}
}
String? completeCommand(CliState state, String text) {
return tryCompleteFileSystemEntity(
text, (filename) => filename.endsWith('.heapsnapshot'));
}
}
class StatsCommand extends SnapshotCommand {
@ -145,6 +184,10 @@ class StatsCommand extends SnapshotCommand {
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 @@ class DataStatsCommand extends SnapshotCommand {
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 @@ class RetainingPathCommand extends SnapshotCommand {
state.output.print('');
}
}
String? completeCommand(CliState state, String text) {
return _completeOptions(text) ?? _completeExpression(state, text);
}
}
class ExamineCommand extends SnapshotCommand {
@ -294,6 +345,10 @@ class ExamineCommand extends SnapshotCommand {
if (++i >= limit) break;
}
}
String? completeCommand(CliState state, String text) {
return _completeOptions(text) ?? _completeExpression(state, text);
}
}
class EvaluateCommand extends SnapshotCommand {
@ -320,6 +375,10 @@ class EvaluateCommand extends SnapshotCommand {
}
state.output.print(' $name {#${oids.length}}');
}
String? completeCommand(CliState state, String text) {
return _completeExpression(state, text);
}
}
class DescFilterCommand extends SnapshotCommand {
@ -383,6 +442,31 @@ class CommandRunner {
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 @@ final cliCommandRunner = CommandRunner([
ExamineCommand(),
DescFilterCommand(),
], EvaluateCommand());
class CompletionCollector extends Output {
String? suggestedCompletion;
void suggestCompletion(String text) {
suggestedCompletion = text;
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -7,6 +7,7 @@ import 'dart:math' as math;
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 @@ SetExpression? parse(
}
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 @@ class NamedSets {
abstract class Output {
void print(String message) {}
void printError(String message) {}
void suggestCompletion(String text) {}
}
const dslDescription = '''

View file

@ -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');
});
});
}