blob: 965b86be0f71b458ba8e67debb7cedbfc4b271ad [file] [log] [blame]
// Copyright (c) 2015, 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.
/**
* A stress test for the analysis server.
*/
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:analysis_server/protocol/protocol_generated.dart';
import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/error/listener.dart' as error;
import 'package:analyzer/src/dart/scanner/reader.dart';
import 'package:analyzer/src/dart/scanner/scanner.dart';
import 'package:analyzer/src/generated/java_engine.dart';
import 'package:analyzer/src/generated/source.dart';
import 'package:analyzer/src/util/glob.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:args/args.dart';
import 'package:path/path.dart' as path;
import '../utilities/git.dart';
import '../utilities/logger.dart';
import '../utilities/server.dart';
import 'operation.dart';
/**
* Run the simulation based on the given command-line [arguments].
*/
Future<void> main(List<String> arguments) async {
Driver driver = new Driver();
await driver.run(arguments);
}
/**
* The driver class that runs the simulation.
*/
class Driver {
/**
* The value of the [OVERLAY_STYLE_OPTION_NAME] indicating that modifications
* to a file should be represented by an add overlay, followed by zero or more
* change overlays, followed by a remove overlay.
*/
static String CHANGE_OVERLAY_STYLE = 'change';
/**
* The name of the command-line flag that will print help text.
*/
static String HELP_FLAG_NAME = 'help';
/**
* The value of the [OVERLAY_STYLE_OPTION_NAME] indicating that modifications
* to a file should be represented by an add overlay, followed by zero or more
* additional add overlays, followed by a remove overlay.
*/
static String MULTIPLE_ADD_OVERLAY_STYLE = 'multipleAdd';
/**
* The name of the command-line option used to specify the style of
* interaction to use when making `analysis.updateContent` requests.
*/
static String OVERLAY_STYLE_OPTION_NAME = 'overlay-style';
/**
* The name of the pubspec file.
*/
static const String PUBSPEC_FILE_NAME = 'pubspec.yaml';
/**
* The name of the branch used to clean-up after making temporary changes.
*/
static const String TEMP_BRANCH_NAME = 'temp';
/**
* The name of the command-line flag that will cause verbose output to be
* produced.
*/
static String VERBOSE_FLAG_NAME = 'verbose';
/**
* The style of interaction to use for analysis.updateContent requests.
*/
OverlayStyle overlayStyle;
/**
* The absolute path of the repository.
*/
String repositoryPath;
/**
* The absolute paths to the analysis roots.
*/
List<String> analysisRoots;
/**
* The git repository.
*/
GitRepository repository;
/**
* The connection to the analysis server.
*/
Server server;
/**
* A list of the glob patterns used to identify the files being analyzed by
* the server.
*/
List<Glob> fileGlobs;
/**
* An object gathering statistics about the simulation.
*/
Statistics statistics;
/**
* A flag indicating whether verbose output should be provided.
*/
bool verbose = false;
/**
* The logger to which verbose logging data will be written.
*/
Logger logger;
/**
* Initialize a newly created driver.
*/
Driver() {
statistics = new Statistics(this);
}
/**
* Allow the output from the server to be read and processed.
*/
Future<void> readServerOutput() async {
await new Future.delayed(new Duration(milliseconds: 2));
}
/**
* Run the simulation based on the given command-line arguments ([args]).
*/
Future<void> run(List<String> args) async {
//
// Process the command-line arguments.
//
if (!_processCommandLine(args)) {
return null;
}
if (verbose) {
stdout.writeln();
stdout.writeln('-' * 80);
stdout.writeln();
}
//
// Simulate interactions with the server.
//
await _runSimulation();
//
// Print out statistics gathered while performing the simulation.
//
if (verbose) {
stdout.writeln();
stdout.writeln('-' * 80);
}
stdout.writeln();
statistics.print();
if (verbose) {
stdout.writeln();
server.printStatistics();
}
exit(0);
return null;
}
/**
* Create and return a parser that can be used to parse the command-line
* arguments.
*/
ArgParser _createArgParser() {
ArgParser parser = new ArgParser();
parser.addFlag(HELP_FLAG_NAME,
abbr: 'h',
help: 'Print usage information',
defaultsTo: false,
negatable: false);
parser.addOption(OVERLAY_STYLE_OPTION_NAME,
help:
'The style of interaction to use for analysis.updateContent requests',
allowed: [CHANGE_OVERLAY_STYLE, MULTIPLE_ADD_OVERLAY_STYLE],
allowedHelp: {
CHANGE_OVERLAY_STYLE: '<add> <change>* <remove>',
MULTIPLE_ADD_OVERLAY_STYLE: '<add>+ <remove>'
},
defaultsTo: 'change');
parser.addFlag(VERBOSE_FLAG_NAME,
abbr: 'v',
help: 'Produce verbose output for debugging',
defaultsTo: false,
negatable: false);
return parser;
}
/**
* Add source edits to the given [fileEdit] based on the given [blobDiff].
*/
void _createSourceEdits(FileEdit fileEdit, BlobDiff blobDiff) {
LineInfo info = fileEdit.lineInfo;
for (DiffHunk hunk in blobDiff.hunks) {
int srcStart = info.getOffsetOfLine(hunk.srcLine);
int srcEnd = info.getOffsetOfLine(
math.min(hunk.srcLine + hunk.removeLines.length, info.lineCount - 1));
String addedText = _join(hunk.addLines);
//
// Create the source edits.
//
List<int> breakOffsets = _getBreakOffsets(addedText);
int breakCount = breakOffsets.length;
List<SourceEdit> sourceEdits = <SourceEdit>[];
if (breakCount == 0) {
sourceEdits
.add(new SourceEdit(srcStart, srcEnd - srcStart + 1, addedText));
} else {
int previousOffset = breakOffsets[0];
String string = addedText.substring(0, previousOffset);
sourceEdits
.add(new SourceEdit(srcStart, srcEnd - srcStart + 1, string));
String reconstruction = string;
for (int i = 1; i < breakCount; i++) {
int offset = breakOffsets[i];
string = addedText.substring(previousOffset, offset);
reconstruction += string;
sourceEdits.add(new SourceEdit(srcStart + previousOffset, 0, string));
previousOffset = offset;
}
string = addedText.substring(previousOffset);
reconstruction += string;
sourceEdits.add(new SourceEdit(srcStart + previousOffset, 0, string));
if (reconstruction != addedText) {
throw new AssertionError();
}
}
fileEdit.addSourceEdits(sourceEdits);
}
}
/**
* Return the absolute paths of all of the pubspec files in all of the
* analysis roots.
*/
Iterable<String> _findPubspecsInAnalysisRoots() {
List<String> pubspecFiles = <String>[];
for (String directoryPath in analysisRoots) {
Directory directory = new Directory(directoryPath);
List<FileSystemEntity> children =
directory.listSync(recursive: true, followLinks: false);
for (FileSystemEntity child in children) {
String filePath = child.path;
if (path.basename(filePath) == PUBSPEC_FILE_NAME) {
pubspecFiles.add(filePath);
}
}
}
return pubspecFiles;
}
/**
* Return a list of offsets into the given [text] that represent good places
* to break the text when building edits.
*/
List<int> _getBreakOffsets(String text) {
List<int> breakOffsets = <int>[];
Scanner scanner = new Scanner(null, new CharSequenceReader(text),
error.AnalysisErrorListener.NULL_LISTENER)
..configureFeatures(FeatureSet.forTesting(sdkVersion: '2.2.2'));
Token token = scanner.tokenize();
// TODO(brianwilkerson) Randomize. Sometimes add zero (0) as a break point.
while (token.type != TokenType.EOF) {
// TODO(brianwilkerson) Break inside comments?
// Token comment = token.precedingComments;
int offset = token.offset;
int length = token.length;
breakOffsets.add(offset);
if (token.type == TokenType.IDENTIFIER && length > 3) {
breakOffsets.add(offset + (length ~/ 2));
}
token = token.next;
}
return breakOffsets;
}
/**
* Join the given [lines] into a single string.
*/
String _join(List<String> lines) {
StringBuffer buffer = new StringBuffer();
for (int i = 0; i < lines.length; i++) {
buffer.writeln(lines[i]);
}
return buffer.toString();
}
/**
* Process the command-line [arguments]. Return `true` if the simulation
* should be run.
*/
bool _processCommandLine(List<String> args) {
ArgParser parser = _createArgParser();
ArgResults results;
try {
results = parser.parse(args);
} catch (exception) {
_showUsage(parser);
return false;
}
if (results[HELP_FLAG_NAME]) {
_showUsage(parser);
return false;
}
String overlayStyleValue = results[OVERLAY_STYLE_OPTION_NAME];
if (overlayStyleValue == CHANGE_OVERLAY_STYLE) {
overlayStyle = OverlayStyle.change;
} else if (overlayStyleValue == MULTIPLE_ADD_OVERLAY_STYLE) {
overlayStyle = OverlayStyle.multipleAdd;
}
if (results[VERBOSE_FLAG_NAME]) {
verbose = true;
logger = new Logger(stdout);
}
List<String> arguments = results.rest;
if (arguments.length < 2) {
_showUsage(parser);
return false;
}
repositoryPath = path.normalize(arguments[0]);
repository = new GitRepository(repositoryPath, logger: logger);
analysisRoots = arguments
.sublist(1)
.map((String analysisRoot) => path.normalize(analysisRoot))
.toList();
for (String analysisRoot in analysisRoots) {
if (repositoryPath != analysisRoot &&
!path.isWithin(repositoryPath, analysisRoot)) {
_showUsage(parser,
'Analysis roots must be contained within the repository: $analysisRoot');
return false;
}
}
return true;
}
/**
* Replay the changes in each commit.
*/
Future<void> _replayChanges() async {
//
// Get the revision history of the repo.
//
LinearCommitHistory history = repository.getCommitHistory();
statistics.commitCount = history.commitIds.length;
LinearCommitHistoryIterator iterator = history.iterator();
try {
//
// Iterate over the history, applying changes.
//
bool firstCheckout = true;
ErrorMap expectedErrors = null;
Iterable<String> changedPubspecs;
while (iterator.moveNext()) {
//
// Checkout the commit on which the changes are based.
//
String commit = iterator.srcCommit;
repository.checkout(commit);
if (expectedErrors != null) {
// ErrorMap actualErrors =
await server.computeErrorMap(server.analyzedDartFiles);
// String difference = expectedErrors.expectErrorMap(actualErrors);
// if (difference != null) {
// stdout.write('Mismatched errors after commit ');
// stdout.writeln(commit);
// stdout.writeln();
// stdout.writeln(difference);
// return;
// }
}
if (firstCheckout) {
changedPubspecs = _findPubspecsInAnalysisRoots();
server.sendAnalysisSetAnalysisRoots(analysisRoots, []);
firstCheckout = false;
} else {
server.removeAllOverlays();
}
await readServerOutput();
expectedErrors = await server.computeErrorMap(server.analyzedDartFiles);
for (String filePath in changedPubspecs) {
_runPub(filePath);
}
//
// Apply the changes.
//
CommitDelta commitDelta = iterator.next();
commitDelta.filterDiffs(analysisRoots, fileGlobs);
if (commitDelta.hasDiffs) {
statistics.commitsWithChangeInRootCount++;
await _replayDiff(commitDelta);
}
changedPubspecs = commitDelta.filesMatching(PUBSPEC_FILE_NAME);
}
} finally {
// Ensure that the repository is left at the most recent commit.
if (history.commitIds.isNotEmpty) {
repository.checkout(history.commitIds[0]);
}
}
server.removeAllOverlays();
await readServerOutput();
stdout.writeln();
}
/**
* Replay the changes between two commits, as represented by the given
* [commitDelta].
*/
Future<void> _replayDiff(CommitDelta commitDelta) async {
List<FileEdit> editList = <FileEdit>[];
for (DiffRecord record in commitDelta.diffRecords) {
FileEdit edit = new FileEdit(overlayStyle, record);
_createSourceEdits(edit, record.getBlobDiff());
editList.add(edit);
}
//
// TODO(brianwilkerson) Randomize.
// Randomly select operations from different files to simulate a user
// editing multiple files simultaneously.
//
for (FileEdit edit in editList) {
List<String> currentFile = <String>[edit.filePath];
server.sendAnalysisSetPriorityFiles(currentFile);
server.sendAnalysisSetSubscriptions({
AnalysisService.FOLDING: currentFile,
AnalysisService.HIGHLIGHTS: currentFile,
AnalysisService.IMPLEMENTED: currentFile,
AnalysisService.NAVIGATION: currentFile,
AnalysisService.OCCURRENCES: currentFile,
AnalysisService.OUTLINE: currentFile,
AnalysisService.OVERRIDES: currentFile
});
for (ServerOperation operation in edit.getOperations()) {
statistics.editCount++;
operation.perform(server);
await readServerOutput();
}
}
}
/**
* Run `pub` on the pubspec with the given [filePath].
*/
void _runPub(String filePath) {
String directoryPath = path.dirname(filePath);
if (new Directory(directoryPath).existsSync()) {
Process.runSync(
'/Users/brianwilkerson/Dev/dart/dart-sdk/bin/pub', ['get'],
workingDirectory: directoryPath);
}
}
/**
* Run the simulation by starting up a server and sending it requests.
*/
Future<void> _runSimulation() async {
server = new Server(logger: logger);
Stopwatch stopwatch = new Stopwatch();
statistics.stopwatch = stopwatch;
stopwatch.start();
await server.start();
server.sendServerSetSubscriptions([ServerService.STATUS]);
server.sendAnalysisSetGeneralSubscriptions(
[GeneralAnalysisService.ANALYZED_FILES]);
// TODO(brianwilkerson) Get the list of glob patterns from the server after
// an API for getting them has been implemented.
fileGlobs = <Glob>[
new Glob(path.context.separator, '**.dart'),
new Glob(path.context.separator, '**.html'),
new Glob(path.context.separator, '**.htm'),
new Glob(path.context.separator, '**/.analysisOptions')
];
try {
await _replayChanges();
} finally {
// TODO(brianwilkerson) This needs to be moved into a Zone in order to
// ensure that it is always run.
server.sendServerShutdown();
repository.checkout('master');
}
stopwatch.stop();
}
/**
* Display usage information, preceded by the [errorMessage] if one is given.
*/
void _showUsage(ArgParser parser, [String errorMessage = null]) {
if (errorMessage != null) {
stderr.writeln(errorMessage);
stderr.writeln();
}
stderr.writeln('''
Usage: replay [options...] repositoryPath analysisRoot...
Uses the commit history of the git repository at the given repository path to
simulate the development of a code base while using the analysis server to
analyze the code base.
The repository path must be the absolute path of a directory containing a git
repository.
There must be at least one analysis root, and all of the analysis roots must be
the absolute path of a directory contained within the repository directory. The
analysis roots represent the portions of the repository that will be analyzed by
the analysis server.
OPTIONS:''');
stderr.writeln(parser.usage);
}
}
/**
* A representation of the edits to be applied to a single file.
*/
class FileEdit {
/**
* The style of interaction to use for analysis.updateContent requests.
*/
OverlayStyle overlayStyle;
/**
* The absolute path of the file to be edited.
*/
String filePath;
/**
* The content of the file before any edits have been applied.
*/
String content;
/**
* The line info for the file before any edits have been applied.
*/
LineInfo lineInfo;
/**
* The lists of source edits, one list for each hunk being edited.
*/
List<List<SourceEdit>> editLists = <List<SourceEdit>>[];
/**
* The current content of the file. This field is only used if the overlay
* style is [OverlayStyle.multipleAdd].
*/
String currentContent;
/**
* Initialize a collection of edits to be associated with the file at the
* given [filePath].
*/
FileEdit(this.overlayStyle, DiffRecord record) {
filePath = record.srcPath;
if (record.isAddition) {
content = '';
lineInfo = new LineInfo(<int>[0]);
} else if (record.isCopy || record.isRename || record.isTypeChange) {
throw new ArgumentError('Unhandled change of type ${record.status}');
} else {
content = new File(filePath).readAsStringSync();
lineInfo = new LineInfo(StringUtilities.computeLineStarts(content));
}
currentContent = content;
}
/**
* Add a list of source edits that, taken together, transform a single hunk in
* the file.
*/
void addSourceEdits(List<SourceEdit> sourceEdits) {
editLists.add(sourceEdits);
}
/**
* Return a list of operations to be sent to the server.
*/
List<ServerOperation> getOperations() {
List<ServerOperation> operations = <ServerOperation>[];
void addUpdateContent(var overlay) {
operations.add(new Analysis_UpdateContent(filePath, overlay));
}
// TODO(brianwilkerson) Randomize.
// Make the order of edits random. Doing so will require updating the
// offsets of edits after the selected edit point.
addUpdateContent(new AddContentOverlay(content));
for (List<SourceEdit> editList in editLists.reversed) {
for (SourceEdit edit in editList.reversed) {
var overlay = null;
if (overlayStyle == OverlayStyle.change) {
overlay = new ChangeContentOverlay([edit]);
} else if (overlayStyle == OverlayStyle.multipleAdd) {
currentContent = edit.apply(currentContent);
overlay = new AddContentOverlay(currentContent);
} else {
throw new StateError(
'Failed to handle overlay style = $overlayStyle');
}
if (overlay != null) {
addUpdateContent(overlay);
}
}
}
addUpdateContent(new RemoveContentOverlay());
return operations;
}
}
/**
* The possible styles of interaction to use for analysis.updateContent requests.
*/
enum OverlayStyle { change, multipleAdd }
/**
* A set of statistics related to the execution of the simulation.
*/
class Statistics {
/**
* The driver driving the simulation.
*/
final Driver driver;
/**
* The stopwatch being used to time the simulation.
*/
Stopwatch stopwatch;
/**
* The total number of commits in the repository.
*/
int commitCount;
/**
* The number of commits in the repository that touched one of the files in
* one of the analysis roots.
*/
int commitsWithChangeInRootCount = 0;
/**
* The total number of edits that were applied.
*/
int editCount = 0;
/**
* Initialize a newly created set of statistics.
*/
Statistics(this.driver);
/**
* Print the statistics to [stdout].
*/
void print() {
stdout.write('Replay commits in ');
stdout.writeln(driver.repositoryPath);
stdout.write(' replay took ');
stdout.writeln(_printTime(stopwatch.elapsedMilliseconds));
stdout.write(' analysis roots = ');
stdout.writeln(driver.analysisRoots);
stdout.write(' number of commits = ');
stdout.writeln(commitCount);
stdout.write(' number of commits with a change in an analysis root = ');
stdout.writeln(commitsWithChangeInRootCount);
stdout.write(' number of edits = ');
stdout.writeln(editCount);
}
/**
* Return a textual representation of the given duration, represented in
* [milliseconds].
*/
String _printTime(int milliseconds) {
int seconds = milliseconds ~/ 1000;
milliseconds -= seconds * 1000;
int minutes = seconds ~/ 60;
seconds -= minutes * 60;
int hours = minutes ~/ 60;
minutes -= hours * 60;
if (hours > 0) {
return '$hours:$minutes:$seconds.$milliseconds';
} else if (minutes > 0) {
return '$minutes:$seconds.$milliseconds';
}
return '$seconds.$milliseconds';
}
}