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